From 628a54b5646ab92e70b1ddafd3000a2367d7bb94 Mon Sep 17 00:00:00 2001 From: Moiztermind Date: Sat, 10 Oct 2026 23:33:35 +0100 Subject: [PATCH 001/167] Print on Windows: printer list, driver Print Settings, GDI printing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - photocraft-winprint: an isolated unsafe crate (like photocraft-tablet) for Windows printing: printer list, the driver's Print Settings dialog, page geometry, and GDI printing with ICM off. - file.print prints on Windows (explicit "send": true only); new file.print.printers and file.print.page commands; printerSettings param. - Print dialog on Windows: Printer menu, Print Settings…, paper from the driver. - panic_hunt fuzzes print commands as dry runs. Closes #2697. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 9 + Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/print_cmds.rs | 110 +++++++- crates/engine/src/print_cmds/tests.rs | 35 +++ crates/engine/tests/panic_hunt.rs | 9 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/file_ui.rs | 113 +++++++- crates/winprint/Cargo.toml | 26 ++ crates/winprint/src/lib.rs | 263 ++++++++++++++++++ crates/winprint/src/windows.rs | 378 ++++++++++++++++++++++++++ xtask/src/layers.rs | 2 + 12 files changed, 933 insertions(+), 15 deletions(-) create mode 100644 crates/winprint/Cargo.toml create mode 100644 crates/winprint/src/lib.rs create mode 100644 crates/winprint/src/windows.rs diff --git a/Cargo.lock b/Cargo.lock index fde4ac01b8..80cfe99225 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4368,6 +4368,7 @@ dependencies = [ "photocraft-raw", "photocraft-text", "photocraft-vector", + "photocraft-winprint", "rayon", "roxmltree", "serde", @@ -4592,6 +4593,7 @@ dependencies = [ "photocraft-testkit", "photocraft-text", "photocraft-vector", + "photocraft-winprint", "rayon", "serde", "serde_json", @@ -4632,6 +4634,13 @@ dependencies = [ "web-sys", ] +[[package]] +name = "photocraft-winprint" +version = "0.7.0" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "pico-args" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 7502ff4fd5..ea82e615c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } +photocraft-winprint = { path = "crates/winprint" } # external thiserror = "2" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index fa15c30986..986ce8fc5c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,6 +19,7 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-ops = { workspace = true } photocraft-compose = { workspace = true } +photocraft-winprint = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 59d6b3e1e8..e3e520ff9e 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -353,6 +353,20 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { }; let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999); let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + // Windows has no `lp`: print through the driver (GDI, ICM off) instead of spooling a PDF. + // Only with an explicit `"send": true` (the dialog's Print button): a bare call, such as a + // fuzzer's or a script's `{}`, never puts paper through a printer. A dry run still renders + // the PDF and reports the `lp` line, as on every platform. + let explicit_send = p.get("send").and_then(Value::as_bool) == Some(true); + if cfg!(windows) && explicit_send && !dry && output.is_none() { + let printed = print_windows(&page, p, &doc.name, copies)?; + remember_print(s, p); + crate::automate_cmds::fire_event(s, "print"); + return Ok(json!({ + "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, + "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, + })); + } let pdf = print_pdf(&page); write_file(&pdf_path, &pdf)?; let mut argv: Vec = vec!["lp".into()]; @@ -386,6 +400,80 @@ fn remember_print(s: &mut Session, p: &Value) { s.file_menu.last_print = Some(remembered); } +/// The `printer` parameter, or the system's default printer. +fn printer_name(p: &Value) -> Result { + if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { + return Ok(name.to_string()); + } + photocraft_winprint::default_printer().map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set")) +} + +/// The driver settings chosen with Print Settings… (`printerSettings`, hex), when any. +fn printer_settings(p: &Value) -> Result> { + match p.get("printerSettings").and_then(Value::as_str).filter(|v| !v.is_empty()) { + Some(hex) => photocraft_winprint::DevMode::from_hex(hex).map(Some).map_err(|e| bad("file.print", e.to_string())), + None => Ok(None), + } +} + +/// The page's image as 8-bit RGB rows (gray expanded), mirrored for Emulsion Down. +pub fn page_rgb(page: &PrintPage) -> Result> { + let img = &page.image; + let (w, h) = (img.width as usize, img.height as usize); + let mut rgb = match img.channels { + 3 => img.data.clone(), + 1 => img.data.iter().flat_map(|g| [*g, *g, *g]).collect(), + _ => return Err(other("this printer profile is CMYK; Windows printers take RGB, so choose the printer's own (RGB) profile")), + }; + if rgb.len() != w * h * 3 { + return Err(other("nothing to print")); + } + if page.emulsion_down { + for row in rgb.chunks_exact_mut(w * 3) { + row.as_chunks_mut::<3>().0.reverse(); + } + } + Ok(rgb) +} + +/// The image box with its origin at the sheet's top-left (GDI's), mirrored for Emulsion Down. +pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) { + let (pw, ph) = page.paper; + let (x, y, w, h) = page.rect; + let x = if page.emulsion_down { pw - x - w } else { x }; + (x, ph - y - h, w, h) +} + +/// Prints `page` on a Windows printer. Printer marks, description and labels are PDF-only. +fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: i64) -> Result { + let printer = printer_name(p)?; + let settings = printer_settings(p)?; + let rgb = page_rgb(page)?; + let image = photocraft_winprint::RgbImage { width: page.image.width, height: page.image.height, data: &rgb }; + let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999) as u32 }; + let done = photocraft_winprint::print(&job, &image, page_rect_top_left(page)).map_err(other)?; + let marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration || page.description.is_some() || page.label.is_some(); + Ok( + json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3], "marksSkipped": marks}), + ) +} + +fn printers(_: &mut Session, _: &Value) -> Result { + match photocraft_winprint::printers() { + Ok(list) => Ok(json!({"supported": true, "printers": list.iter().map(|p| json!({"name": p.name, "default": p.default})).collect::>()})), + Err(photocraft_winprint::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})), + Err(e) => Err(other(e)), + } +} + +fn printer_page(_: &mut Session, p: &Value) -> Result { + let printer = printer_name(p)?; + let pg = photocraft_winprint::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?; + Ok( + json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}), + ) +} + #[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}")))?; @@ -567,9 +655,29 @@ 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),"printerSettings":hex? (the driver settings from Print Settings…, Windows),"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? (Windows prints to the printer only with an explicit true),"dryRun":bool=false (render the PDF, report the lp command, don't spool)} → {pdf, imageRect, command, sent}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), + CommandSpec { + id: "file.print.printers", + label: "Printers", + menu: &[], + shortcut: None, + params: r##"{} → {supported, printers: [{name, default}]} (supported: Windows; elsewhere Print spools with lp)"##, + enabled: |_| Ok(()), + journal: false, + run: printers, + }, + CommandSpec { + id: "file.print.page", + label: "Printer Page", + menu: &[], + shortcut: None, + params: r##"{"printer":name? (default printer),"printerSettings":hex?} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y]} (Windows)"##, + enabled: |_| Ok(()), + journal: false, + run: printer_page, + }, spec!( "file.printOneCopy", "Print One Copy", diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 877fb945ff..04102eb78c 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -133,6 +133,41 @@ fn emulsion_down_mirrors_the_whole_page() { } } +fn page_of(channels: usize, data: Vec, emulsion_down: bool) -> PrintPage { + PrintPage { + paper: (600.0, 800.0), + image: PrintImage { width: 2, height: 1, channels, data, icc: None }, + rect: (100.0, 200.0, 144.0, 72.0), + marks: Marks::default(), + description: None, + label: None, + emulsion_down, + } +} + +#[test] +fn windows_print_image_is_rgb_mirrored_for_emulsion_down() { + assert_eq!(page_rgb(&page_of(3, vec![1, 2, 3, 4, 5, 6], false)).unwrap(), [1, 2, 3, 4, 5, 6]); + assert_eq!(page_rgb(&page_of(3, vec![1, 2, 3, 4, 5, 6], true)).unwrap(), [4, 5, 6, 1, 2, 3]); + assert_eq!(page_rgb(&page_of(1, vec![7, 9], true)).unwrap(), [9, 9, 9, 7, 7, 7]); + assert!(page_rgb(&page_of(4, vec![0; 8], false)).is_err(), "CMYK can't go to a GDI printer"); + assert!(page_rgb(&page_of(3, vec![0; 5], false)).is_err(), "short data"); + // GDI's origin is the sheet's top-left; Emulsion Down moves the box to the mirrored side. + assert_eq!(page_rect_top_left(&page_of(3, vec![0; 6], false)), (100.0, 528.0, 144.0, 72.0)); + assert_eq!(page_rect_top_left(&page_of(3, vec![0; 6], true)), (356.0, 528.0, 144.0, 72.0)); +} + +#[test] +fn printer_commands_answer_without_a_document_and_reject_bad_settings() { + let mut s = Session::new(); + let r = s.execute("file.print.printers", json!({})).unwrap(); + assert_eq!(r["supported"], cfg!(windows)); + assert!(r["printers"].is_array()); + assert!(s.execute("file.print.page", json!({"printer": "Anything", "printerSettings": "zz"})).is_err()); + let mut s = session("rgb", 8); + assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err()); +} + #[test] fn printer_profile_can_be_an_installed_icc_file() { let dir = tmp("icc"); diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index b301b95389..d3b396f2b8 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -61,6 +61,15 @@ fn no_command_panics_or_hangs_on_adversarial_params() { let mut bad: Vec = Vec::new(); for spec in command_specs() { for p in ¶ms { + // Print and Print One Copy reach real printers (lp, or the Windows driver): fuzz them + // as dry runs so the hunt never prints a page. + let mut p = p.clone(); + if spec.id.starts_with("file.print") + && let Some(o) = p.as_object_mut() + { + o.insert("dryRun".into(), json!(true)); + } + let p = &p; if let Err(why) = run_guarded(spec.id, p) { bad.push(format!("{} [{why}] <- {p}", spec.id)); break; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a9af622286..1152a36680 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -12,6 +12,7 @@ craft-ui = { workspace = true } photocraft-geom = { workspace = true } photocraft-color = { workspace = true } photocraft-cms = { workspace = true } +photocraft-winprint = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 8b96a51a3d..323519c36b 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -593,9 +593,36 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { ] { f.entry(k.to_string()).or_insert(v); } + // Windows: the system's printers, and the paper from the chosen printer's driver settings. + if let Ok(list) = photocraft_winprint::printers() { + f.insert("__printers".into(), json!(list.iter().map(|p| p.name.clone()).collect::>())); + let known = |n: &str| list.iter().any(|p| p.name == n); + if !known(&s(&f, "printer", "")) { + let fallback = list.iter().find(|p| p.default).or(list.first()).map(|p| p.name.clone()).unwrap_or_default(); + f.insert("printer".into(), json!(fallback)); + f.remove("printerSettings"); + } + driver_page(&mut f); + } app.ui.open_dialog(DialogKind::Command, f) } +/// Takes the paper from the printer's driver settings (Windows), as Photoshop does. +fn driver_page(f: &mut Map) { + let printer = s(f, "printer", ""); + let settings = f.get("printerSettings").and_then(Value::as_str).and_then(|h| photocraft_winprint::DevMode::from_hex(h).ok()); + match photocraft_winprint::page(&printer, settings.as_ref()) { + Ok(pg) => { + f.insert("paper".into(), json!([pg.paper.0, pg.paper.1])); + f.insert("orientation".into(), json!("portrait")); + f.remove("__pageError"); + } + Err(e) => { + f.insert("__pageError".into(), json!(e.to_string())); + } + } +} + /// Points per unit for the Position and Size fields. fn unit_pt(units: &str) -> f64 { match units { @@ -702,19 +729,61 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map> = + f.get("__printers").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); + if let Some(printers) = &printers { + // Windows: Photoshop's Printer menu and Print Settings… (the driver's own dialog). + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim)); + let before = s(f, "printer", ""); + let opts: Vec<(&str, &str)> = printers.iter().map(|n| (n.as_str(), n.as_str())).collect(); + dropdown_str(ui, "print-printer", f, "printer", &opts, 220.0); + if s(f, "printer", "") != before { + f.remove("printerSettings"); + driver_page(f); + } + }); + ui.horizontal(|ui| { + number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0); + if ui.button(tl!("Print Settings…")).clicked() { + let printer = s(f, "printer", ""); + let current = f.get("printerSettings").and_then(Value::as_str).and_then(|h| photocraft_winprint::DevMode::from_hex(h).ok()); + match photocraft_winprint::settings_dialog(&printer, current.as_ref(), None) { + Ok(Some(dm)) => { + f.insert("printerSettings".into(), json!(dm.to_hex())); + driver_page(f); + } + Ok(None) => {} + Err(e) => { + f.insert("__pageError".into(), json!(e.to_string())); + } + } + } + }); + let paper = p.get("paper").and_then(Value::as_array).map(|a| (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64))); + let text = match paper { + Some((Some(w), Some(h))) => format!("{:.0} × {:.0} mm · {}", w / 72.0 * 25.4, h / 72.0 * 25.4, tl!("set in Print Settings…")), + _ => String::new(), + }; + ui.label(egui::RichText::new(text).color(t.text_dim).size(11.0)); + if let Some(e) = f.get("__pageError").and_then(Value::as_str) { + ui.label(egui::RichText::new(e).color(t.warning).size(11.0)); } - }); - number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0); - ui.horizontal(|ui| { - let papers: Vec<(&str, &str)> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0, p.0)).collect(); - dropdown_str(ui, "print-paper", f, "paper", &papers, 110.0); - dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0); - }); + } else { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim)); + let mut pr = s(f, "printer", ""); + if ui.add(egui::TextEdit::singleline(&mut pr).hint_text(tl!("Default printer")).desired_width(180.0)).changed() { + f.insert("printer".into(), json!(pr)); + } + }); + number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0); + ui.horizontal(|ui| { + let papers: Vec<(&str, &str)> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0, p.0)).collect(); + dropdown_str(ui, "print-paper", f, "paper", &papers, 110.0); + dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0); + }); + } head(ui, tl!("Color Management")); dropdown_str( ui, @@ -766,6 +835,11 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { - let r = app.run("file.print", params(f))?; - app.ui.status = if r["sent"] == json!(true) { + let mut p = params(f); + // The Print button prints (an empty Save as PDF path means the printer). + if s(f, "output", "").is_empty() { + p["send"] = json!(true); + } + let r = app.run("file.print", p)?; + app.ui.status = if let Some(printer) = r["spooler"]["printer"].as_str() { + let skipped = if r["spooler"]["marksSkipped"] == json!(true) { " (printer marks and labels are only in Save as PDF)" } else { "" }; + format!("Sent to {printer}{skipped}") + } else if r["sent"] == json!(true) { let template = "Sent to the printer ({spooler})"; crate::i18n::fmt(tl!(template), &[("spooler", r["spooler"].as_str().unwrap_or(""))]) } else { diff --git a/crates/winprint/Cargo.toml b/crates/winprint/Cargo.toml new file mode 100644 index 0000000000..09504156d5 --- /dev/null +++ b/crates/winprint/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "photocraft-winprint" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Windows printing: printer list, the driver's Print Settings dialog, page geometry and GDI printing with ICM off" + +# An isolated `unsafe` helper crate like photocraft-tablet (craftrules never-crash, AGENTS.md): +# Windows printing needs Win32 calls. Only `src/windows.rs` allows `unsafe`; every block has a +# `SAFETY:` comment. Elsewhere every function returns `Unsupported`. +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" + +# Already in Cargo.lock (winit/wgpu pull it in); pinned exactly: review the diff before bumping. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Graphics_Printing", + "Win32_Storage_Xps", + "Win32_UI_ColorSystem", +] } diff --git a/crates/winprint/src/lib.rs b/crates/winprint/src/lib.rs new file mode 100644 index 0000000000..fac300a50f --- /dev/null +++ b/crates/winprint/src/lib.rs @@ -0,0 +1,263 @@ +//! Windows printing for File › Print: the system's printers, the driver's own Print Settings +//! dialog (paper, paper type, quality, the driver's colour correction and mirror), the page +//! geometry it implies, and printing an 8-bit RGB image through GDI with ICM off, so colours +//! converted with a printer profile ("PhotoCraft Manages Colors") reach the driver unchanged. +//! +//! The driver settings travel as an opaque DEVMODE blob ([`DevMode`]), validated before use. +//! Everything here is a safe API; the Win32 calls live in `windows.rs`. Other platforms get +//! [`PrintError::Unsupported`]. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +#[cfg(windows)] +#[allow(unsafe_code)] +mod windows; + +use std::fmt; + +/// Why a printing call failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrintError { + /// Not on Windows. + Unsupported, + /// Bad input (an unknown printer, a malformed DEVMODE, an empty image). + Invalid(String), + /// A Win32 call failed. + System(String), +} + +impl fmt::Display for PrintError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PrintError::Unsupported => f.write_str("printing to a printer needs Windows here"), + PrintError::Invalid(m) | PrintError::System(m) => f.write_str(m), + } + } +} + +impl std::error::Error for PrintError {} + +pub type Result = std::result::Result; + +/// One installed printer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Printer { + pub name: String, + pub default: bool, +} + +/// A printer driver's settings (a Win32 DEVMODEW with the driver's private part), as bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DevMode(Vec); + +/// Offsets inside DEVMODEW: `dmSize` and `dmDriverExtra` follow the 32-wchar device name and +/// two version words. +const DM_SIZE_AT: usize = 68; +const DM_EXTRA_AT: usize = 70; +/// The smallest DEVMODEW a driver writes (Windows 95's, up to `dmICMIntent`). +const DM_MIN: usize = 156; +/// Larger blobs are not driver settings. +const DM_MAX: usize = 1 << 20; + +impl DevMode { + /// Validates driver settings bytes: the header's own sizes must add up to the length. + pub fn from_bytes(b: Vec) -> Result { + let word = |at: usize| b.get(at..at + 2).map(|w| usize::from(u16::from_le_bytes([w[0], w[1]]))); + let (Some(size), Some(extra)) = (word(DM_SIZE_AT), word(DM_EXTRA_AT)) else { + return Err(PrintError::Invalid("printer settings are too short".into())); + }; + if b.len() > DM_MAX || size < DM_MIN || size.checked_add(extra) != Some(b.len()) { + return Err(PrintError::Invalid("printer settings are damaged; choose Print Settings again".into())); + } + Ok(DevMode(b)) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Hex, for JSON parameters and remembered print settings. + pub fn to_hex(&self) -> String { + self.0.iter().map(|b| format!("{b:02x}")).collect() + } + + pub fn from_hex(s: &str) -> Result { + if !s.len().is_multiple_of(2) || s.len() > DM_MAX * 2 { + return Err(PrintError::Invalid("printer settings are damaged; choose Print Settings again".into())); + } + let bytes: Option> = (0..s.len()).step_by(2).map(|i| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); + Self::from_bytes(bytes.ok_or_else(|| PrintError::Invalid("printer settings are damaged; choose Print Settings again".into()))?) + } +} + +/// What a printer will print on with given settings. Points (1/72 in), origin top-left. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Page { + /// The whole sheet. + pub paper: (f64, f64), + /// The area the printer can reach: (x, y, width, height). + pub printable: (f64, f64, f64, f64), + /// Device resolution (dots per inch). + pub dpi: (u32, u32), +} + +/// An 8-bit RGB image, rows top to bottom, `width * 3` bytes each. +pub struct RgbImage<'a> { + pub width: u32, + pub height: u32, + pub data: &'a [u8], +} + +/// One print job. +pub struct Job<'a> { + pub printer: &'a str, + pub settings: Option<&'a DevMode>, + /// The name in the print queue. + pub title: &'a str, + /// Print to this file instead of the device (drivers that write files, and tests). + pub output_file: Option<&'a str>, + pub copies: u32, +} + +/// Where a job went: the image box in device pixels and the device resolution. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Printed { + pub device_rect: (i32, i32, i32, i32), + pub dpi: (u32, u32), +} + +/// Maps an image box in points (origin top-left of the sheet) to device pixels (origin at the +/// printable area's corner, as GDI draws). +pub fn device_rect(page: &Page, rect: (f64, f64, f64, f64)) -> (i32, i32, i32, i32) { + let (dx, dy) = (f64::from(page.dpi.0) / 72.0, f64::from(page.dpi.1) / 72.0); + let px = |v: f64| if v.is_finite() { v.round().clamp(-1e8, 1e8) as i32 } else { 0 }; + let (x, y, w, h) = rect; + (px((x - page.printable.0) * dx), px((y - page.printable.1) * dy), px(w * dx), px(h * dy)) +} + +/// The printers installed for this user, the default first. +pub fn printers() -> Result> { + #[cfg(windows)] + { + let mut v = windows::printers()?; + v.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(v) + } + #[cfg(not(windows))] + Err(PrintError::Unsupported) +} + +/// The default printer's name. +pub fn default_printer() -> Result> { + #[cfg(windows)] + { + windows::default_printer() + } + #[cfg(not(windows))] + Err(PrintError::Unsupported) +} + +/// A printer's settings as its driver starts them. +pub fn default_settings(printer: &str) -> Result { + #[cfg(windows)] + { + windows::properties(printer, None, None, false)?.ok_or_else(|| PrintError::System("the printer driver gave no settings".into())) + } + #[cfg(not(windows))] + { + let _ = printer; + Err(PrintError::Unsupported) + } +} + +/// Shows the driver's Print Settings dialog (modal; `parent` is a window handle). `None` when +/// the user cancels. +pub fn settings_dialog(printer: &str, current: Option<&DevMode>, parent: Option) -> Result> { + #[cfg(windows)] + { + windows::properties(printer, current, parent, true) + } + #[cfg(not(windows))] + { + let _ = (printer, current, parent); + Err(PrintError::Unsupported) + } +} + +/// The sheet and printable area for `printer` with `settings`. +pub fn page(printer: &str, settings: Option<&DevMode>) -> Result { + #[cfg(windows)] + { + windows::page(printer, settings) + } + #[cfg(not(windows))] + { + let _ = (printer, settings); + Err(PrintError::Unsupported) + } +} + +/// Prints `image` into `rect` (points, origin top-left of the sheet), `job.copies` pages. +pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result { + let row = (image.width as usize).checked_mul(3).ok_or_else(|| PrintError::Invalid("image too large".into()))?; + if image.width == 0 || image.height == 0 || row.checked_mul(image.height as usize) != Some(image.data.len()) { + return Err(PrintError::Invalid("nothing to print".into())); + } + if !(rect.2 > 0.0 && rect.3 > 0.0) { + return Err(PrintError::Invalid("the print size is empty".into())); + } + #[cfg(windows)] + { + windows::print(job, image, rect) + } + #[cfg(not(windows))] + { + let _ = job; + Err(PrintError::Unsupported) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn devmode(size: u16, extra: u16) -> Vec { + let mut b = vec![0u8; usize::from(size) + usize::from(extra)]; + b[DM_SIZE_AT..DM_SIZE_AT + 2].copy_from_slice(&size.to_le_bytes()); + b[DM_EXTRA_AT..DM_EXTRA_AT + 2].copy_from_slice(&extra.to_le_bytes()); + b + } + + #[test] + fn devmode_checks_its_own_sizes_and_round_trips_hex() { + let d = DevMode::from_bytes(devmode(220, 1200)).unwrap(); + assert_eq!(DevMode::from_hex(&d.to_hex()).unwrap(), d); + for bad in [vec![], vec![0; 40], devmode(100, 0), { + let mut b = devmode(220, 10); + b.pop(); + b + }] { + assert!(DevMode::from_bytes(bad).is_err()); + } + for bad in ["", "abc", "zz", &"00".repeat(300)] { + assert!(DevMode::from_hex(bad).is_err(), "{bad}"); + } + } + + #[test] + fn device_rect_is_relative_to_the_printable_corner() { + // A3 at 360 dpi with a 3 mm (8.5 pt) unprintable margin. + let page = Page { paper: (841.89, 1190.55), printable: (8.5, 8.5, 824.89, 1173.55), dpi: (360, 360) }; + assert_eq!(device_rect(&page, (8.5, 8.5, 72.0, 36.0)), (0, 0, 360, 180)); + assert_eq!(device_rect(&page, (72.0, 144.0, 144.0, 72.0)), (318, 678, 720, 360)); + assert_eq!(device_rect(&page, (f64::NAN, f64::INFINITY, 1.0, 1.0)).0, 0); + } + + #[test] + fn print_rejects_empty_or_short_images_before_any_system_call() { + let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1 }; + let short = RgbImage { width: 2, height: 2, data: &[0; 11] }; + assert!(matches!(print(&job, &short, (0.0, 0.0, 10.0, 10.0)), Err(PrintError::Invalid(_)))); + let ok = RgbImage { width: 2, height: 2, data: &[0; 12] }; + assert!(matches!(print(&job, &ok, (0.0, 0.0, 0.0, 10.0)), Err(PrintError::Invalid(_)))); + } +} diff --git a/crates/winprint/src/windows.rs b/crates/winprint/src/windows.rs new file mode 100644 index 0000000000..104333cf03 --- /dev/null +++ b/crates/winprint/src/windows.rs @@ -0,0 +1,378 @@ +//! The Win32 side: winspool (printers, driver settings) and GDI (page geometry, printing). +//! Every `unsafe` block says why it is sound. Buffers the system writes into are `u64`-backed so +//! the structures read from them are aligned. + +use std::ptr::{null, null_mut}; + +use windows_sys::Win32::Foundation::HWND; +use windows_sys::Win32::Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDCW, DEVMODEW, DIB_RGB_COLORS, DM_IN_BUFFER, DM_IN_PROMPT, DM_OUT_BUFFER, DeleteDC, GetDeviceCaps, HALFTONE, + HDC, HORZRES, LOGPIXELSX, LOGPIXELSY, PHYSICALHEIGHT, PHYSICALOFFSETX, PHYSICALOFFSETY, PHYSICALWIDTH, SRCCOPY, SetBrushOrgEx, SetStretchBltMode, + StretchDIBits, VERTRES, +}; +use windows_sys::Win32::Graphics::Printing::{ + ClosePrinter, DocumentPropertiesW, EnumPrintersW, GetDefaultPrinterW, OpenPrinterW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_HANDLE, + PRINTER_INFO_4W, +}; +use windows_sys::Win32::Storage::Xps::{AbortDoc, DOCINFOW, EndDoc, EndPage, StartDocW, StartPage}; +use windows_sys::Win32::UI::ColorSystem::{ICM_OFF, SetICMMode}; + +use crate::{DevMode, Job, Page, PrintError, Printed, Printer, Result, RgbImage}; + +const IDOK: i32 = 1; +const IDCANCEL: i32 = 2; +/// Wide strings read from the system stop here at the latest. +const MAX_WCHARS: usize = 32 * 1024; + +fn last_error(what: &str) -> PrintError { + PrintError::System(format!("{what} failed: {}", std::io::Error::last_os_error())) +} + +fn wide(s: &str) -> Result> { + if s.contains('\0') { + return Err(PrintError::Invalid("a name contains a NUL character".into())); + } + Ok(s.encode_utf16().chain(Some(0)).collect()) +} + +/// A zeroed, 8-byte aligned buffer of at least `bytes` bytes. +fn aligned(bytes: usize) -> Vec { + vec![0u64; bytes.div_ceil(8).max(1)] +} + +fn bytes_of(buf: &[u64], len: usize) -> Vec { + buf.iter().flat_map(|w| w.to_le_bytes()).take(len).collect() +} + +/// Reads a NUL-terminated wide string. +/// +/// # Safety +/// `p` is null or points to a NUL-terminated UTF-16 string that stays alive for the call. +unsafe fn read_wide(p: *const u16) -> String { + if p.is_null() { + return String::new(); + } + let mut n = 0; + // SAFETY: the caller guarantees a NUL-terminated string; we stop at the NUL (or the cap). + while n < MAX_WCHARS && unsafe { *p.add(n) } != 0 { + n += 1; + } + // SAFETY: the `n` units before the NUL were just read and are valid. + String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, n) }) +} + +pub fn printers() -> Result> { + let flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS; + let (mut needed, mut count) = (0u32, 0u32); + // SAFETY: a size query: no buffer (null, 0 bytes); the out-params are valid locals. + unsafe { EnumPrintersW(flags, null(), 4, null_mut(), 0, &mut needed, &mut count) }; + if needed == 0 { + return Ok(Vec::new()); + } + let mut buf = aligned(needed as usize); + // SAFETY: `buf` holds at least `needed` writable bytes, aligned for PRINTER_INFO_4W. + let ok = unsafe { EnumPrintersW(flags, null(), 4, buf.as_mut_ptr().cast(), needed, &mut needed, &mut count) }; + if ok == 0 { + return Err(last_error("listing printers")); + } + let default = default_printer().ok().flatten(); + let infos = buf.as_ptr().cast::(); + let fits = (needed as usize) / std::mem::size_of::(); + let mut out = Vec::new(); + for i in 0..(count as usize).min(fits) { + // SAFETY: the system wrote `count` PRINTER_INFO_4W at the start of `buf` (bounded by + // the buffer size above); their name pointers point into `buf`, which is alive. + let name = unsafe { read_wide((*infos.add(i)).pPrinterName) }; + if !name.is_empty() { + out.push(Printer { default: default.as_deref() == Some(name.as_str()), name }); + } + } + Ok(out) +} + +pub fn default_printer() -> Result> { + let mut len = 0u32; + // SAFETY: a size query with a null buffer; `len` is a valid local. + unsafe { GetDefaultPrinterW(null_mut(), &mut len) }; + if len == 0 || len as usize > MAX_WCHARS { + return Ok(None); + } + let mut buf = vec![0u16; len as usize]; + // SAFETY: `buf` holds `len` writable wide chars. + if unsafe { GetDefaultPrinterW(buf.as_mut_ptr(), &mut len) } == 0 { + return Ok(None); + } + // SAFETY: on success the buffer holds a NUL-terminated name. + Ok(Some(unsafe { read_wide(buf.as_ptr()) })) +} + +/// An open printer handle, closed on drop. +struct OpenPrinter(PRINTER_HANDLE); + +impl OpenPrinter { + fn open(name: &[u16]) -> Result { + let mut h = PRINTER_HANDLE { Value: null_mut() }; + // SAFETY: `name` is NUL-terminated; `h` is a valid out-param; no defaults. + if unsafe { OpenPrinterW(name.as_ptr(), &mut h, null()) } == 0 || h.Value.is_null() { + return Err(last_error("opening the printer")); + } + Ok(OpenPrinter(h)) + } +} + +impl Drop for OpenPrinter { + fn drop(&mut self) { + // SAFETY: the handle came from OpenPrinterW and is closed once. + unsafe { ClosePrinter(self.0) }; + } +} + +/// Settings the driver can take as input: `current`, copied aligned, when it is this printer's. +fn input_settings(printer: &str, current: Option<&DevMode>) -> Option> { + let d = current?; + let b = d.as_bytes(); + // dmDeviceName: the first 32 wide chars (31 + NUL), so long names are truncated. + let device: Vec = b.get(..64)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|c| *c != 0).collect(); + let want: Vec = printer.encode_utf16().take(31).collect(); + if device != want { + return None; + } + let mut buf = aligned(b.len()); + for (i, chunk) in b.chunks(8).enumerate() { + let mut w = [0u8; 8]; + w[..chunk.len()].copy_from_slice(chunk); + if let Some(slot) = buf.get_mut(i) { + *slot = u64::from_le_bytes(w); + } + } + Some(buf) +} + +pub fn properties(printer: &str, current: Option<&DevMode>, parent: Option, prompt: bool) -> Result> { + let name = wide(printer)?; + let p = OpenPrinter::open(&name)?; + let hwnd: HWND = parent.map_or(null_mut(), |h| h as HWND); + // SAFETY: with fmode 0 DocumentPropertiesW only returns the size the settings need. + let needed = unsafe { DocumentPropertiesW(hwnd, p.0, name.as_ptr(), null_mut(), null(), 0) }; + if needed <= 0 || needed as usize > (1 << 20) { + return Err(last_error("reading the printer's settings")); + } + let mut out = aligned(needed as usize); + let input = input_settings(printer, current); + let mut mode = DM_OUT_BUFFER; + if input.is_some() { + mode |= DM_IN_BUFFER; + } + if prompt { + mode |= DM_IN_PROMPT; + } + let inp: *const DEVMODEW = input.as_ref().map_or(null(), |b| b.as_ptr().cast()); + // SAFETY: `out` holds `needed` aligned writable bytes as the size query asked for; `inp` is + // null or an aligned copy of settings written by this printer's driver. + let r = unsafe { DocumentPropertiesW(hwnd, p.0, name.as_ptr(), out.as_mut_ptr().cast(), inp, mode) }; + match r { + IDOK => { + let all = bytes_of(&out, needed as usize); + let word = |at: usize| all.get(at..at + 2).map_or(0, |w| usize::from(u16::from_le_bytes([w[0], w[1]]))); + let len = (word(68) + word(70)).min(all.len()); + DevMode::from_bytes(all.into_iter().take(len).collect()).map(Some) + } + IDCANCEL => Ok(None), + _ => Err(last_error("the printer's settings dialog")), + } +} + +/// A printer device context, deleted on drop. +struct Dc(HDC); + +impl Dc { + fn create(printer: &str, settings: Option<&DevMode>) -> Result { + let name = wide(printer)?; + let dm = input_settings(printer, settings); + let dmp: *const DEVMODEW = dm.as_ref().map_or(null(), |b| b.as_ptr().cast()); + // SAFETY: `name` is NUL-terminated; `dmp` is null or aligned settings from this + // printer's driver, alive for the call. + let hdc = unsafe { CreateDCW(null(), name.as_ptr(), null(), dmp) }; + if hdc.is_null() { + return Err(last_error(&format!("opening printer “{printer}”"))); + } + Ok(Dc(hdc)) + } + + fn cap(&self, index: u32) -> i32 { + // SAFETY: a valid printer DC and a documented capability index. + unsafe { GetDeviceCaps(self.0, index as i32) } + } + + fn page(&self) -> Result { + let (dx, dy) = (self.cap(LOGPIXELSX), self.cap(LOGPIXELSY)); + if dx <= 0 || dy <= 0 { + return Err(PrintError::System("the printer reported no resolution".into())); + } + let (fx, fy) = (72.0 / f64::from(dx), 72.0 / f64::from(dy)); + let (pw, ph) = (self.cap(PHYSICALWIDTH), self.cap(PHYSICALHEIGHT)); + let (ox, oy) = (self.cap(PHYSICALOFFSETX), self.cap(PHYSICALOFFSETY)); + let (hw, vh) = (self.cap(HORZRES), self.cap(VERTRES)); + let (pw, ph) = if pw > 0 && ph > 0 { (pw, ph) } else { (hw, vh) }; + Ok(Page { + paper: (f64::from(pw) * fx, f64::from(ph) * fy), + printable: (f64::from(ox) * fx, f64::from(oy) * fy, f64::from(hw) * fx, f64::from(vh) * fy), + dpi: (dx as u32, dy as u32), + }) + } +} + +impl Drop for Dc { + fn drop(&mut self) { + // SAFETY: the DC came from CreateDCW and is deleted once. + unsafe { DeleteDC(self.0) }; + } +} + +pub fn page(printer: &str, settings: Option<&DevMode>) -> Result { + Dc::create(printer, settings)?.page() +} + +/// RGB rows top-down → a bottom-up 24-bit DIB (BGR, rows padded to 4 bytes). +fn dib(image: &RgbImage<'_>) -> Result<(BITMAPINFO, Vec)> { + let (w, h) = (image.width as usize, image.height as usize); + let stride = (w.checked_mul(3).ok_or_else(|| PrintError::Invalid("image too large".into()))? + 3) & !3; + let total = stride.checked_mul(h).filter(|t| *t <= i32::MAX as usize).ok_or_else(|| PrintError::Invalid("image too large to print in one piece".into()))?; + let mut bits = vec![0u8; total]; + for (y, src) in image.data.chunks_exact(w * 3).enumerate() { + let start = (h - 1 - y) * stride; + let Some(dst) = bits.get_mut(start..start + w * 3) else { continue }; + for (d, s) in dst.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0) { + *d = [s[2], s[1], s[0]]; + } + } + let header = BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: image.width as i32, + biHeight: image.height as i32, + biPlanes: 1, + biBitCount: 24, + biCompression: BI_RGB, + biSizeImage: total as u32, + ..Default::default() + }; + Ok((BITMAPINFO { bmiHeader: header, bmiColors: [Default::default()] }, bits)) +} + +pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result { + if image.width > i32::MAX as u32 || image.height > i32::MAX as u32 { + return Err(PrintError::Invalid("image too large".into())); + } + let dc = Dc::create(job.printer, job.settings)?; + let page = dc.page()?; + let (x, y, w, h) = crate::device_rect(&page, rect); + let (bmi, bits) = dib(image)?; + let title = wide(job.title)?; + let output = job.output_file.map(wide).transpose()?; + let info = DOCINFOW { + cbSize: std::mem::size_of::() as i32, + lpszDocName: title.as_ptr(), + lpszOutput: output.as_ref().map_or(null(), |o| o.as_ptr()), + lpszDatatype: null(), + fwType: 0, + }; + // SAFETY: a valid printer DC. ICM off: the pixels are already in the printer's space + // (or deliberately unmanaged), so GDI must not convert them again. + unsafe { SetICMMode(dc.0, ICM_OFF) }; + // SAFETY: a valid DC; HALFTONE resampling needs the brush origin reset afterwards. + unsafe { + SetStretchBltMode(dc.0, HALFTONE); + SetBrushOrgEx(dc.0, 0, 0, null_mut()); + } + // SAFETY: `info` and the strings it points to live until EndDoc/AbortDoc below. + if unsafe { StartDocW(dc.0, &info) } <= 0 { + return Err(last_error("starting the print job")); + } + let fail = |what: &str| { + let e = last_error(what); + // SAFETY: the job was started on this DC. + unsafe { AbortDoc(dc.0) }; + e + }; + for _ in 0..job.copies.clamp(1, 999) { + // SAFETY: inside a started document on a valid DC. + if unsafe { StartPage(dc.0) } <= 0 { + return Err(fail("starting a page")); + } + // SAFETY: `bits` is a bottom-up DIB exactly as `bmi` describes (24-bit, padded rows, + // `biSizeImage` bytes); the source rectangle is the whole image. + let lines = + unsafe { StretchDIBits(dc.0, x, y, w, h, 0, 0, image.width as i32, image.height as i32, bits.as_ptr().cast(), &bmi, DIB_RGB_COLORS, SRCCOPY) }; + if lines <= 0 { + return Err(fail("sending the image to the printer")); + } + // SAFETY: closes the page started above. + if unsafe { EndPage(dc.0) } <= 0 { + return Err(fail("finishing a page")); + } + } + // SAFETY: closes the document started above. + if unsafe { EndDoc(dc.0) } <= 0 { + return Err(last_error("finishing the print job")); + } + Ok(Printed { device_rect: (x, y, w, h), dpi: page.dpi }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dib_is_bottom_up_bgr_with_padded_rows() { + // 2×2: red, green / blue, white. + let data = [255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]; + let (bmi, bits) = dib(&RgbImage { width: 2, height: 2, data: &data }).unwrap(); + assert_eq!(bmi.bmiHeader.biHeight, 2); + assert_eq!(bits.len(), 16, "6 bytes per row padded to 8"); + // First stored row is the bottom one: blue, white. + assert_eq!(&bits[0..6], &[255, 0, 0, 255, 255, 255]); + assert_eq!(&bits[8..14], &[0, 0, 255, 0, 255, 0]); + } + + #[test] + fn settings_from_another_printer_are_not_passed_to_the_driver() { + let mut b = vec![0u8; 220]; + for (i, c) in "Printer A".encode_utf16().enumerate() { + b[i * 2..i * 2 + 2].copy_from_slice(&c.to_le_bytes()); + } + b[68..70].copy_from_slice(&220u16.to_le_bytes()); + let d = DevMode::from_bytes(b).unwrap(); + assert!(input_settings("Printer A", Some(&d)).is_some()); + assert!(input_settings("Printer B", Some(&d)).is_none()); + assert!(input_settings("Printer A", None).is_none()); + } + + /// A real job through Windows' "Microsoft Print to PDF" driver, written to a file (needs that + /// printer installed; run by hand). + #[test] + #[ignore] + fn prints_through_a_real_driver_to_a_file() { + let out = std::env::temp_dir().join(format!("pc-winprint-{}.pdf", std::process::id())); + let _ = std::fs::remove_file(&out); + let settings = crate::default_settings("Microsoft Print to PDF").unwrap(); + let (w, h) = (300u32, 200u32); + let data: Vec = (0..w * h).flat_map(|i| if (i % w) < w / 2 { [220, 30, 30] } else { [30, 30, 220] }).collect(); + let out_s = out.to_string_lossy().into_owned(); + let job = Job { printer: "Microsoft Print to PDF", settings: Some(&settings), title: "PhotoCraft test", output_file: Some(&out_s), copies: 2 }; + let done = crate::print(&job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap(); + assert_eq!(done.device_rect.2, done.dpi.0 as i32 * 3, "3 inches wide"); + let pdf = std::fs::read(&out).unwrap(); + assert!(pdf.starts_with(b"%PDF"), "the driver wrote a PDF"); + assert!(pdf.len() > 1000, "the page has content"); + let _ = std::fs::remove_file(&out); + } + + /// Lists this machine's printers and their pages (needs real printers; run by hand). + #[test] + #[ignore] + fn lists_real_printers() { + for p in printers().unwrap() { + println!("{}{} {:?}", p.name, if p.default { " (default)" } else { "" }, page(&p.name, None)); + } + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 2128345148..dcb66f7120 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -46,6 +46,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("adobe-assets", Class::Standalone), // Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS). ("tablet", Class::Standalone), + // Windows printing (an isolated `unsafe` helper: winspool and GDI). + ("winprint", Class::Standalone), ("doc", Class::Layer(1)), ("ops", Class::Layer(2)), ("paint", Class::Layer(2)), From ce8d8d950bd1b2e7908af7e41d51c2949f377f3b Mon Sep 17 00:00:00 2001 From: Moiztermind Date: Sun, 11 Oct 2026 15:10:57 +0100 Subject: [PATCH 002/167] winprint: let the driver validate settings; never print on defaults silently The settings check compared the printer name stored in the DEVMODE with the queue name. Windows keeps only 31 characters of it, so a long queue name ("Sublimation - EPSON ET-18100 Series") failed the check and the chosen paper type and quality were silently dropped: the job printed on the driver defaults (plain paper). Settings now go through DocumentProperties(DM_IN_BUFFER | DM_OUT_BUFFER) before CreateDC, so the driver validates them itself, and a refusal is an error instead of a silent fallback. Co-Authored-By: Claude Opus 5.5 --- crates/winprint/src/windows.rs | 79 +++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/crates/winprint/src/windows.rs b/crates/winprint/src/windows.rs index 104333cf03..9b8f8ba16e 100644 --- a/crates/winprint/src/windows.rs +++ b/crates/winprint/src/windows.rs @@ -127,25 +127,21 @@ impl Drop for OpenPrinter { } } -/// Settings the driver can take as input: `current`, copied aligned, when it is this printer's. -fn input_settings(printer: &str, current: Option<&DevMode>) -> Option> { - let d = current?; - let b = d.as_bytes(); - // dmDeviceName: the first 32 wide chars (31 + NUL), so long names are truncated. - let device: Vec = b.get(..64)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|c| *c != 0).collect(); - let want: Vec = printer.encode_utf16().take(31).collect(); - if device != want { - return None; - } +/// `settings` copied into an aligned buffer the driver can read. +/// +/// There is deliberately no check of the printer name stored in the settings: Windows keeps only +/// 31 characters of it, and drivers fill it differently, so a long queue name ("Sublimation - +/// EPSON ET-18100 Series") failed such a check and the settings were silently dropped. The driver +/// itself validates them instead (see [`Dc::create`]). +fn aligned_copy(settings: &DevMode) -> Vec { + let b = settings.as_bytes(); let mut buf = aligned(b.len()); - for (i, chunk) in b.chunks(8).enumerate() { + for (slot, chunk) in buf.iter_mut().zip(b.chunks(8)) { let mut w = [0u8; 8]; w[..chunk.len()].copy_from_slice(chunk); - if let Some(slot) = buf.get_mut(i) { - *slot = u64::from_le_bytes(w); - } + *slot = u64::from_le_bytes(w); } - Some(buf) + buf } pub fn properties(printer: &str, current: Option<&DevMode>, parent: Option, prompt: bool) -> Result> { @@ -158,7 +154,7 @@ pub fn properties(printer: &str, current: Option<&DevMode>, parent: Option) -> Result { let name = wide(printer)?; - let dm = input_settings(printer, settings); + // The driver validates and completes the settings first (DM_IN_BUFFER | DM_OUT_BUFFER, no + // dialog). If it refuses them, that is an error: never print on the defaults instead. + let merged = match settings { + Some(s) => Some( + properties(printer, Some(s), None, false)? + .ok_or_else(|| PrintError::Invalid(format!("printer “{printer}” refused these settings; choose Print Settings… again")))?, + ), + None => None, + }; + let dm = merged.as_ref().map(aligned_copy); let dmp: *const DEVMODEW = dm.as_ref().map_or(null(), |b| b.as_ptr().cast()); // SAFETY: `name` is NUL-terminated; `dmp` is null or aligned settings from this // printer's driver, alive for the call. @@ -335,16 +340,38 @@ mod tests { } #[test] - fn settings_from_another_printer_are_not_passed_to_the_driver() { - let mut b = vec![0u8; 220]; - for (i, c) in "Printer A".encode_utf16().enumerate() { - b[i * 2..i * 2 + 2].copy_from_slice(&c.to_le_bytes()); - } + fn settings_are_copied_aligned_and_intact() { + let mut b: Vec = (0..230u32).map(|i| (i * 7) as u8).collect(); b[68..70].copy_from_slice(&220u16.to_le_bytes()); - let d = DevMode::from_bytes(b).unwrap(); - assert!(input_settings("Printer A", Some(&d)).is_some()); - assert!(input_settings("Printer B", Some(&d)).is_none()); - assert!(input_settings("Printer A", None).is_none()); + b[70..72].copy_from_slice(&10u16.to_le_bytes()); + let d = DevMode::from_bytes(b.clone()).unwrap(); + let buf = aligned_copy(&d); + assert_eq!(buf.as_ptr().align_offset(std::mem::align_of::()), 0); + assert_eq!(bytes_of(&buf, b.len()), b, "no name check, no truncation: the bytes go through as chosen"); + } + + /// Settings chosen for a printer are what the driver prints with: paper and resolution survive + /// the driver's own validation (needs "Microsoft Print to PDF"; run by hand). + #[test] + #[ignore] + fn chosen_settings_reach_the_device_context() { + let printer = "Microsoft Print to PDF"; + let d = crate::default_settings(printer).unwrap(); + let mut b = d.as_bytes().to_vec(); + // dmPaperSize = DMPAPER_A3 (8); dmFields |= DM_PAPERSIZE (2). + b[78..80].copy_from_slice(&8i16.to_le_bytes()); + let fields = u32::from_le_bytes([b[72], b[73], b[74], b[75]]) | 2; + b[72..76].copy_from_slice(&fields.to_le_bytes()); + let a3 = DevMode::from_bytes(b).unwrap(); + let pg = crate::page(printer, Some(&a3)).unwrap(); + assert!((pg.paper.0 - 841.9).abs() < 2.0 && (pg.paper.1 - 1190.6).abs() < 2.0, "A3, not the default: {:?}", pg.paper); + // Landscape (dmOrientation = 2, DM_ORIENTATION = 1): the page comes back wide. + let mut b = a3.as_bytes().to_vec(); + b[76..78].copy_from_slice(&2i16.to_le_bytes()); + let fields = u32::from_le_bytes([b[72], b[73], b[74], b[75]]) | 1; + b[72..76].copy_from_slice(&fields.to_le_bytes()); + let pg = crate::page(printer, Some(&DevMode::from_bytes(b).unwrap())).unwrap(); + assert!((pg.paper.0 - 1190.6).abs() < 2.0 && (pg.paper.1 - 841.9).abs() < 2.0, "landscape A3: {:?}", pg.paper); } /// A real job through Windows' "Microsoft Print to PDF" driver, written to a file (needs that From f9b3c6bd85cf92f266fb22df22354ab3eb0d83f2 Mon Sep 17 00:00:00 2001 From: moiztermind Date: Sun, 11 Oct 2026 19:23:18 +0100 Subject: [PATCH 003/167] winprint: draw crop and registration marks on GDI prints Printer marks (corner/centre crop, registration) were silently dropped on Windows GDI prints -- only the PDF export path drew them, as content-stream vector operators. A workshop test print surfaced the gap. Adds a Marks type to photocraft-winprint and draws the same geometry GDI's way (MoveToEx/LineTo for lines, Ellipse for registration circles) once per page between StretchDIBits and EndPage. The JSON field marksSkipped becomes textSkipped: description and label text are still PDF-only (no font layout in the GDI path yet). Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/print_cmds.rs | 11 +++-- crates/ui-egui/src/file_ui.rs | 8 +++- crates/winprint/src/lib.rs | 23 +++++++++- crates/winprint/src/windows.rs | 79 ++++++++++++++++++++++++++++++--- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index e3e520ff9e..1b05a6511a 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -444,17 +444,20 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) { (x, ph - y - h, w, h) } -/// Prints `page` on a Windows printer. Printer marks, description and labels are PDF-only. +/// Prints `page` on a Windows printer. Corner/centre crop and registration marks are drawn with +/// GDI vector calls, matching the PDF path's geometry; description and label text are still +/// PDF-only (no font/text layout in the GDI path yet). fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: i64) -> Result { let printer = printer_name(p)?; let settings = printer_settings(p)?; let rgb = page_rgb(page)?; let image = photocraft_winprint::RgbImage { width: page.image.width, height: page.image.height, data: &rgb }; - let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999) as u32 }; + let marks = photocraft_winprint::Marks { corner_crop: page.marks.corner_crop, center_crop: page.marks.center_crop, registration: page.marks.registration }; + let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999) as u32, marks }; let done = photocraft_winprint::print(&job, &image, page_rect_top_left(page)).map_err(other)?; - let marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration || page.description.is_some() || page.label.is_some(); + let text_skipped = page.description.is_some() || page.label.is_some(); Ok( - json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3], "marksSkipped": marks}), + json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3], "textSkipped": text_skipped}), ) } diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 323519c36b..75592be6d8 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -923,8 +923,12 @@ fn print_confirm(app: &mut PhotocraftApp, f: &Map) -> Result { pub data: &'a [u8], } +/// Printer marks drawn around the image box: corner and centre crop marks, and registration +/// marks. Matches [`photocraft_engine`]'s PDF `Marks`, mirrored here so this crate stays +/// independent of the engine; GDI draws the same geometry with vector line/ellipse calls instead +/// of PDF content stream operators. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Marks { + pub corner_crop: bool, + pub center_crop: bool, + pub registration: bool, +} + +impl Marks { + pub(crate) fn any(self) -> bool { + self.corner_crop || self.center_crop || self.registration + } +} + /// One print job. pub struct Job<'a> { pub printer: &'a str, @@ -116,6 +136,7 @@ pub struct Job<'a> { /// Print to this file instead of the device (drivers that write files, and tests). pub output_file: Option<&'a str>, pub copies: u32, + pub marks: Marks, } /// Where a job went: the image box in device pixels and the device resolution. @@ -254,7 +275,7 @@ mod tests { #[test] fn print_rejects_empty_or_short_images_before_any_system_call() { - let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1 }; + let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1, marks: Marks::default() }; let short = RgbImage { width: 2, height: 2, data: &[0; 11] }; assert!(matches!(print(&job, &short, (0.0, 0.0, 10.0, 10.0)), Err(PrintError::Invalid(_)))); let ok = RgbImage { width: 2, height: 2, data: &[0; 12] }; diff --git a/crates/winprint/src/windows.rs b/crates/winprint/src/windows.rs index 9b8f8ba16e..1ff5de984a 100644 --- a/crates/winprint/src/windows.rs +++ b/crates/winprint/src/windows.rs @@ -6,9 +6,9 @@ use std::ptr::{null, null_mut}; use windows_sys::Win32::Foundation::HWND; use windows_sys::Win32::Graphics::Gdi::{ - BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDCW, DEVMODEW, DIB_RGB_COLORS, DM_IN_BUFFER, DM_IN_PROMPT, DM_OUT_BUFFER, DeleteDC, GetDeviceCaps, HALFTONE, - HDC, HORZRES, LOGPIXELSX, LOGPIXELSY, PHYSICALHEIGHT, PHYSICALOFFSETX, PHYSICALOFFSETY, PHYSICALWIDTH, SRCCOPY, SetBrushOrgEx, SetStretchBltMode, - StretchDIBits, VERTRES, + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDCW, CreatePen, DEVMODEW, DIB_RGB_COLORS, DM_IN_BUFFER, DM_IN_PROMPT, DM_OUT_BUFFER, DeleteDC, DeleteObject, + Ellipse, GetDeviceCaps, GetStockObject, HALFTONE, HDC, HOLLOW_BRUSH, HORZRES, LOGPIXELSX, LOGPIXELSY, LineTo, MoveToEx, PHYSICALHEIGHT, PHYSICALOFFSETX, + PHYSICALOFFSETY, PHYSICALWIDTH, PS_SOLID, SRCCOPY, SelectObject, SetBrushOrgEx, SetStretchBltMode, StretchDIBits, VERTRES, }; use windows_sys::Win32::Graphics::Printing::{ ClosePrinter, DocumentPropertiesW, EnumPrintersW, GetDefaultPrinterW, OpenPrinterW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_HANDLE, @@ -17,7 +17,7 @@ use windows_sys::Win32::Graphics::Printing::{ use windows_sys::Win32::Storage::Xps::{AbortDoc, DOCINFOW, EndDoc, EndPage, StartDocW, StartPage}; use windows_sys::Win32::UI::ColorSystem::{ICM_OFF, SetICMMode}; -use crate::{DevMode, Job, Page, PrintError, Printed, Printer, Result, RgbImage}; +use crate::{DevMode, Job, Marks, Page, PrintError, Printed, Printer, Result, RgbImage}; const IDOK: i32 = 1; const IDCANCEL: i32 = 2; @@ -264,6 +264,61 @@ fn dib(image: &RgbImage<'_>) -> Result<(BITMAPINFO, Vec)> { Ok((BITMAPINFO { bmiHeader: header, bmiColors: [Default::default()] }, bits)) } +/// Vector marks around the image box `(x, y, w, h)` (device pixels, origin top-left, as GDI +/// draws). Same geometry as `photocraft_engine::print_cmds::marks_ops`'s PDF content stream, with +/// the vertical sense flipped (PDF's origin is bottom-left; GDI's is top-left), scaled from points +/// to device pixels with `dpi` instead of PDF's user-space points. +/// +/// # Safety +/// `dc` is a valid, selected device context for the current page. +unsafe fn draw_marks(dc: HDC, marks: Marks, (x, y, w, h): (i32, i32, i32, i32), dpi: (u32, u32)) -> Result<()> { + let (sx, sy) = (f64::from(dpi.0) / 72.0, f64::from(dpi.1) / 72.0); + let px = |v: f64| v.round() as i32; + let (gap_x, gap_y) = (px(6.0 * sx), px(6.0 * sy)); + let (len_x, len_y) = (px(18.0 * sx), px(18.0 * sy)); + let pen_width = px(0.3 * sx).max(1); + // SAFETY: `dc` is valid; the pen and brush are deleted/restored before returning. + let pen = unsafe { CreatePen(PS_SOLID, pen_width, 0x0000_0000) }; + let hollow = unsafe { GetStockObject(HOLLOW_BRUSH) }; + let old_pen = unsafe { SelectObject(dc, pen) }; + let old_brush = unsafe { SelectObject(dc, hollow) }; + let ok = std::cell::Cell::new(true); + let line = |x0: i32, y0: i32, x1: i32, y1: i32| { + // SAFETY: `dc` is valid for the duration of this call. + ok.set(ok.get() && unsafe { MoveToEx(dc, x0, y0, null_mut()) != 0 && LineTo(dc, x1, y1) != 0 }); + }; + if marks.corner_crop { + for (cx, cy, dx, dy) in [(x, y, -1, -1), (x + w, y, 1, -1), (x, y + h, -1, 1), (x + w, y + h, 1, 1)] { + line(cx + dx * gap_x, cy, cx + dx * (gap_x + len_x), cy); + line(cx, cy + dy * gap_y, cx, cy + dy * (gap_y + len_y)); + } + } + if marks.center_crop { + let (mx, my) = (x + w / 2, y + h / 2); + line(mx, y - gap_y, mx, y - gap_y - len_y); + line(mx, y + h + gap_y, mx, y + h + gap_y + len_y); + line(x - gap_x, my, x - gap_x - len_x, my); + line(x + w + gap_x, my, x + w + gap_x + len_x, my); + } + if marks.registration { + let (rx, ry) = (px(5.0 * sx), px(5.0 * sy)); + let (off_x, off_y) = (gap_x + len_x / 2, gap_y + len_y / 2); + for (cx, cy) in [(x + w / 2, y - off_y), (x + w / 2, y + h + off_y), (x - off_x, y + h / 2), (x + w + off_x, y + h / 2)] { + // SAFETY: `dc` is valid for the duration of this call. + ok.set(ok.get() && unsafe { Ellipse(dc, cx - rx, cy - ry, cx + rx, cy + ry) != 0 }); + line(cx - (rx as f64 * 1.6) as i32, cy, cx + (rx as f64 * 1.6) as i32, cy); + line(cx, cy - (ry as f64 * 1.6) as i32, cx, cy + (ry as f64 * 1.6) as i32); + } + } + // SAFETY: restores the DC's previous pen/brush, then frees the ones created above. + unsafe { + SelectObject(dc, old_pen); + SelectObject(dc, old_brush); + DeleteObject(pen); + } + if ok.get() { Ok(()) } else { Err(last_error("drawing printer marks")) } +} + pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result { if image.width > i32::MAX as u32 || image.height > i32::MAX as u32 { return Err(PrintError::Invalid("image too large".into())); @@ -311,6 +366,12 @@ pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> if lines <= 0 { return Err(fail("sending the image to the printer")); } + if job.marks.any() { + // SAFETY: `dc.0` is the valid, started page's device context. + if let Err(e) = unsafe { draw_marks(dc.0, job.marks, (x, y, w, h), page.dpi) } { + return Err(fail(&e.to_string())); + } + } // SAFETY: closes the page started above. if unsafe { EndPage(dc.0) } <= 0 { return Err(fail("finishing a page")); @@ -385,12 +446,20 @@ mod tests { let (w, h) = (300u32, 200u32); let data: Vec = (0..w * h).flat_map(|i| if (i % w) < w / 2 { [220, 30, 30] } else { [30, 30, 220] }).collect(); let out_s = out.to_string_lossy().into_owned(); - let job = Job { printer: "Microsoft Print to PDF", settings: Some(&settings), title: "PhotoCraft test", output_file: Some(&out_s), copies: 2 }; + let marks = crate::Marks { corner_crop: true, center_crop: true, registration: true }; + let job = Job { printer: "Microsoft Print to PDF", settings: Some(&settings), title: "PhotoCraft test", output_file: Some(&out_s), copies: 2, marks }; let done = crate::print(&job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap(); assert_eq!(done.device_rect.2, done.dpi.0 as i32 * 3, "3 inches wide"); let pdf = std::fs::read(&out).unwrap(); assert!(pdf.starts_with(b"%PDF"), "the driver wrote a PDF"); assert!(pdf.len() > 1000, "the page has content"); + // The driver vectorises our GDI line/ellipse calls as real PDF content: a job with every + // mark on is meaningfully bigger than the same image with marks off. + let no_marks_job = Job { marks: crate::Marks::default(), ..job }; + let done2 = crate::print(&no_marks_job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap(); + let _ = done2; + let pdf_no_marks = std::fs::read(&out).unwrap(); + assert!(pdf.len() > pdf_no_marks.len() + 100, "marks add vector content: {} vs {}", pdf.len(), pdf_no_marks.len()); let _ = std::fs::remove_file(&out); } From 9f7f8b7a7b9ece8ab024d6f0194d11805d1b6bfa Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 01:51:12 +0200 Subject: [PATCH 004/167] Check that driver copy fields remain active after validation --- crates/winprint/src/lib.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/winprint/src/lib.rs b/crates/winprint/src/lib.rs index e263926e73..e0c5729881 100644 --- a/crates/winprint/src/lib.rs +++ b/crates/winprint/src/lib.rs @@ -94,7 +94,10 @@ impl DevMode { #[cfg(any(windows, test))] pub(crate) fn has_manual_copies(&self) -> bool { - self.0.get(86..88) == Some(1i16.to_le_bytes().as_slice()) && self.0.get(100..102) == Some(0i16.to_le_bytes().as_slice()) + let fields = self.0.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes); + fields.is_some_and(|f| f & 0x100 != 0) + && self.0.get(86..88) == Some(1i16.to_le_bytes().as_slice()) + && self.0.get(100..102) == Some(0i16.to_le_bytes().as_slice()) } /// Hex, for JSON parameters and remembered print settings. @@ -296,6 +299,9 @@ mod tests { assert!(!original.has_manual_copies()); assert_eq!(&normalized.as_bytes()[220..], &original.as_bytes()[220..]); assert_eq!(normalized.for_manual_copies().unwrap(), normalized); + let mut ignored = normalized.as_bytes().to_vec(); + ignored[72..76].copy_from_slice(&0u32.to_le_bytes()); + assert!(!DevMode::from_bytes(ignored).unwrap().has_manual_copies(), "a driver must advertise that it accepts dmCopies"); } #[test] @@ -314,5 +320,8 @@ mod tests { assert!(matches!(print(&job, &short, (0.0, 0.0, 10.0, 10.0)), Err(PrintError::Invalid(_)))); let ok = RgbImage { width: 2, height: 2, data: &[0; 12] }; assert!(matches!(print(&job, &ok, (0.0, 0.0, 0.0, 10.0)), Err(PrintError::Invalid(_)))); + for rect in [(f64::NAN, 0.0, 10.0, 10.0), (0.0, 0.0, f64::INFINITY, 10.0)] { + assert!(matches!(print(&job, &ok, rect), Err(PrintError::Invalid(_)))); + } } } From 4a730912c0dd3868b3c4933f8734ca03e9698cae Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 01:53:16 +0200 Subject: [PATCH 005/167] Preserve PDF text and CMYK paths and reject native layout mismatches --- crates/engine/src/print_cmds.rs | 20 +++++++++++++++++--- crates/engine/src/print_cmds/tests.rs | 15 +++++++++++++++ crates/winprint/src/windows.rs | 11 +++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index dd7f85971c..4093c8dd35 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -391,13 +391,16 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { // Windows has no `lp`: print through the driver (GDI, ICM off) instead of spooling a PDF. // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. - if cfg!(windows) && send && !dry && output.is_none() { + if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page) { let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } + if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() { + return Err(other("CMYK profiles and description/label text use PDF composition; these native driver settings cannot be applied. Save as PDF, or clear the driver settings to use PDF printing.")); + } let pdf = print_pdf(&page); let job = craft_print::Job { printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), @@ -427,6 +430,14 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { ) } +/// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently +/// draws RGB/gray and vector marks only; it must never silently omit requested page content. +fn native_rgb_compatible(page: &PrintPage) -> bool { + matches!(page.image.channels, 1 | 3) + && page.description.as_deref().is_none_or(str::is_empty) + && page.label.as_deref().is_none_or(str::is_empty) +} + /// Print One Copy repeats these. fn remember_print(s: &mut Session, p: &Value) { let mut remembered = p.clone(); @@ -490,14 +501,17 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) { fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result { let printer = printer_name(p)?; let settings = printer_settings(p)?; + let device_page = photocraft_winprint::page(&printer, settings.as_ref()).map_err(other)?; + if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 { + return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing")); + } let rgb = page_rgb(page)?; let image = photocraft_winprint::RgbImage { width: page.image.width, height: page.image.height, data: &rgb }; let marks = photocraft_winprint::Marks { corner_crop: page.marks.corner_crop, center_crop: page.marks.center_crop, registration: page.marks.registration }; let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999), marks }; let done = photocraft_winprint::print(&job, &image, page_rect_top_left(page)).map_err(other)?; - let text_skipped = page.description.is_some() || page.label.is_some(); Ok( - json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3], "textSkipped": text_skipped}), + json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3]}), ) } diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index c15b7c6562..b240efb48a 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -175,6 +175,21 @@ fn windows_print_image_is_rgb_mirrored_for_emulsion_down() { assert_eq!(page_rect_top_left(&page_of(3, vec![0; 6], true)), (356.0, 528.0, 144.0, 72.0)); } +#[test] +fn native_rgb_route_preserves_pdf_only_color_and_page_content() { + let mut page = page_of(3, vec![0; 6], false); + assert!(native_rgb_compatible(&page)); + page.label = Some("Print Me".into()); + assert!(!native_rgb_compatible(&page)); + page.label = None; + page.description = Some("Proof description".into()); + assert!(!native_rgb_compatible(&page)); + page.description = Some(String::new()); + assert!(native_rgb_compatible(&page)); + assert!(!native_rgb_compatible(&page_of(4, vec![0; 8], false))); + assert!(native_rgb_compatible(&page_of(1, vec![0; 2], false))); +} + #[test] fn printer_commands_answer_without_a_document_and_reject_bad_settings() { let mut s = Session::new(); diff --git a/crates/winprint/src/windows.rs b/crates/winprint/src/windows.rs index ec9bd784fb..0e00338668 100644 --- a/crates/winprint/src/windows.rs +++ b/crates/winprint/src/windows.rs @@ -458,6 +458,11 @@ mod tests { let out = std::env::temp_dir().join(format!("pc-winprint-{}.pdf", std::process::id())); let _ = std::fs::remove_file(&out); let settings = crate::default_settings("Microsoft Print to PDF").unwrap(); + let mut bytes = settings.as_bytes().to_vec(); + bytes[86..88].copy_from_slice(&5i16.to_le_bytes()); + let fields = u32::from_le_bytes(bytes[72..76].try_into().unwrap()) | 0x100; + bytes[72..76].copy_from_slice(&fields.to_le_bytes()); + let settings = DevMode::from_bytes(bytes).unwrap(); let (w, h) = (300u32, 200u32); let data: Vec = (0..w * h).flat_map(|i| if (i % w) < w / 2 { [220, 30, 30] } else { [30, 30, 220] }).collect(); let out_s = out.to_string_lossy().into_owned(); @@ -475,6 +480,12 @@ mod tests { let _ = done2; let pdf_no_marks = std::fs::read(&out).unwrap(); assert!(pdf.len() > pdf_no_marks.len() + 100, "marks add vector content: {} vs {}", pdf.len(), pdf_no_marks.len()); + if let Ok(evidence) = std::env::var("PHOTOCRAFT_WINPRINT_TEST_EVIDENCE") { + let evidence = std::path::Path::new(&evidence); + std::fs::create_dir_all(evidence).unwrap(); + std::fs::write(evidence.join("copies2-driver5-marks.pdf"), &pdf).unwrap(); + std::fs::write(evidence.join("copies2-driver5-no-marks.pdf"), &pdf_no_marks).unwrap(); + } let _ = std::fs::remove_file(&out); } From 3702555aa9ded0393aa70cb1e81ae92a679d65b6 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 02:02:13 +0200 Subject: [PATCH 006/167] Make native RGB color policy explicit and preserve embedded profiles --- crates/engine/src/print_cmds.rs | 23 ++++++++++++++--- crates/engine/src/print_cmds/tests.rs | 16 +++++++----- crates/winprint/src/lib.rs | 14 ++++++++++- crates/winprint/src/windows.rs | 36 +++++++++++++++++++++------ 4 files changed, 70 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 4093c8dd35..aa8f7d2a8a 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -391,7 +391,8 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { // Windows has no `lp`: print through the driver (GDI, ICM off) instead of spooling a PDF. // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. - if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page) { + let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages"); + if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, @@ -399,7 +400,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { })); } if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() { - return Err(other("CMYK profiles and description/label text use PDF composition; these native driver settings cannot be applied. Save as PDF, or clear the driver settings to use PDF printing.")); + return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing.")); } let pdf = print_pdf(&page); let job = craft_print::Job { @@ -432,8 +433,9 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { /// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently /// draws RGB/gray and vector marks only; it must never silently omit requested page content. -fn native_rgb_compatible(page: &PrintPage) -> bool { +fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { matches!(page.image.channels, 1 | 3) + && (page.image.icc.is_none() || matches!(handling, "photocraftManages" | "photoshopManages" | "noColorManagement")) && page.description.as_deref().is_none_or(str::is_empty) && page.label.as_deref().is_none_or(str::is_empty) } @@ -508,7 +510,20 @@ fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Resul let rgb = page_rgb(page)?; let image = photocraft_winprint::RgbImage { width: page.image.width, height: page.image.height, data: &rgb }; let marks = photocraft_winprint::Marks { corner_crop: page.marks.corner_crop, center_crop: page.marks.center_crop, registration: page.marks.registration }; - let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999), marks }; + let color_policy = match p.get("colorHandling").and_then(Value::as_str) { + Some("photocraftManages" | "photoshopManages") => photocraft_winprint::ColorPolicy::ApplicationManaged, + Some("noColorManagement") => photocraft_winprint::ColorPolicy::Unmanaged, + _ => photocraft_winprint::ColorPolicy::DriverManaged, + }; + let job = photocraft_winprint::Job { + printer: &printer, + settings: settings.as_ref(), + title, + output_file: None, + copies: copies.clamp(1, 999), + color_policy, + marks, + }; let done = photocraft_winprint::print(&job, &image, page_rect_top_left(page)).map_err(other)?; Ok( json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3]}), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index b240efb48a..023829d72d 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -178,16 +178,20 @@ fn windows_print_image_is_rgb_mirrored_for_emulsion_down() { #[test] fn native_rgb_route_preserves_pdf_only_color_and_page_content() { let mut page = page_of(3, vec![0; 6], false); - assert!(native_rgb_compatible(&page)); + assert!(native_rgb_compatible(&page, "printerManages")); page.label = Some("Print Me".into()); - assert!(!native_rgb_compatible(&page)); + assert!(!native_rgb_compatible(&page, "printerManages")); page.label = None; page.description = Some("Proof description".into()); - assert!(!native_rgb_compatible(&page)); + assert!(!native_rgb_compatible(&page, "printerManages")); page.description = Some(String::new()); - assert!(native_rgb_compatible(&page)); - assert!(!native_rgb_compatible(&page_of(4, vec![0; 8], false))); - assert!(native_rgb_compatible(&page_of(1, vec![0; 2], false))); + assert!(native_rgb_compatible(&page, "printerManages")); + page.image.icc = Some(vec![1, 2, 3]); + assert!(!native_rgb_compatible(&page, "printerManages"), "preserve embedded source ICC in the PDF route"); + assert!(native_rgb_compatible(&page, "photocraftManages")); + assert!(native_rgb_compatible(&page, "photoshopManages")); + assert!(!native_rgb_compatible(&page_of(4, vec![0; 8], false), "photocraftManages")); + assert!(native_rgb_compatible(&page_of(1, vec![0; 2], false), "noColorManagement")); } #[test] diff --git a/crates/winprint/src/lib.rs b/crates/winprint/src/lib.rs index e0c5729881..ef20e28ce1 100644 --- a/crates/winprint/src/lib.rs +++ b/crates/winprint/src/lib.rs @@ -150,6 +150,17 @@ impl Marks { } } +/// One print job. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColorPolicy { + /// The driver applies its color settings to the RGB image. + DriverManaged, + /// The caller has converted the pixels into the printer's RGB space. + ApplicationManaged, + /// The caller deliberately requests no DC color correction. + Unmanaged, +} + /// One print job. pub struct Job<'a> { pub printer: &'a str, @@ -159,6 +170,7 @@ pub struct Job<'a> { /// Print to this file instead of the device (drivers that write files, and tests). pub output_file: Option<&'a str>, pub copies: u32, + pub color_policy: ColorPolicy, pub marks: Marks, } @@ -315,7 +327,7 @@ mod tests { #[test] fn print_rejects_empty_or_short_images_before_any_system_call() { - let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1, marks: Marks::default() }; + let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1, color_policy: ColorPolicy::Unmanaged, marks: Marks::default() }; let short = RgbImage { width: 2, height: 2, data: &[0; 11] }; assert!(matches!(print(&job, &short, (0.0, 0.0, 10.0, 10.0)), Err(PrintError::Invalid(_)))); let ok = RgbImage { width: 2, height: 2, data: &[0; 12] }; diff --git a/crates/winprint/src/windows.rs b/crates/winprint/src/windows.rs index 0e00338668..103309dcd0 100644 --- a/crates/winprint/src/windows.rs +++ b/crates/winprint/src/windows.rs @@ -15,9 +15,9 @@ use windows_sys::Win32::Graphics::Printing::{ PRINTER_INFO_4W, }; use windows_sys::Win32::Storage::Xps::{AbortDoc, DOCINFOW, EndDoc, EndPage, StartDocW, StartPage}; -use windows_sys::Win32::UI::ColorSystem::{ICM_OFF, SetICMMode}; +use windows_sys::Win32::UI::ColorSystem::{ICM_DONE_OUTSIDEDC, ICM_OFF, SetICMMode}; -use crate::{DevMode, Job, Marks, Page, PrintError, Printed, Printer, Result, RgbImage}; +use crate::{ColorPolicy, DevMode, Job, Marks, Page, PrintError, Printed, Printer, Result, RgbImage}; const IDOK: i32 = 1; const IDCANCEL: i32 = 2; @@ -349,11 +349,17 @@ pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> lpszDatatype: null(), fwType: 0, }; - // SAFETY: a valid printer DC. ICM off: the pixels are already in the printer's space - // (or deliberately unmanaged), so GDI must not convert them again. - if unsafe { SetICMMode(dc.0, ICM_OFF) } == 0 { - return Err(last_error("disabling GDI color management")); - } + let color_mode = match job.color_policy { + ColorPolicy::DriverManaged => ICM_OFF, + // ICM_OFF enables old-style halftone correction. Pixels converted by the caller + // need both DC conversion and that correction off (Microsoft SetICMMode contract). + ColorPolicy::ApplicationManaged | ColorPolicy::Unmanaged => ICM_DONE_OUTSIDEDC, + }; + let set_color_mode = || { + // SAFETY: a valid printer DC and a documented SetICMMode value. + if unsafe { SetICMMode(dc.0, color_mode) } == 0 { Err(last_error("setting GDI color policy")) } else { Ok(()) } + }; + set_color_mode()?; // SAFETY: a valid DC; HALFTONE resampling needs the brush origin reset afterwards. unsafe { SetStretchBltMode(dc.0, HALFTONE); @@ -374,6 +380,12 @@ pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> if unsafe { StartPage(dc.0) } <= 0 { return Err(fail("starting a page")); } + // StartPage saves/restores DC state. Reapply the policy for each page, before pixels. + if let Err(error) = set_color_mode() { + // SAFETY: this DC owns the document started above. + unsafe { AbortDoc(dc.0) }; + return Err(error); + } // SAFETY: `bits` is a bottom-up DIB exactly as `bmi` describes (24-bit, padded rows, // `biSizeImage` bytes); the source rectangle is the whole image. let lines = @@ -467,7 +479,15 @@ mod tests { let data: Vec = (0..w * h).flat_map(|i| if (i % w) < w / 2 { [220, 30, 30] } else { [30, 30, 220] }).collect(); let out_s = out.to_string_lossy().into_owned(); let marks = crate::Marks { corner_crop: true, center_crop: true, registration: true }; - let job = Job { printer: "Microsoft Print to PDF", settings: Some(&settings), title: "PhotoCraft test", output_file: Some(&out_s), copies: 2, marks }; + let job = Job { + printer: "Microsoft Print to PDF", + settings: Some(&settings), + title: "PhotoCraft test", + output_file: Some(&out_s), + copies: 2, + color_policy: ColorPolicy::ApplicationManaged, + marks, + }; let done = crate::print(&job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap(); assert_eq!(done.device_rect.2, done.dpi.0 as i32 * 3, "3 inches wide"); let pdf = std::fs::read(&out).unwrap(); From d9bc5a8cd51bf967aede79d41a96e2e15328c00d Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 02:56:11 +0200 Subject: [PATCH 007/167] Use shared native print transport and keep page composition in PhotoCraft --- Cargo.lock | 1229 ++++++++++++++++- Cargo.toml | 3 +- crates/engine/Cargo.toml | 1 - crates/engine/src/print_cmds.rs | 33 +- crates/engine/src/print_cmds/tests.rs | 4 +- crates/engine/src/print_cmds/windows_marks.rs | 47 + crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/file_ui.rs | 10 +- crates/winprint/Cargo.toml | 26 - crates/winprint/src/lib.rs | 339 ----- crates/winprint/src/windows.rs | 520 ------- xtask/src/layers.rs | 1 - 12 files changed, 1288 insertions(+), 927 deletions(-) create mode 100644 crates/engine/src/print_cmds/windows_marks.rs delete mode 100644 crates/winprint/Cargo.toml delete mode 100644 crates/winprint/src/lib.rs delete mode 100644 crates/winprint/src/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 08d317ce94..9ba37f36a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,12 +550,48 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.72.1" @@ -645,7 +681,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -654,6 +690,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -885,7 +930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -896,8 +941,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -973,6 +1020,39 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1028,6 +1108,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1103,6 +1189,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1112,6 +1204,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1124,9 +1225,14 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=ee70afc29a41bc50f2a55affa9aa2c62e0d28e3a#ee70afc29a41bc50f2a55affa9aa2c62e0d28e3a" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree", "tempfile", + "winprint", + "winsafe", ] [[package]] @@ -1138,6 +1244,14 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1181,6 +1295,18 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1221,6 +1347,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1267,6 +1403,30 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dify" version = "0.8.0" @@ -1280,6 +1440,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1394,6 +1564,12 @@ dependencies = [ "linux-raw-sys 0.9.4", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1578,6 +1754,20 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1797,6 +1987,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1809,6 +2009,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1826,6 +2032,28 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fmv-macos-events" version = "0.1.0" @@ -1948,6 +2176,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.34" @@ -2073,6 +2307,16 @@ dependencies = [ "libc", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2092,6 +2336,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2111,9 +2368,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2279,6 +2538,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2359,6 +2637,107 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3188,6 +3567,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "maybe-owned" version = "0.3.4" @@ -3237,6 +3622,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3302,6 +3693,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -3997,6 +4394,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -4116,6 +4519,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -4258,7 +4680,7 @@ dependencies = [ name = "photocraft-automation" version = "0.8.2" dependencies = [ - "base64", + "base64 0.22.1", "cap-std", "getrandom 0.3.4", "photocraft-codecs", @@ -4379,7 +4801,6 @@ dependencies = [ "photocraft-raw", "photocraft-text", "photocraft-vector", - "photocraft-winprint", "rayon", "roxmltree", "serde", @@ -4579,7 +5000,8 @@ dependencies = [ name = "photocraft-ui-egui" version = "0.8.2" dependencies = [ - "base64", + "base64 0.22.1", + "craft-print", "craft-ui", "eframe", "egui", @@ -4607,7 +5029,6 @@ dependencies = [ "photocraft-testkit", "photocraft-text", "photocraft-vector", - "photocraft-winprint", "rayon", "serde", "serde_json", @@ -4649,13 +5070,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "photocraft-winprint" -version = "0.8.2" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "pico-args" version = "0.5.0" @@ -4856,6 +5270,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -4962,6 +5400,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -5014,6 +5509,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -5029,6 +5530,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -5239,6 +5749,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -5292,6 +5845,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "3.5.0" @@ -5419,6 +5986,81 @@ dependencies = [ "rustix 1.1.5", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -5464,6 +6106,18 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -5473,6 +6127,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "1.2.2" @@ -5511,6 +6174,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -5577,6 +6263,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -5597,6 +6294,40 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5619,6 +6350,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand 0.10.3", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5796,6 +6696,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5839,6 +6749,33 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -5849,6 +6786,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -5871,6 +6818,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5891,6 +6847,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -5904,6 +6881,17 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -6039,6 +7027,27 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -6065,6 +7074,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -6141,6 +7160,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -6194,6 +7258,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6224,6 +7294,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -6301,6 +7377,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -6319,7 +7407,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -6427,6 +7515,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6761,6 +7858,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -7048,6 +8154,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -7300,6 +8417,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + [[package]] name = "winresource" version = "0.1.31" @@ -7388,6 +8526,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcap" version = "0.9.8" @@ -7455,6 +8628,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -7652,6 +8831,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 408997d343..54bd1a3a05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,10 +36,9 @@ photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } -photocraft-winprint = { path = "crates/winprint" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "ee70afc29a41bc50f2a55affa9aa2c62e0d28e3a" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 2f144ce57e..741d927a3f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,7 +19,6 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-ops = { workspace = true } photocraft-compose = { workspace = true } -photocraft-winprint = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index aa8f7d2a8a..419ffd55aa 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -14,6 +14,8 @@ use std::io::Write as _; +mod windows_marks; + use photocraft_color::ColorMode; use photocraft_doc::{Document, LayerContent, SmartSource}; use serde_json::{Value, json}; @@ -457,13 +459,13 @@ fn printer_name(p: &Value) -> Result { if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { return Ok(name.to_string()); } - photocraft_winprint::default_printer().map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set")) + craft_print::rgb::default_printer().map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set")) } /// The driver settings chosen with Print Settings… (`printerSettings`, hex), when any. -fn printer_settings(p: &Value) -> Result> { +fn printer_settings(p: &Value) -> Result> { match p.get("printerSettings").and_then(Value::as_str).filter(|v| !v.is_empty()) { - Some(hex) => photocraft_winprint::DevMode::from_hex(hex).map(Some).map_err(|e| bad("file.print", e.to_string())), + Some(hex) => craft_print::rgb::DevMode::from_hex(hex).map(Some).map_err(|e| bad("file.print", e.to_string())), None => Ok(None), } } @@ -503,44 +505,45 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) { fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result { let printer = printer_name(p)?; let settings = printer_settings(p)?; - let device_page = photocraft_winprint::page(&printer, settings.as_ref()).map_err(other)?; + let device_page = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?; if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 { return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing")); } let rgb = page_rgb(page)?; - let image = photocraft_winprint::RgbImage { width: page.image.width, height: page.image.height, data: &rgb }; - let marks = photocraft_winprint::Marks { corner_crop: page.marks.corner_crop, center_crop: page.marks.center_crop, registration: page.marks.registration }; + let image = craft_print::rgb::RgbImage { width: page.image.width, height: page.image.height, data: &rgb }; + let rect = page_rect_top_left(page); + let primitives = windows_marks::primitives(page.marks, rect); let color_policy = match p.get("colorHandling").and_then(Value::as_str) { - Some("photocraftManages" | "photoshopManages") => photocraft_winprint::ColorPolicy::ApplicationManaged, - Some("noColorManagement") => photocraft_winprint::ColorPolicy::Unmanaged, - _ => photocraft_winprint::ColorPolicy::DriverManaged, + Some("photocraftManages" | "photoshopManages") => craft_print::rgb::ColorPolicy::ApplicationManaged, + Some("noColorManagement") => craft_print::rgb::ColorPolicy::Unmanaged, + _ => craft_print::rgb::ColorPolicy::DriverManaged, }; - let job = photocraft_winprint::Job { + let job = craft_print::rgb::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999), color_policy, - marks, + primitives: &primitives, }; - let done = photocraft_winprint::print(&job, &image, page_rect_top_left(page)).map_err(other)?; + let done = craft_print::rgb::print(&job, &image, rect).map_err(other)?; Ok( json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3]}), ) } fn printers(_: &mut Session, _: &Value) -> Result { - match photocraft_winprint::printers() { + match craft_print::rgb::printers() { Ok(list) => Ok(json!({"supported": true, "printers": list.iter().map(|p| json!({"name": p.name, "default": p.default})).collect::>()})), - Err(photocraft_winprint::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})), + Err(craft_print::rgb::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})), Err(e) => Err(other(e)), } } fn printer_page(_: &mut Session, p: &Value) -> Result { let printer = printer_name(p)?; - let pg = photocraft_winprint::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?; + let pg = craft_print::rgb::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?; Ok( json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}), ) diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 023829d72d..6181097532 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -52,7 +52,7 @@ fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { assert!(!cmd.contains(&out), "PDF bytes are streamed instead of passing a path"); } #[cfg(windows)] - assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); + assert_eq!(cmd.first().map(String::as_str), Some("xps-print")); assert_eq!(r["sent"], false); assert_eq!(r["pdf"], out); let pdf = std::fs::read(&out).unwrap(); @@ -237,7 +237,7 @@ fn print_one_copy_repeats_the_last_settings() { #[cfg(unix)] assert_eq!(&cmd[..3], &["lp", "-n", "1"]); #[cfg(windows)] - assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); + assert_eq!(cmd.first().map(String::as_str), Some("xps-print")); assert!(r["pdf"].is_null()); } diff --git a/crates/engine/src/print_cmds/windows_marks.rs b/crates/engine/src/print_cmds/windows_marks.rs new file mode 100644 index 0000000000..857e6335a9 --- /dev/null +++ b/crates/engine/src/print_cmds/windows_marks.rs @@ -0,0 +1,47 @@ +//! App-owned print-mark composition in points from the sheet's top-left. +use super::Marks; +use craft_print::rgb::{Primitive, Shape}; + +pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Vec { + let mut out = Vec::with_capacity(24); + let line = |out: &mut Vec, x0, y0, x1, y1| { + out.push(Primitive { shape: Shape::Line { from: (x0, y0), to: (x1, y1) }, width: 0.3, color: [0; 3] }); + }; + let (gap, len) = (6.0, 18.0); + if marks.corner_crop { + for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { + line(&mut out, cx + dx * gap, cy, cx + dx * (gap + len), cy); + line(&mut out, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + } + } + if marks.center_crop { + let (mx, my) = (x + w / 2.0, y + h / 2.0); + line(&mut out, mx, y - gap, mx, y - gap - len); + line(&mut out, mx, y + h + gap, mx, y + h + gap + len); + line(&mut out, x - gap, my, x - gap - len, my); + line(&mut out, x + w + gap, my, x + w + gap + len, my); + } + if marks.registration { + let (r, off) = (5.0, gap + len / 2.0); + for (cx, cy) in [(x + w / 2.0, y - off), (x + w / 2.0, y + h + off), (x - off, y + h / 2.0), (x + w + off, y + h / 2.0)] { + out.push(Primitive { shape: Shape::Ellipse { bounds: (cx - r, cy - r, 2.0 * r, 2.0 * r) }, width: 0.3, color: [0; 3] }); + line(&mut out, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(&mut out, cx, cy - r * 1.6, cx, cy + r * 1.6); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn marks_surround_the_final_image_box_in_physical_points() { + let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true }, (100.0, 200.0, 144.0, 72.0)); + assert_eq!(out.len(), 24); + assert_eq!(out[0].shape, Shape::Line { from: (94.0, 200.0), to: (76.0, 200.0) }); + assert_eq!(out[12].shape, Shape::Ellipse { bounds: (167.0, 180.0, 10.0, 10.0) }); + assert!(out.iter().all(|p| p.width == 0.3 && p.color == [0; 3])); + assert!(primitives(Marks::default(), (0.0, 0.0, 10.0, 10.0)).is_empty()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 9925af20a9..aeefacaf2f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -8,11 +8,11 @@ license.workspace = true workspace = true [dependencies] +craft-print = { workspace = true } craft-ui = { workspace = true } photocraft-geom = { workspace = true } photocraft-color = { workspace = true } photocraft-cms = { workspace = true } -photocraft-winprint = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 9805db14c3..d57a450e05 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -594,7 +594,7 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { f.entry(k.to_string()).or_insert(v); } // Windows: the system's printers, and the paper from the chosen printer's driver settings. - if let Ok(list) = photocraft_winprint::printers() { + if let Ok(list) = craft_print::rgb::printers() { f.insert("__printers".into(), json!(list.iter().map(|p| p.name.clone()).collect::>())); let known = |n: &str| list.iter().any(|p| p.name == n); if !known(&s(&f, "printer", "")) { @@ -610,8 +610,8 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { /// Takes the paper from the printer's driver settings (Windows), as Photoshop does. fn driver_page(f: &mut Map) { let printer = s(f, "printer", ""); - let settings = f.get("printerSettings").and_then(Value::as_str).and_then(|h| photocraft_winprint::DevMode::from_hex(h).ok()); - match photocraft_winprint::page(&printer, settings.as_ref()) { + let settings = f.get("printerSettings").and_then(Value::as_str).and_then(|h| craft_print::rgb::DevMode::from_hex(h).ok()); + match craft_print::rgb::page(&printer, settings.as_ref()) { Ok(pg) => { f.insert("paper".into(), json!([pg.paper.0, pg.paper.1])); f.insert("orientation".into(), json!("portrait")); @@ -747,8 +747,8 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { f.insert("printerSettings".into(), json!(dm.to_hex())); driver_page(f); diff --git a/crates/winprint/Cargo.toml b/crates/winprint/Cargo.toml deleted file mode 100644 index 09504156d5..0000000000 --- a/crates/winprint/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "photocraft-winprint" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Windows printing: printer list, the driver's Print Settings dialog, page geometry and GDI printing with ICM off" - -# An isolated `unsafe` helper crate like photocraft-tablet (craftrules never-crash, AGENTS.md): -# Windows printing needs Win32 calls. Only `src/windows.rs` allows `unsafe`; every block has a -# `SAFETY:` comment. Elsewhere every function returns `Unsupported`. -[lints.rust] -unsafe_code = "deny" - -[lints.clippy] -dbg_macro = "warn" -todo = "warn" - -# Already in Cargo.lock (winit/wgpu pull it in); pinned exactly: review the diff before bumping. -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "=0.61.2", features = [ - "Win32_Foundation", - "Win32_Graphics_Gdi", - "Win32_Graphics_Printing", - "Win32_Storage_Xps", - "Win32_UI_ColorSystem", -] } diff --git a/crates/winprint/src/lib.rs b/crates/winprint/src/lib.rs deleted file mode 100644 index ef20e28ce1..0000000000 --- a/crates/winprint/src/lib.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Windows printing for File › Print: the system's printers, the driver's own Print Settings -//! dialog (paper, paper type, quality, the driver's colour correction and mirror), the page -//! geometry it implies, and printing an 8-bit RGB image through GDI with ICM off, so colours -//! converted with a printer profile ("PhotoCraft Manages Colors") reach the driver unchanged. -//! Corner/centre crop marks and registration marks are drawn around the image with GDI vector -//! calls (`MoveToEx`/`LineTo`/`Ellipse`), the same geometry as the PDF path's content stream. -//! Description and label text stay PDF-only (no font/text layout here yet). -//! -//! The driver settings travel as an opaque DEVMODE blob ([`DevMode`]), validated before use. -//! Everything here is a safe API; the Win32 calls live in `windows.rs`. Other platforms get -//! [`PrintError::Unsupported`]. -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -#[cfg(windows)] -#[allow(unsafe_code)] -mod windows; - -use std::fmt; - -/// Why a printing call failed. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PrintError { - /// Not on Windows. - Unsupported, - /// Bad input (an unknown printer, a malformed DEVMODE, an empty image). - Invalid(String), - /// A Win32 call failed. - System(String), -} - -impl fmt::Display for PrintError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PrintError::Unsupported => f.write_str("printing to a printer needs Windows here"), - PrintError::Invalid(m) | PrintError::System(m) => f.write_str(m), - } - } -} - -impl std::error::Error for PrintError {} - -pub type Result = std::result::Result; - -/// One installed printer. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Printer { - pub name: String, - pub default: bool, -} - -/// A printer driver's settings (a Win32 DEVMODEW with the driver's private part), as bytes. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DevMode(Vec); - -/// Offsets inside DEVMODEW: `dmSize` and `dmDriverExtra` follow the 32-wchar device name and -/// two version words. -const DM_SIZE_AT: usize = 68; -const DM_EXTRA_AT: usize = 70; -/// The smallest DEVMODEW a driver writes (Windows 95's, up to `dmICMIntent`). -const DM_MIN: usize = 156; -/// Larger blobs are not driver settings. -const DM_MAX: usize = 1 << 20; - -impl DevMode { - /// Validates driver settings bytes: the header's own sizes must add up to the length. - pub fn from_bytes(b: Vec) -> Result { - let word = |at: usize| b.get(at..at + 2).map(|w| usize::from(u16::from_le_bytes([w[0], w[1]]))); - let (Some(size), Some(extra)) = (word(DM_SIZE_AT), word(DM_EXTRA_AT)) else { - return Err(PrintError::Invalid("printer settings are too short".into())); - }; - if b.len() > DM_MAX || size < DM_MIN || size.checked_add(extra) != Some(b.len()) { - return Err(PrintError::Invalid("printer settings are damaged; choose Print Settings again".into())); - } - Ok(DevMode(b)) - } - - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - - /// The application emits every copy as a page. Disable driver copies/collation too, - /// preserving the driver's private settings and all other public fields. - #[cfg(any(windows, test))] - pub(crate) fn for_manual_copies(&self) -> Result { - let mut bytes = self.0.clone(); - let invalid = || PrintError::Invalid("printer settings are too short".into()); - let fields: [u8; 4] = bytes.get(72..76).and_then(|b| b.try_into().ok()).ok_or_else(invalid)?; - let fields = u32::from_le_bytes(fields) | 0x100 | 0x8000; // DM_COPIES | DM_COLLATE - bytes.get_mut(72..76).ok_or_else(invalid)?.copy_from_slice(&fields.to_le_bytes()); - bytes.get_mut(86..88).ok_or_else(invalid)?.copy_from_slice(&1i16.to_le_bytes()); - bytes.get_mut(100..102).ok_or_else(invalid)?.copy_from_slice(&0i16.to_le_bytes()); - Ok(Self(bytes)) - } - - #[cfg(any(windows, test))] - pub(crate) fn has_manual_copies(&self) -> bool { - let fields = self.0.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes); - fields.is_some_and(|f| f & 0x100 != 0) - && self.0.get(86..88) == Some(1i16.to_le_bytes().as_slice()) - && self.0.get(100..102) == Some(0i16.to_le_bytes().as_slice()) - } - - /// Hex, for JSON parameters and remembered print settings. - pub fn to_hex(&self) -> String { - self.0.iter().map(|b| format!("{b:02x}")).collect() - } - - pub fn from_hex(s: &str) -> Result { - if !s.len().is_multiple_of(2) || s.len() > DM_MAX * 2 { - return Err(PrintError::Invalid("printer settings are damaged; choose Print Settings again".into())); - } - let bytes: Option> = (0..s.len()).step_by(2).map(|i| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); - Self::from_bytes(bytes.ok_or_else(|| PrintError::Invalid("printer settings are damaged; choose Print Settings again".into()))?) - } -} - -/// What a printer will print on with given settings. Points (1/72 in), origin top-left. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Page { - /// The whole sheet. - pub paper: (f64, f64), - /// The area the printer can reach: (x, y, width, height). - pub printable: (f64, f64, f64, f64), - /// Device resolution (dots per inch). - pub dpi: (u32, u32), -} - -/// An 8-bit RGB image, rows top to bottom, `width * 3` bytes each. -pub struct RgbImage<'a> { - pub width: u32, - pub height: u32, - pub data: &'a [u8], -} - -/// Printer marks drawn around the image box: corner and centre crop marks, and registration -/// marks. Matches [`photocraft_engine`]'s PDF `Marks`, mirrored here so this crate stays -/// independent of the engine; GDI draws the same geometry with vector line/ellipse calls instead -/// of PDF content stream operators. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Marks { - pub corner_crop: bool, - pub center_crop: bool, - pub registration: bool, -} - -impl Marks { - #[cfg(windows)] - pub(crate) fn any(self) -> bool { - self.corner_crop || self.center_crop || self.registration - } -} - -/// One print job. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ColorPolicy { - /// The driver applies its color settings to the RGB image. - DriverManaged, - /// The caller has converted the pixels into the printer's RGB space. - ApplicationManaged, - /// The caller deliberately requests no DC color correction. - Unmanaged, -} - -/// One print job. -pub struct Job<'a> { - pub printer: &'a str, - pub settings: Option<&'a DevMode>, - /// The name in the print queue. - pub title: &'a str, - /// Print to this file instead of the device (drivers that write files, and tests). - pub output_file: Option<&'a str>, - pub copies: u32, - pub color_policy: ColorPolicy, - pub marks: Marks, -} - -/// Where a job went: the image box in device pixels and the device resolution. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Printed { - pub device_rect: (i32, i32, i32, i32), - pub dpi: (u32, u32), -} - -/// Maps an image box in points (origin top-left of the sheet) to device pixels (origin at the -/// printable area's corner, as GDI draws). -pub fn device_rect(page: &Page, rect: (f64, f64, f64, f64)) -> (i32, i32, i32, i32) { - let (dx, dy) = (f64::from(page.dpi.0) / 72.0, f64::from(page.dpi.1) / 72.0); - let px = |v: f64| if v.is_finite() { v.round().clamp(-1e8, 1e8) as i32 } else { 0 }; - let (x, y, w, h) = rect; - (px((x - page.printable.0) * dx), px((y - page.printable.1) * dy), px(w * dx), px(h * dy)) -} - -/// The printers installed for this user, the default first. -pub fn printers() -> Result> { - #[cfg(windows)] - { - let mut v = windows::printers()?; - v.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); - Ok(v) - } - #[cfg(not(windows))] - Err(PrintError::Unsupported) -} - -/// The default printer's name. -pub fn default_printer() -> Result> { - #[cfg(windows)] - { - windows::default_printer() - } - #[cfg(not(windows))] - Err(PrintError::Unsupported) -} - -/// A printer's settings as its driver starts them. -pub fn default_settings(printer: &str) -> Result { - #[cfg(windows)] - { - windows::properties(printer, None, None, false)?.ok_or_else(|| PrintError::System("the printer driver gave no settings".into())) - } - #[cfg(not(windows))] - { - let _ = printer; - Err(PrintError::Unsupported) - } -} - -/// Shows the driver's Print Settings dialog (modal; `parent` is a window handle). `None` when -/// the user cancels. -pub fn settings_dialog(printer: &str, current: Option<&DevMode>, parent: Option) -> Result> { - #[cfg(windows)] - { - windows::properties(printer, current, parent, true) - } - #[cfg(not(windows))] - { - let _ = (printer, current, parent); - Err(PrintError::Unsupported) - } -} - -/// The sheet and printable area for `printer` with `settings`. -pub fn page(printer: &str, settings: Option<&DevMode>) -> Result { - #[cfg(windows)] - { - windows::page(printer, settings) - } - #[cfg(not(windows))] - { - let _ = (printer, settings); - Err(PrintError::Unsupported) - } -} - -/// Prints `image` into `rect` (points, origin top-left of the sheet), `job.copies` pages. -pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result { - let row = (image.width as usize).checked_mul(3).ok_or_else(|| PrintError::Invalid("image too large".into()))?; - if image.width == 0 || image.height == 0 || row.checked_mul(image.height as usize) != Some(image.data.len()) { - return Err(PrintError::Invalid("nothing to print".into())); - } - if ![rect.0, rect.1, rect.2, rect.3].iter().all(|v| v.is_finite()) || !(rect.2 > 0.0 && rect.3 > 0.0) { - return Err(PrintError::Invalid("the print size is empty".into())); - } - #[cfg(windows)] - { - windows::print(job, image, rect) - } - #[cfg(not(windows))] - { - let _ = job; - Err(PrintError::Unsupported) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn devmode(size: u16, extra: u16) -> Vec { - let mut b = vec![0u8; usize::from(size) + usize::from(extra)]; - b[DM_SIZE_AT..DM_SIZE_AT + 2].copy_from_slice(&size.to_le_bytes()); - b[DM_EXTRA_AT..DM_EXTRA_AT + 2].copy_from_slice(&extra.to_le_bytes()); - b - } - - #[test] - fn devmode_checks_its_own_sizes_and_round_trips_hex() { - let d = DevMode::from_bytes(devmode(220, 1200)).unwrap(); - assert_eq!(DevMode::from_hex(&d.to_hex()).unwrap(), d); - for bad in [vec![], vec![0; 40], devmode(100, 0), { - let mut b = devmode(220, 10); - b.pop(); - b - }] { - assert!(DevMode::from_bytes(bad).is_err()); - } - for bad in ["", "abc", "zz", &"00".repeat(300)] { - assert!(DevMode::from_hex(bad).is_err(), "{bad}"); - } - } - - #[test] - fn manual_copies_disable_driver_multiplication_without_losing_private_settings() { - let mut bytes = devmode(220, 32); - bytes[86..88].copy_from_slice(&7i16.to_le_bytes()); - bytes[100..102].copy_from_slice(&1i16.to_le_bytes()); - bytes[220..].fill(0x5a); - let original = DevMode::from_bytes(bytes).unwrap(); - let normalized = original.for_manual_copies().unwrap(); - assert!(normalized.has_manual_copies()); - assert!(!original.has_manual_copies()); - assert_eq!(&normalized.as_bytes()[220..], &original.as_bytes()[220..]); - assert_eq!(normalized.for_manual_copies().unwrap(), normalized); - let mut ignored = normalized.as_bytes().to_vec(); - ignored[72..76].copy_from_slice(&0u32.to_le_bytes()); - assert!(!DevMode::from_bytes(ignored).unwrap().has_manual_copies(), "a driver must advertise that it accepts dmCopies"); - } - - #[test] - fn device_rect_is_relative_to_the_printable_corner() { - // A3 at 360 dpi with a 3 mm (8.5 pt) unprintable margin. - let page = Page { paper: (841.89, 1190.55), printable: (8.5, 8.5, 824.89, 1173.55), dpi: (360, 360) }; - assert_eq!(device_rect(&page, (8.5, 8.5, 72.0, 36.0)), (0, 0, 360, 180)); - assert_eq!(device_rect(&page, (72.0, 144.0, 144.0, 72.0)), (318, 678, 720, 360)); - assert_eq!(device_rect(&page, (f64::NAN, f64::INFINITY, 1.0, 1.0)).0, 0); - } - - #[test] - fn print_rejects_empty_or_short_images_before_any_system_call() { - let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1, color_policy: ColorPolicy::Unmanaged, marks: Marks::default() }; - let short = RgbImage { width: 2, height: 2, data: &[0; 11] }; - assert!(matches!(print(&job, &short, (0.0, 0.0, 10.0, 10.0)), Err(PrintError::Invalid(_)))); - let ok = RgbImage { width: 2, height: 2, data: &[0; 12] }; - assert!(matches!(print(&job, &ok, (0.0, 0.0, 0.0, 10.0)), Err(PrintError::Invalid(_)))); - for rect in [(f64::NAN, 0.0, 10.0, 10.0), (0.0, 0.0, f64::INFINITY, 10.0)] { - assert!(matches!(print(&job, &ok, rect), Err(PrintError::Invalid(_)))); - } - } -} diff --git a/crates/winprint/src/windows.rs b/crates/winprint/src/windows.rs deleted file mode 100644 index 103309dcd0..0000000000 --- a/crates/winprint/src/windows.rs +++ /dev/null @@ -1,520 +0,0 @@ -//! The Win32 side: winspool (printers, driver settings) and GDI (page geometry, printing). -//! Every `unsafe` block says why it is sound. Buffers the system writes into are `u64`-backed so -//! the structures read from them are aligned. - -use std::ptr::{null, null_mut}; - -use windows_sys::Win32::Foundation::HWND; -use windows_sys::Win32::Graphics::Gdi::{ - BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDCW, CreatePen, DEVMODEW, DIB_RGB_COLORS, DM_IN_BUFFER, DM_IN_PROMPT, DM_OUT_BUFFER, DeleteDC, DeleteObject, - Ellipse, GetDeviceCaps, GetStockObject, HALFTONE, HDC, HOLLOW_BRUSH, HORZRES, LOGPIXELSX, LOGPIXELSY, LineTo, MoveToEx, PHYSICALHEIGHT, PHYSICALOFFSETX, - PHYSICALOFFSETY, PHYSICALWIDTH, PS_SOLID, SRCCOPY, SelectObject, SetBrushOrgEx, SetStretchBltMode, StretchDIBits, VERTRES, -}; -use windows_sys::Win32::Graphics::Printing::{ - ClosePrinter, DocumentPropertiesW, EnumPrintersW, GetDefaultPrinterW, OpenPrinterW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_HANDLE, - PRINTER_INFO_4W, -}; -use windows_sys::Win32::Storage::Xps::{AbortDoc, DOCINFOW, EndDoc, EndPage, StartDocW, StartPage}; -use windows_sys::Win32::UI::ColorSystem::{ICM_DONE_OUTSIDEDC, ICM_OFF, SetICMMode}; - -use crate::{ColorPolicy, DevMode, Job, Marks, Page, PrintError, Printed, Printer, Result, RgbImage}; - -const IDOK: i32 = 1; -const IDCANCEL: i32 = 2; -/// Wide strings read from the system stop here at the latest. -const MAX_WCHARS: usize = 32 * 1024; - -fn last_error(what: &str) -> PrintError { - PrintError::System(format!("{what} failed: {}", std::io::Error::last_os_error())) -} - -fn wide(s: &str) -> Result> { - if s.contains('\0') { - return Err(PrintError::Invalid("a name contains a NUL character".into())); - } - Ok(s.encode_utf16().chain(Some(0)).collect()) -} - -/// A zeroed, 8-byte aligned buffer of at least `bytes` bytes. -fn aligned(bytes: usize) -> Vec { - vec![0u64; bytes.div_ceil(8).max(1)] -} - -fn bytes_of(buf: &[u64], len: usize) -> Vec { - buf.iter().flat_map(|w| w.to_le_bytes()).take(len).collect() -} - -/// Reads a NUL-terminated wide string. -/// -/// # Safety -/// `p` is null or points to a NUL-terminated UTF-16 string that stays alive for the call. -unsafe fn read_wide(p: *const u16) -> String { - if p.is_null() { - return String::new(); - } - let mut n = 0; - // SAFETY: the caller guarantees a NUL-terminated string; we stop at the NUL (or the cap). - while n < MAX_WCHARS && unsafe { *p.add(n) } != 0 { - n += 1; - } - // SAFETY: the `n` units before the NUL were just read and are valid. - String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, n) }) -} - -pub fn printers() -> Result> { - let flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS; - let (mut needed, mut count) = (0u32, 0u32); - // SAFETY: a size query: no buffer (null, 0 bytes); the out-params are valid locals. - unsafe { EnumPrintersW(flags, null(), 4, null_mut(), 0, &mut needed, &mut count) }; - if needed == 0 { - return Ok(Vec::new()); - } - let mut buf = aligned(needed as usize); - // SAFETY: `buf` holds at least `needed` writable bytes, aligned for PRINTER_INFO_4W. - let ok = unsafe { EnumPrintersW(flags, null(), 4, buf.as_mut_ptr().cast(), needed, &mut needed, &mut count) }; - if ok == 0 { - return Err(last_error("listing printers")); - } - let default = default_printer().ok().flatten(); - let infos = buf.as_ptr().cast::(); - let fits = (needed as usize) / std::mem::size_of::(); - let mut out = Vec::new(); - for i in 0..(count as usize).min(fits) { - // SAFETY: the system wrote `count` PRINTER_INFO_4W at the start of `buf` (bounded by - // the buffer size above); their name pointers point into `buf`, which is alive. - let name = unsafe { read_wide((*infos.add(i)).pPrinterName) }; - if !name.is_empty() { - out.push(Printer { default: default.as_deref() == Some(name.as_str()), name }); - } - } - Ok(out) -} - -pub fn default_printer() -> Result> { - let mut len = 0u32; - // SAFETY: a size query with a null buffer; `len` is a valid local. - unsafe { GetDefaultPrinterW(null_mut(), &mut len) }; - if len == 0 || len as usize > MAX_WCHARS { - return Ok(None); - } - let mut buf = vec![0u16; len as usize]; - // SAFETY: `buf` holds `len` writable wide chars. - if unsafe { GetDefaultPrinterW(buf.as_mut_ptr(), &mut len) } == 0 { - return Ok(None); - } - // SAFETY: on success the buffer holds a NUL-terminated name. - Ok(Some(unsafe { read_wide(buf.as_ptr()) })) -} - -/// An open printer handle, closed on drop. -struct OpenPrinter(PRINTER_HANDLE); - -impl OpenPrinter { - fn open(name: &[u16]) -> Result { - let mut h = PRINTER_HANDLE { Value: null_mut() }; - // SAFETY: `name` is NUL-terminated; `h` is a valid out-param; no defaults. - if unsafe { OpenPrinterW(name.as_ptr(), &mut h, null()) } == 0 || h.Value.is_null() { - return Err(last_error("opening the printer")); - } - Ok(OpenPrinter(h)) - } -} - -impl Drop for OpenPrinter { - fn drop(&mut self) { - // SAFETY: the handle came from OpenPrinterW and is closed once. - unsafe { ClosePrinter(self.0) }; - } -} - -/// `settings` copied into an aligned buffer the driver can read. -/// -/// There is deliberately no check of the printer name stored in the settings: Windows keeps only -/// 31 characters of it, and drivers fill it differently, so a long queue name ("Sublimation - -/// EPSON ET-18100 Series") failed such a check and the settings were silently dropped. The driver -/// itself validates them instead (see [`Dc::create`]). -fn aligned_copy(settings: &DevMode) -> Vec { - let b = settings.as_bytes(); - let mut buf = aligned(b.len()); - for (slot, chunk) in buf.iter_mut().zip(b.chunks(8)) { - let mut w = [0u8; 8]; - w[..chunk.len()].copy_from_slice(chunk); - *slot = u64::from_le_bytes(w); - } - buf -} - -pub fn properties(printer: &str, current: Option<&DevMode>, parent: Option, prompt: bool) -> Result> { - let name = wide(printer)?; - let p = OpenPrinter::open(&name)?; - let hwnd: HWND = parent.map_or(null_mut(), |h| h as HWND); - // SAFETY: with fmode 0 DocumentPropertiesW only returns the size the settings need. - let needed = unsafe { DocumentPropertiesW(hwnd, p.0, name.as_ptr(), null_mut(), null(), 0) }; - if needed <= 0 || needed as usize > (1 << 20) { - return Err(last_error("reading the printer's settings")); - } - let mut out = aligned(needed as usize); - let input = current.map(aligned_copy); - let mut mode = DM_OUT_BUFFER; - if input.is_some() { - mode |= DM_IN_BUFFER; - } - if prompt { - mode |= DM_IN_PROMPT; - } - let inp: *const DEVMODEW = input.as_ref().map_or(null(), |b| b.as_ptr().cast()); - // SAFETY: `out` holds `needed` aligned writable bytes as the size query asked for; `inp` is - // null or an aligned copy of settings written by this printer's driver. - let r = unsafe { DocumentPropertiesW(hwnd, p.0, name.as_ptr(), out.as_mut_ptr().cast(), inp, mode) }; - match r { - IDOK => { - let all = bytes_of(&out, needed as usize); - let word = |at: usize| all.get(at..at + 2).map_or(0, |w| usize::from(u16::from_le_bytes([w[0], w[1]]))); - let len = (word(68) + word(70)).min(all.len()); - DevMode::from_bytes(all.into_iter().take(len).collect()).map(Some) - } - IDCANCEL => Ok(None), - _ => Err(last_error("the printer's settings dialog")), - } -} - -/// A printer device context, deleted on drop. -struct Dc(HDC); - -impl Dc { - fn create(printer: &str, settings: Option<&DevMode>, manual_copies: bool) -> Result { - let name = wide(printer)?; - let normalized = if manual_copies { - let defaults = match settings { - Some(s) => s.clone(), - None => properties(printer, None, None, false)?.ok_or_else(|| PrintError::Invalid("the printer refused its default settings".into()))?, - }; - Some(defaults.for_manual_copies()?) - } else { - None - }; - let settings = normalized.as_ref().or(settings); - // The driver validates and completes the settings first (DM_IN_BUFFER | DM_OUT_BUFFER, no - // dialog). If it refuses them, that is an error: never print on the defaults instead. - let merged = match settings { - Some(s) => Some( - properties(printer, Some(s), None, false)? - .ok_or_else(|| PrintError::Invalid(format!("printer “{printer}” refused these settings; choose Print Settings… again")))?, - ), - None => None, - }; - if manual_copies && !merged.as_ref().is_some_and(DevMode::has_manual_copies) { - return Err(PrintError::Invalid("the driver refused single-copy settings; no print job was sent".into())); - } - let dm = merged.as_ref().map(aligned_copy); - let dmp: *const DEVMODEW = dm.as_ref().map_or(null(), |b| b.as_ptr().cast()); - // SAFETY: `name` is NUL-terminated; `dmp` is null or aligned settings from this - // printer's driver, alive for the call. - let hdc = unsafe { CreateDCW(null(), name.as_ptr(), null(), dmp) }; - if hdc.is_null() { - return Err(last_error(&format!("opening printer “{printer}”"))); - } - Ok(Dc(hdc)) - } - - fn cap(&self, index: u32) -> i32 { - // SAFETY: a valid printer DC and a documented capability index. - unsafe { GetDeviceCaps(self.0, index as i32) } - } - - fn page(&self) -> Result { - let (dx, dy) = (self.cap(LOGPIXELSX), self.cap(LOGPIXELSY)); - if dx <= 0 || dy <= 0 { - return Err(PrintError::System("the printer reported no resolution".into())); - } - let (fx, fy) = (72.0 / f64::from(dx), 72.0 / f64::from(dy)); - let (pw, ph) = (self.cap(PHYSICALWIDTH), self.cap(PHYSICALHEIGHT)); - let (ox, oy) = (self.cap(PHYSICALOFFSETX), self.cap(PHYSICALOFFSETY)); - let (hw, vh) = (self.cap(HORZRES), self.cap(VERTRES)); - let (pw, ph) = if pw > 0 && ph > 0 { (pw, ph) } else { (hw, vh) }; - Ok(Page { - paper: (f64::from(pw) * fx, f64::from(ph) * fy), - printable: (f64::from(ox) * fx, f64::from(oy) * fy, f64::from(hw) * fx, f64::from(vh) * fy), - dpi: (dx as u32, dy as u32), - }) - } -} - -impl Drop for Dc { - fn drop(&mut self) { - // SAFETY: the DC came from CreateDCW and is deleted once. - unsafe { DeleteDC(self.0) }; - } -} - -pub fn page(printer: &str, settings: Option<&DevMode>) -> Result { - Dc::create(printer, settings, false)?.page() -} - -/// RGB rows top-down → a bottom-up 24-bit DIB (BGR, rows padded to 4 bytes). -fn dib(image: &RgbImage<'_>) -> Result<(BITMAPINFO, Vec)> { - let (w, h) = (image.width as usize, image.height as usize); - let stride = (w.checked_mul(3).ok_or_else(|| PrintError::Invalid("image too large".into()))? + 3) & !3; - let total = stride.checked_mul(h).filter(|t| *t <= i32::MAX as usize).ok_or_else(|| PrintError::Invalid("image too large to print in one piece".into()))?; - let mut bits = vec![0u8; total]; - for (y, src) in image.data.chunks_exact(w * 3).enumerate() { - let start = (h - 1 - y) * stride; - let Some(dst) = bits.get_mut(start..start + w * 3) else { continue }; - for (d, s) in dst.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0) { - *d = [s[2], s[1], s[0]]; - } - } - let header = BITMAPINFOHEADER { - biSize: std::mem::size_of::() as u32, - biWidth: image.width as i32, - biHeight: image.height as i32, - biPlanes: 1, - biBitCount: 24, - biCompression: BI_RGB, - biSizeImage: total as u32, - ..Default::default() - }; - Ok((BITMAPINFO { bmiHeader: header, bmiColors: [Default::default()] }, bits)) -} - -/// Vector marks around the image box `(x, y, w, h)` (device pixels, origin top-left, as GDI -/// draws). Same geometry as `photocraft_engine::print_cmds::marks_ops`'s PDF content stream, with -/// the vertical sense flipped (PDF's origin is bottom-left; GDI's is top-left), scaled from points -/// to device pixels with `dpi` instead of PDF's user-space points. -/// -/// # Safety -/// `dc` is a valid, selected device context for the current page. -unsafe fn draw_marks(dc: HDC, marks: Marks, (x, y, w, h): (i32, i32, i32, i32), dpi: (u32, u32)) -> Result<()> { - let (sx, sy) = (f64::from(dpi.0) / 72.0, f64::from(dpi.1) / 72.0); - let px = |v: f64| v.round() as i32; - let (gap_x, gap_y) = (px(6.0 * sx), px(6.0 * sy)); - let (len_x, len_y) = (px(18.0 * sx), px(18.0 * sy)); - let pen_width = px(0.3 * sx).max(1); - // SAFETY: `dc` is valid; the pen and brush are deleted/restored before returning. - let pen = unsafe { CreatePen(PS_SOLID, pen_width, 0x0000_0000) }; - let hollow = unsafe { GetStockObject(HOLLOW_BRUSH) }; - let old_pen = unsafe { SelectObject(dc, pen) }; - let old_brush = unsafe { SelectObject(dc, hollow) }; - let ok = std::cell::Cell::new(true); - let line = |x0: i32, y0: i32, x1: i32, y1: i32| { - // SAFETY: `dc` is valid for the duration of this call. - ok.set(ok.get() && unsafe { MoveToEx(dc, x0, y0, null_mut()) != 0 && LineTo(dc, x1, y1) != 0 }); - }; - if marks.corner_crop { - for (cx, cy, dx, dy) in [(x, y, -1, -1), (x + w, y, 1, -1), (x, y + h, -1, 1), (x + w, y + h, 1, 1)] { - line(cx + dx * gap_x, cy, cx + dx * (gap_x + len_x), cy); - line(cx, cy + dy * gap_y, cx, cy + dy * (gap_y + len_y)); - } - } - if marks.center_crop { - let (mx, my) = (x + w / 2, y + h / 2); - line(mx, y - gap_y, mx, y - gap_y - len_y); - line(mx, y + h + gap_y, mx, y + h + gap_y + len_y); - line(x - gap_x, my, x - gap_x - len_x, my); - line(x + w + gap_x, my, x + w + gap_x + len_x, my); - } - if marks.registration { - let (rx, ry) = (px(5.0 * sx), px(5.0 * sy)); - let (off_x, off_y) = (gap_x + len_x / 2, gap_y + len_y / 2); - for (cx, cy) in [(x + w / 2, y - off_y), (x + w / 2, y + h + off_y), (x - off_x, y + h / 2), (x + w + off_x, y + h / 2)] { - // SAFETY: `dc` is valid for the duration of this call. - ok.set(ok.get() && unsafe { Ellipse(dc, cx - rx, cy - ry, cx + rx, cy + ry) != 0 }); - line(cx - (rx as f64 * 1.6) as i32, cy, cx + (rx as f64 * 1.6) as i32, cy); - line(cx, cy - (ry as f64 * 1.6) as i32, cx, cy + (ry as f64 * 1.6) as i32); - } - } - // SAFETY: restores the DC's previous pen/brush, then frees the ones created above. - unsafe { - SelectObject(dc, old_pen); - SelectObject(dc, old_brush); - DeleteObject(pen); - } - if ok.get() { Ok(()) } else { Err(last_error("drawing printer marks")) } -} - -pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result { - if image.width > i32::MAX as u32 || image.height > i32::MAX as u32 { - return Err(PrintError::Invalid("image too large".into())); - } - let dc = Dc::create(job.printer, job.settings, true)?; - let page = dc.page()?; - let (x, y, w, h) = crate::device_rect(&page, rect); - let (bmi, bits) = dib(image)?; - let title = wide(job.title)?; - let output = job.output_file.map(wide).transpose()?; - let info = DOCINFOW { - cbSize: std::mem::size_of::() as i32, - lpszDocName: title.as_ptr(), - lpszOutput: output.as_ref().map_or(null(), |o| o.as_ptr()), - lpszDatatype: null(), - fwType: 0, - }; - let color_mode = match job.color_policy { - ColorPolicy::DriverManaged => ICM_OFF, - // ICM_OFF enables old-style halftone correction. Pixels converted by the caller - // need both DC conversion and that correction off (Microsoft SetICMMode contract). - ColorPolicy::ApplicationManaged | ColorPolicy::Unmanaged => ICM_DONE_OUTSIDEDC, - }; - let set_color_mode = || { - // SAFETY: a valid printer DC and a documented SetICMMode value. - if unsafe { SetICMMode(dc.0, color_mode) } == 0 { Err(last_error("setting GDI color policy")) } else { Ok(()) } - }; - set_color_mode()?; - // SAFETY: a valid DC; HALFTONE resampling needs the brush origin reset afterwards. - unsafe { - SetStretchBltMode(dc.0, HALFTONE); - SetBrushOrgEx(dc.0, 0, 0, null_mut()); - } - // SAFETY: `info` and the strings it points to live until EndDoc/AbortDoc below. - if unsafe { StartDocW(dc.0, &info) } <= 0 { - return Err(last_error("starting the print job")); - } - let fail = |what: &str| { - let e = last_error(what); - // SAFETY: the job was started on this DC. - unsafe { AbortDoc(dc.0) }; - e - }; - for _ in 0..job.copies.clamp(1, 999) { - // SAFETY: inside a started document on a valid DC. - if unsafe { StartPage(dc.0) } <= 0 { - return Err(fail("starting a page")); - } - // StartPage saves/restores DC state. Reapply the policy for each page, before pixels. - if let Err(error) = set_color_mode() { - // SAFETY: this DC owns the document started above. - unsafe { AbortDoc(dc.0) }; - return Err(error); - } - // SAFETY: `bits` is a bottom-up DIB exactly as `bmi` describes (24-bit, padded rows, - // `biSizeImage` bytes); the source rectangle is the whole image. - let lines = - unsafe { StretchDIBits(dc.0, x, y, w, h, 0, 0, image.width as i32, image.height as i32, bits.as_ptr().cast(), &bmi, DIB_RGB_COLORS, SRCCOPY) }; - if lines <= 0 { - return Err(fail("sending the image to the printer")); - } - if job.marks.any() { - // SAFETY: `dc.0` is the valid, started page's device context. - if let Err(e) = unsafe { draw_marks(dc.0, job.marks, (x, y, w, h), page.dpi) } { - return Err(fail(&e.to_string())); - } - } - // SAFETY: closes the page started above. - if unsafe { EndPage(dc.0) } <= 0 { - return Err(fail("finishing a page")); - } - } - // SAFETY: closes the document started above. - if unsafe { EndDoc(dc.0) } <= 0 { - return Err(last_error("finishing the print job")); - } - Ok(Printed { device_rect: (x, y, w, h), dpi: page.dpi }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dib_is_bottom_up_bgr_with_padded_rows() { - // 2×2: red, green / blue, white. - let data = [255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]; - let (bmi, bits) = dib(&RgbImage { width: 2, height: 2, data: &data }).unwrap(); - assert_eq!(bmi.bmiHeader.biHeight, 2); - assert_eq!(bits.len(), 16, "6 bytes per row padded to 8"); - // First stored row is the bottom one: blue, white. - assert_eq!(&bits[0..6], &[255, 0, 0, 255, 255, 255]); - assert_eq!(&bits[8..14], &[0, 0, 255, 0, 255, 0]); - } - - #[test] - fn settings_are_copied_aligned_and_intact() { - let mut b: Vec = (0..230u32).map(|i| (i * 7) as u8).collect(); - b[68..70].copy_from_slice(&220u16.to_le_bytes()); - b[70..72].copy_from_slice(&10u16.to_le_bytes()); - let d = DevMode::from_bytes(b.clone()).unwrap(); - let buf = aligned_copy(&d); - assert_eq!(buf.as_ptr().align_offset(std::mem::align_of::()), 0); - assert_eq!(bytes_of(&buf, b.len()), b, "no name check, no truncation: the bytes go through as chosen"); - } - - /// Settings chosen for a printer are what the driver prints with: paper and resolution survive - /// the driver's own validation (needs "Microsoft Print to PDF"; run by hand). - #[test] - #[ignore] - fn chosen_settings_reach_the_device_context() { - let printer = "Microsoft Print to PDF"; - let d = crate::default_settings(printer).unwrap(); - let mut b = d.as_bytes().to_vec(); - // dmPaperSize = DMPAPER_A3 (8); dmFields |= DM_PAPERSIZE (2). - b[78..80].copy_from_slice(&8i16.to_le_bytes()); - let fields = u32::from_le_bytes([b[72], b[73], b[74], b[75]]) | 2; - b[72..76].copy_from_slice(&fields.to_le_bytes()); - let a3 = DevMode::from_bytes(b).unwrap(); - let pg = crate::page(printer, Some(&a3)).unwrap(); - assert!((pg.paper.0 - 841.9).abs() < 2.0 && (pg.paper.1 - 1190.6).abs() < 2.0, "A3, not the default: {:?}", pg.paper); - // Landscape (dmOrientation = 2, DM_ORIENTATION = 1): the page comes back wide. - let mut b = a3.as_bytes().to_vec(); - b[76..78].copy_from_slice(&2i16.to_le_bytes()); - let fields = u32::from_le_bytes([b[72], b[73], b[74], b[75]]) | 1; - b[72..76].copy_from_slice(&fields.to_le_bytes()); - let pg = crate::page(printer, Some(&DevMode::from_bytes(b).unwrap())).unwrap(); - assert!((pg.paper.0 - 1190.6).abs() < 2.0 && (pg.paper.1 - 841.9).abs() < 2.0, "landscape A3: {:?}", pg.paper); - } - - /// A real job through Windows' "Microsoft Print to PDF" driver, written to a file (needs that - /// printer installed; run by hand). - #[test] - #[ignore] - fn prints_through_a_real_driver_to_a_file() { - let out = std::env::temp_dir().join(format!("pc-winprint-{}.pdf", std::process::id())); - let _ = std::fs::remove_file(&out); - let settings = crate::default_settings("Microsoft Print to PDF").unwrap(); - let mut bytes = settings.as_bytes().to_vec(); - bytes[86..88].copy_from_slice(&5i16.to_le_bytes()); - let fields = u32::from_le_bytes(bytes[72..76].try_into().unwrap()) | 0x100; - bytes[72..76].copy_from_slice(&fields.to_le_bytes()); - let settings = DevMode::from_bytes(bytes).unwrap(); - let (w, h) = (300u32, 200u32); - let data: Vec = (0..w * h).flat_map(|i| if (i % w) < w / 2 { [220, 30, 30] } else { [30, 30, 220] }).collect(); - let out_s = out.to_string_lossy().into_owned(); - let marks = crate::Marks { corner_crop: true, center_crop: true, registration: true }; - let job = Job { - printer: "Microsoft Print to PDF", - settings: Some(&settings), - title: "PhotoCraft test", - output_file: Some(&out_s), - copies: 2, - color_policy: ColorPolicy::ApplicationManaged, - marks, - }; - let done = crate::print(&job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap(); - assert_eq!(done.device_rect.2, done.dpi.0 as i32 * 3, "3 inches wide"); - let pdf = std::fs::read(&out).unwrap(); - assert!(pdf.starts_with(b"%PDF"), "the driver wrote a PDF"); - assert!(pdf.len() > 1000, "the page has content"); - // The driver vectorises our GDI line/ellipse calls as real PDF content: a job with every - // mark on is meaningfully bigger than the same image with marks off. - let no_marks_job = Job { marks: crate::Marks::default(), ..job }; - let done2 = crate::print(&no_marks_job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap(); - let _ = done2; - let pdf_no_marks = std::fs::read(&out).unwrap(); - assert!(pdf.len() > pdf_no_marks.len() + 100, "marks add vector content: {} vs {}", pdf.len(), pdf_no_marks.len()); - if let Ok(evidence) = std::env::var("PHOTOCRAFT_WINPRINT_TEST_EVIDENCE") { - let evidence = std::path::Path::new(&evidence); - std::fs::create_dir_all(evidence).unwrap(); - std::fs::write(evidence.join("copies2-driver5-marks.pdf"), &pdf).unwrap(); - std::fs::write(evidence.join("copies2-driver5-no-marks.pdf"), &pdf_no_marks).unwrap(); - } - let _ = std::fs::remove_file(&out); - } - - /// Lists this machine's printers and their pages (needs real printers; run by hand). - #[test] - #[ignore] - fn lists_real_printers() { - for p in printers().unwrap() { - println!("{}{} {:?}", p.name, if p.default { " (default)" } else { "" }, page(&p.name, None)); - } - } -} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index dcb66f7120..e78613f37a 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -47,7 +47,6 @@ pub const TABLE: &[(&str, Class)] = &[ // Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS). ("tablet", Class::Standalone), // Windows printing (an isolated `unsafe` helper: winspool and GDI). - ("winprint", Class::Standalone), ("doc", Class::Layer(1)), ("ops", Class::Layer(2)), ("paint", Class::Layer(2)), From 4de93daa912cdfe803984b0686c989a5b0490bad Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 04:25:32 +0200 Subject: [PATCH 008/167] Pin pre-submission annotation visibility guard --- Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ba37f36a7..9b052103d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ "maybe-owned", "rustix 1.1.5", "rustix-linux-procfs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", "winx", ] @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ee70afc29a41bc50f2a55affa9aa2c62e0d28e3a#ee70afc29a41bc50f2a55affa9aa2c62e0d28e3a" +source = "git+https://github.com/storytold/craft-libs?rev=6ba4d0b97ad09767c588d82ff9c988eea53f0546#6ba4d0b97ad09767c588d82ff9c988eea53f0546" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1891,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3028,7 +3028,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3690,7 +3690,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4436,7 +4436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5454,7 +5454,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5973,7 +5973,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6040,7 +6040,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6678,7 +6678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6905,10 +6905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7308,7 +7308,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8068,7 +8068,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 54bd1a3a05..9d289b3438 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "ee70afc29a41bc50f2a55affa9aa2c62e0d28e3a" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6ba4d0b97ad09767c588d82ff9c988eea53f0546" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" From 70059ac1b43e4679c462f77efc1d0a2ef63de0ae Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 04:56:32 +0200 Subject: [PATCH 009/167] Pin driver option retention qualification fixes --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b052103d1..5c5b43b827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6ba4d0b97ad09767c588d82ff9c988eea53f0546#6ba4d0b97ad09767c588d82ff9c988eea53f0546" +source = "git+https://github.com/storytold/craft-libs?rev=d873514e8a773f02f54c34ba890e57c02c3a36f6#d873514e8a773f02f54c34ba890e57c02c3a36f6" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", diff --git a/Cargo.toml b/Cargo.toml index 9d289b3438..cd5017ba68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6ba4d0b97ad09767c588d82ff9c988eea53f0546" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d873514e8a773f02f54c34ba890e57c02c3a36f6" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" From d6fbac1720941a477333c20c70948efd9365fd04 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 05:04:13 +0200 Subject: [PATCH 010/167] Pin qualification fixes integrated with current print progress --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c5b43b827..362a874cbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d873514e8a773f02f54c34ba890e57c02c3a36f6#d873514e8a773f02f54c34ba890e57c02c3a36f6" +source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b76109da11416e9ca4ea719#3d37da389a3618ab9b76109da11416e9ca4ea719" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", diff --git a/Cargo.toml b/Cargo.toml index cd5017ba68..2c329d980d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d873514e8a773f02f54c34ba890e57c02c3a36f6" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" From 8f56cd5be5a8b3a384da888b6c769b0b8353dfc4 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 05:54:57 +0200 Subject: [PATCH 011/167] Preserve explicit PDF export before disabled submission refusal --- crates/engine/src/print_cmds.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 0235d3af11..9361f7d2b4 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -390,17 +390,12 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let send = p.get("send").and_then(Value::as_bool).unwrap_or(output.is_none()); 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); - // Apply the process-wide test/fuzz guard before either native transport can reach a driver. - if send && !dry && printing_disabled() { - return Err(other( - "printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true", - )); - } // Shared RGB transport handles compatible Windows pages; PDF-only content retains its route. // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages"); if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { + require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, @@ -431,6 +426,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let mut sent = false; let mut spool = Value::Null; if send && !dry { + require_printing_enabled()?; spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } @@ -569,6 +565,14 @@ 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 require_printing_enabled() -> Result<()> { + if printing_disabled() { + Err(other("printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true")) + } else { + Ok(()) + } +} + fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } From 3454620f8933241ba3575a26c48c6e9a54f0e75f Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 06:04:03 +0200 Subject: [PATCH 012/167] Format shared print-disable error --- crates/engine/src/print_cmds.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9361f7d2b4..87862b65f6 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -567,7 +567,9 @@ pub fn printing_disabled() -> bool { fn require_printing_enabled() -> Result<()> { if printing_disabled() { - Err(other("printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true")) + Err(other( + "printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true", + )) } else { Ok(()) } From 06387a955c960b3ced7c4267b35e39814c9ae411 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 06:36:33 +0200 Subject: [PATCH 013/167] Apply CI formatter output for print guard --- crates/engine/src/print_cmds.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 87862b65f6..9361f7d2b4 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -567,9 +567,7 @@ pub fn printing_disabled() -> bool { fn require_printing_enabled() -> Result<()> { if printing_disabled() { - Err(other( - "printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true", - )) + Err(other("printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true")) } else { Ok(()) } From f85eddf3cc8f1d1934822c4343f42cc2dc8c9075 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 07:20:25 +0200 Subject: [PATCH 014/167] Complete native printing translations and Serbian theme labels --- crates/ui-egui/src/i18n/cs.tsv | 7 +++++++ crates/ui-egui/src/i18n/de.tsv | 7 +++++++ crates/ui-egui/src/i18n/el.tsv | 7 +++++++ crates/ui-egui/src/i18n/es.tsv | 7 +++++++ crates/ui-egui/src/i18n/fr.tsv | 7 +++++++ crates/ui-egui/src/i18n/hu.tsv | 7 +++++++ crates/ui-egui/src/i18n/id.tsv | 7 +++++++ crates/ui-egui/src/i18n/it.tsv | 7 +++++++ crates/ui-egui/src/i18n/ja.tsv | 7 +++++++ crates/ui-egui/src/i18n/ko.tsv | 7 +++++++ crates/ui-egui/src/i18n/nl.tsv | 7 +++++++ crates/ui-egui/src/i18n/pl.tsv | 7 +++++++ crates/ui-egui/src/i18n/pt-br.tsv | 7 +++++++ crates/ui-egui/src/i18n/ru.tsv | 7 +++++++ crates/ui-egui/src/i18n/sr.tsv | 11 +++++++++++ crates/ui-egui/src/i18n/tr.tsv | 7 +++++++ crates/ui-egui/src/i18n/uk.tsv | 7 +++++++ crates/ui-egui/src/i18n/vi.tsv | 7 +++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 +++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 +++++++ 20 files changed, 144 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4fdabb4406..312fca6fba 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3703,3 +3703,10 @@ navigator Zoom Zvětšení 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nezapínejte současně zrcadlení v nastavení tisku: obě převrácení se navzájem zruší. + Print Settings… Nastavení tisku… + Remember to turn off the printer's own colour management in Print Settings… Nezapomeňte v nastavení tisku vypnout vlastní správu barev tiskárny… + Sent to {printer} Odesláno do {printer} + set in Print Settings… nastavuje se v nastavení tisku… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index be910dda32..86dbf56351 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2735,3 +2735,10 @@ curves Smooth Glätten 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Aktiviere nicht zusätzlich das Spiegelbild in den Druckeinstellungen: Die beiden Spiegelungen heben sich auf. + Print Settings… Druckeinstellungen… + Remember to turn off the printer's own colour management in Print Settings… Denke daran, die eigene Farbverwaltung des Druckers in den Druckeinstellungen auszuschalten… + Sent to {printer} An {printer} gesendet + set in Print Settings… in den Druckeinstellungen festlegen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d02c1318aa..94ebb84f0e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3793,3 +3793,10 @@ curves Smooth Εξομάλυνση Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες Auto Add/Delete Αυτόματη προσθήκη/διαγραφή + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Μην ενεργοποιείτε επίσης τον κατοπτρισμό στις ρυθμίσεις εκτύπωσης: οι δύο αναστροφές αλληλοαναιρούνται. + Print Settings… Ρυθμίσεις εκτύπωσης… + Remember to turn off the printer's own colour management in Print Settings… Θυμηθείτε να απενεργοποιήσετε τη διαχείριση χρωμάτων του εκτυπωτή στις ρυθμίσεις εκτύπωσης… + Sent to {printer} Στάλθηκε στον {printer} + set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cdee7d44b9..f3f9893f2a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2776,3 +2776,10 @@ curves Smooth Suavizar 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. No actives también Imagen especular en Ajustes de impresión: las dos inversiones se anulan. + Print Settings… Ajustes de impresión… + Remember to turn off the printer's own colour management in Print Settings… Recuerda desactivar la gestión de color de la impresora en Ajustes de impresión… + Sent to {printer} Enviado a {printer} + set in Print Settings… se establece en Ajustes de impresión… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index daf5447d76..b0a4aedb6c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3096,3 +3096,10 @@ curves Smooth Lisser 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Ne cochez pas aussi Image miroir dans les paramètres d’impression : les deux inversions s’annulent. + Print Settings… Paramètres d’impression… + Remember to turn off the printer's own colour management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression… + Sent to {printer} Envoyé à {printer} + set in Print Settings… défini dans les paramètres d’impression… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ef7905b881..d0da3a93c7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4016,3 +4016,10 @@ cameraRaw Yellow Sárga 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. A nyomtatási beállításokban ne jelöld be a tükrözést is: a két tükrözés kioltja egymást. + Print Settings… Nyomtatási beállítások… + Remember to turn off the printer's own colour management in Print Settings… Ne felejtsd el kikapcsolni a nyomtató saját színkezelését a nyomtatási beállításokban… + Sent to {printer} Elküldve ide: {printer} + set in Print Settings… a nyomtatási beállításokban adható meg… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index db948970fe..10876b8572 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3083,3 +3083,10 @@ curves Smooth Haluskan Step {step} is waiting for its dialog Langkah {step} menunggu dialognya @plural {n} action|{n} actions {n} tindakan Auto Add/Delete Tambah/Hapus Otomatis + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Jangan aktifkan juga Gambar Cermin di Pengaturan Cetak: kedua pembalikan saling meniadakan. + Print Settings… Pengaturan Cetak… + Remember to turn off the printer's own colour management in Print Settings… Ingat untuk menonaktifkan pengelolaan warna milik printer di Pengaturan Cetak… + Sent to {printer} Dikirim ke {printer} + set in Print Settings… diatur di Pengaturan Cetak… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf4c8962cf..3de380dcde 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4059,3 +4059,10 @@ navigator Zoom Zoom 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Non attivare anche Immagine speculare nelle impostazioni di stampa: le due inversioni si annullano. + Print Settings… Impostazioni di stampa… + Remember to turn off the printer's own colour management in Print Settings… Ricorda di disattivare la gestione del colore della stampante nelle impostazioni di stampa… + Sent to {printer} Inviato a {printer} + set in Print Settings… impostato nelle impostazioni di stampa… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 19c36d54ab..7620e9ad57 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3705,3 +3705,10 @@ curves Smooth スムーズ Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 Auto Add/Delete 自動追加・削除 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 印刷設定の「鏡像」も有効にしないでください。2回反転すると元に戻ります。 + Print Settings… 印刷設定… + Remember to turn off the printer's own colour management in Print Settings… 印刷設定でプリンター独自のカラーマネジメントを無効にしてください… + Sent to {printer} {printer} に送信しました + set in Print Settings… 印刷設定で指定… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7857b418b7..fa32cf82d0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3265,3 +3265,10 @@ curves Smooth 매끄럽게 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 Auto Add/Delete 자동 추가/삭제 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 인쇄 설정에서도 이미지 반전을 켜지 마세요. 두 번 반전하면 원래대로 돌아갑니다. + Print Settings… 인쇄 설정… + Remember to turn off the printer's own colour management in Print Settings… 인쇄 설정에서 프린터 자체의 색상 관리를 끄세요… + Sent to {printer} {printer}(으)로 전송됨 + set in Print Settings… 인쇄 설정에서 지정… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a7f7c2ae13..9412f08f4a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3855,3 +3855,10 @@ curves Smooth Vloeiend maken 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Schakel niet ook Spiegelbeeld in bij de afdrukinstellingen: de twee spiegelingen heffen elkaar op. + Print Settings… Afdrukinstellingen… + Remember to turn off the printer's own colour management in Print Settings… Vergeet niet het eigen kleurbeheer van de printer uit te schakelen in de afdrukinstellingen… + Sent to {printer} Verzonden naar {printer} + set in Print Settings… instellen in de afdrukinstellingen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0c82ac3b9b..24749182ea 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3692,3 +3692,10 @@ curves Smooth Wygładź 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nie włączaj też odbicia lustrzanego w ustawieniach druku: oba odbicia wzajemnie się znoszą. + Print Settings… Ustawienia druku… + Remember to turn off the printer's own colour management in Print Settings… Pamiętaj o wyłączeniu zarządzania kolorami drukarki w ustawieniach druku… + Sent to {printer} Wysłano do {printer} + set in Print Settings… ustawiane w ustawieniach druku… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 199fa97345..9fe2601cf6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3885,3 +3885,10 @@ layerLabel No Color Sem cor 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Não ative também a imagem espelhada nas configurações de impressão: as duas inversões se anulam. + Print Settings… Configurações de impressão… + Remember to turn off the printer's own colour management in Print Settings… Lembre-se de desativar o gerenciamento de cores da própria impressora nas configurações de impressão… + Sent to {printer} Enviado para {printer} + set in Print Settings… definido nas configurações de impressão… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dd7001bc1f..00ce3f12d0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3040,3 +3040,10 @@ curves Smooth Сгладить Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций Auto Add/Delete Автодобавление/удаление + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не включайте также зеркальное отражение в параметрах печати: два отражения отменяют друг друга. + Print Settings… Параметры печати… + Remember to turn off the printer's own colour management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати… + Sent to {printer} Отправлено на {printer} + set in Print Settings… задаётся в параметрах печати… diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9d8aebef63..76a8a421a0 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4167,3 +4167,14 @@ shape Appearance Izgled Import .GRD Uvezi .GRD Choose a gradient file (.grd). Izaberite datoteku gradijenta (.grd). Auto Add/Delete Automatsko dodavanje/brisanje + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nemojte uključiti i ogledalsku sliku u podešavanjima štampanja: dva preslikavanja se poništavaju. + Print Settings… Podešavanja štampanja… + Remember to turn off the printer's own colour management in Print Settings… Ne zaboravite da isključite upravljanje bojama samog štampača u podešavanjima štampanja… + Sent to {printer} Poslato na {printer} + set in Print Settings… podešava se u podešavanjima štampanja… + Soft dark Blaga tamna + Soft light Blaga svetla + Soft Dark Theme Blaga tamna tema + Soft Light Theme Blaga svetla tema diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 605ffd26f9..fb7ce8f663 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3828,3 +3828,10 @@ cameraData Lens Lens 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Yazdırma Ayarlarında ayrıca Ayna Görüntüsünü açmayın: iki yansıtma birbirini iptal eder. + Print Settings… Yazdırma Ayarları… + Remember to turn off the printer's own colour management in Print Settings… Yazdırma Ayarlarında yazıcının kendi renk yönetimini kapatmayı unutmayın… + Sent to {printer} {printer} yazıcısına gönderildi + set in Print Settings… Yazdırma Ayarlarında belirlenir… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 712766a793..85418d216f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3793,3 +3793,10 @@ curves Smooth Згладити Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій Auto Add/Delete Автододавання/видалення + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не вмикайте також дзеркальне відображення в параметрах друку: два відображення скасовують одне одного. + Print Settings… Параметри друку… + Remember to turn off the printer's own colour management in Print Settings… Не забудьте вимкнути керування кольором самого принтера в параметрах друку… + Sent to {printer} Надіслано на {printer} + set in Print Settings… задається в параметрах друку… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7ef6f16dd6..eb66747a1a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3580,3 +3580,10 @@ curves Smooth Làm mượt 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 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Đừng bật thêm Ảnh phản chiếu trong thiết lập in: hai lần lật sẽ triệt tiêu nhau. + Print Settings… Thiết lập in… + Remember to turn off the printer's own colour management in Print Settings… Nhớ tắt quản lý màu riêng của máy in trong thiết lập in… + Sent to {printer} Đã gửi tới {printer} + set in Print Settings… đặt trong thiết lập in… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 142718f393..e46fd61904 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2700,3 +2700,10 @@ curves Smooth 平滑 Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 @plural {n} action|{n} actions {n} 个动作 Auto Add/Delete 自动添加/删除 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 不要同时勾选打印设置中的镜像:两次翻转会相互抵消。 + Print Settings… 打印设置… + Remember to turn off the printer's own colour management in Print Settings… 请记得在打印设置中关闭打印机自身的色彩管理… + Sent to {printer} 已发送到 {printer} + set in Print Settings… 在打印设置中设置… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e4da035be..e6e8b921e5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2685,3 +2685,10 @@ curves Smooth 平滑 Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 @plural {n} action|{n} actions {n} 個動作 Auto Add/Delete 自動增加/刪除 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 請勿同時勾選列印設定中的鏡像:兩次翻轉會互相抵消。 + Print Settings… 列印設定… + Remember to turn off the printer's own colour management in Print Settings… 請記得在列印設定中關閉印表機本身的色彩管理… + Sent to {printer} 已傳送至 {printer} + set in Print Settings… 在列印設定中設定… From 3a2d657b9aa05292b9b5fc9ef2e959526070ace9 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 20:43:45 +0200 Subject: [PATCH 015/167] Keep custom paper for Windows PDF exports and repair merged catalogs --- crates/ui-egui/src/file_ui.rs | 19 ++++++++++++++++--- crates/ui-egui/src/i18n/ja.tsv | 7 ------- crates/ui-egui/src/i18n/sr.tsv | 9 +++++---- crates/ui-egui/tests/custom_print_paper.rs | 2 ++ 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index d86f9cf12d..ba7efccaf6 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -726,7 +726,6 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { f.insert("printer".into(), json!(fallback)); f.remove("printerSettings"); } - driver_page(&mut f); } app.ui.open_dialog(DialogKind::Command, f) } @@ -828,6 +827,22 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // PDF exports and previews retain editable paper on Windows too. Only a real native + // submission takes its media from the printer; entering that mode refreshes the driver + // geometry after any export-only paper edits. + let printers: Option> = if !b(f, "dryRun", false) && s(f, "output", "").is_empty() { + f.get("__printers").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + } else { + None + }; + if printers.is_some() { + if !b(f, "__driverPageInitialized", false) { + driver_page(f); + f.insert("__driverPageInitialized".into(), json!(true)); + } + } else { + f.remove("__driverPageInitialized"); + } let p = params(f); ui.horizontal_top(|ui| { // Page preview. @@ -904,8 +919,6 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map> = - f.get("__printers").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); if let Some(printers) = &printers { // Windows: Photoshop's Printer menu and Print Settings… (the driver's own dialog). ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d382744c51..905c62874e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3796,13 +3796,6 @@ curves Smooth スムーズ Sent to {printer} {printer} に送信しました set in Print Settings… 印刷設定で指定… # Restored rows (custom print paper #3820, brush presets, Vanishing Point help, brush panel menu) - Paper Width 用紙の幅 - Paper Height 用紙の高さ - Capture Brush Size in Preset ブラシサイズをプリセットに保存 - Include Tool Settings ツール設定を含める - Include Color カラーを含める - Enter a name for the brush preset ブラシプリセットの名前を入力してください - A brush preset with this name already exists この名前のブラシプリセットは既に存在します Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 Clear Brush Controls ブラシのコントロールを消去 Reset All Locked Settings ロックされた設定をすべて初期化 diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 3d01325ef8..66fd394b20 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4192,10 +4192,11 @@ shape Appearance Izgled Remember to turn off the printer's own colour management in Print Settings… Ne zaboravite da isključite upravljanje bojama samog štampača u podešavanjima štampanja… Sent to {printer} Poslato na {printer} set in Print Settings… podešava se u podešavanjima štampanja… - Soft dark Blaga tamna - Soft light Blaga svetla - Soft Dark Theme Blaga tamna tema - Soft Light Theme Blaga svetla tema # Custom print paper (#3820) Paper Width Širina papira Paper Height Visina papira + +# Layer mask selection commands + Add Mask to Selection Dodaj masku izboru + Subtract Mask from Selection Oduzmi masku od izbora + Intersect Mask with Selection Presek maske i izbora diff --git a/crates/ui-egui/tests/custom_print_paper.rs b/crates/ui-egui/tests/custom_print_paper.rs index 7361468bbc..d0d81dea69 100644 --- a/crates/ui-egui/tests/custom_print_paper.rs +++ b/crates/ui-egui/tests/custom_print_paper.rs @@ -10,6 +10,8 @@ fn app() -> (PhotocraftApp, u64) { let id = open_print(&mut app); // Every UI path in these fixtures is safe even if an unexpected Enter confirms the dialog. app.ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true)); + // Exercise PDF paper controls even when native printers are available, on every OS. + app.ui.dialog_mut(id).unwrap().fields.insert("__printers".into(), json!(["Test native printer"])); (app, id) } From 6c1a3590f5116ad70de1a60b2cb4cdbf290969ed Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 21:33:44 +0200 Subject: [PATCH 016/167] Remove obsolete app-owned Windows printing layer comment --- xtask/src/layers.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index e78613f37a..2128345148 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -46,7 +46,6 @@ pub const TABLE: &[(&str, Class)] = &[ ("adobe-assets", Class::Standalone), // Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS). ("tablet", Class::Standalone), - // Windows printing (an isolated `unsafe` helper: winspool and GDI). ("doc", Class::Layer(1)), ("ops", Class::Layer(2)), ("paint", Class::Layer(2)), From a9ac922fc12a07cf5bf228644130693db45bcbd1 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Wed, 14 Oct 2026 02:21:09 +0200 Subject: [PATCH 017/167] Align printer menu regression with the merged system-printer cache --- crates/ui-egui/src/file_ui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 9809f33c96..c3b8abc4d7 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -1445,7 +1445,8 @@ mod tests { let (mut app, ctx) = app(); let id = crate::menus::invoke(&mut app, &ctx, "file.print", json!({})).unwrap()["dialog"].as_u64().unwrap(); let f = &app.ui.dialog_mut(id).unwrap().fields; - assert!(f.get("__printers").is_some_and(Value::is_array)); + assert!(f.get("__systemPrinters").is_some_and(Value::is_array)); + assert!(params(f).get("__systemPrinters").is_none()); assert!(params(f).get("__printers").is_none()); } From 583425b4df1c57a4515f591fed64fcedf8440cba Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 13 Oct 2026 18:09:37 -0700 Subject: [PATCH 018/167] Stroke latency trace: PHOTOCRAFT_STROKE_TRACE times each frame of a brush stroke (#1343) (#4403) Testers report ~50 ms input-to-pixel while painting on Linux and macOS, and asked for a per-stage breakdown. With PHOTOCRAFT_STROKE_TRACE=1 the app prints, per stroke, p50/p95 of the frame interval, pointer moves per frame, the live stroke's render, the canvas refresh (composite + upload), the UI build, eframe's CPU time (UI, tessellation, submission) and the remaining vsync/swap-chain wait, with the GPU backend; = also appends every frame. The last summary is in the control channel's perf.strokeTrace. RTX 5090, Vulkan, 120 Hz: interval 8.33, cpu 2.2 (ui 0.9, stroke 0.8, canvas 0.4), wait 6.0 ms. --- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/gpu_canvas.rs | 4 + crates/ui-egui/src/lib.rs | 20 +- crates/ui-egui/src/stroke_timing_tests.rs | 42 +++ crates/ui-egui/src/stroke_trace.rs | 297 ++++++++++++++++++++++ docs/development.md | 21 ++ 7 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/stroke_trace.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db0efe31fa..676d068070 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -697,10 +697,12 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { return; } l.fed = d.points.len(); + let started = crate::gpu_canvas::now_ms(); match l.stroke.push(&pts) { Ok(r) => l.damage.push(r), Err(_) => app.live_stroke = None, } + app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); } /// The points the live retouching stroke has rendered. @@ -5460,7 +5462,9 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { { app.session.clear_live_stroke(); } + let started = crate::gpu_canvas::now_ms(); let result = app.run(stroke_command(app, d.tool), p); + app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); app.session.clear_live_stroke(); let Ok(r) = result else { return }; let revision = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.revision); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index db908d0943..209691b875 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1315,7 +1315,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "frame": app.frame, "session": photocraft_engine::inspect::session(&app.session), "document": app.session.active().map(photocraft_engine::inspect::document), - "perf": {"fps": app.fps, "timings": app.perf}, + "perf": {"fps": app.fps, "timings": app.perf, "strokeTrace": app.stroke_trace.last}, "brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity}, "distort": app.distort.describe(), "jobs": crate::jobs_ui::inspect(app), diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 046d4ac2a7..3862aec534 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -898,6 +898,9 @@ pub struct Perf { pub spans: std::collections::BTreeMap<&'static str, f64>, #[serde(skip)] max_decay: f64, + /// Composite and upload time of this frame's canvas refreshes, in ms (the stroke trace). + #[serde(skip)] + pub(crate) frame_refresh_ms: f64, } /// What the app renders with and why (Help › System Info, Preferences › Performance, the `perf` @@ -1012,6 +1015,7 @@ pub fn backend_name(b: wgpu::Backend) -> &'static str { impl Perf { pub fn record(&mut self, kind: &'static str, px: u64, composite_ms: f64, upload_ms: f64) { + self.frame_refresh_ms += composite_ms + upload_ms; self.last_refresh = kind; self.last_refresh_px = px; self.composite_ms = composite_ms; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2cd006087d..38118955c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -190,6 +190,7 @@ pub mod spring_tools; pub mod stack_dialog; pub mod state; pub mod stroke_constraint; +pub mod stroke_trace; pub mod stroke_trail; pub mod stroke_ui; pub mod stylus; @@ -730,6 +731,8 @@ pub struct PhotocraftApp { started: Option, /// Frame and canvas-upload timings (exposed via `ui.inspect`). pub perf: gpu_canvas::Perf, + /// Per-frame stroke timings for latency reports (`PHOTOCRAFT_STROKE_TRACE`, see `stroke_trace`). + pub stroke_trace: stroke_trace::StrokeTrace, /// Preferences, autosave and snapping runtime state (see `prefs_ui`, `snap_ui`). pub(crate) prefs_rt: prefs_ui::Runtime, /// Close, Revert or Exit parked behind the unsaved-changes prompt (see `discard_ui`). @@ -875,6 +878,7 @@ impl PhotocraftApp { gpu: None, started: None, perf: Default::default(), + stroke_trace: stroke_trace::StrokeTrace::from_env(), prefs_rt: Default::default(), discard: None, file_dialog: None, @@ -1756,6 +1760,7 @@ impl eframe::App for PhotocraftApp { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + let frame_start = gpu_canvas::now_ms(); // An undecorated window gets its caption back when the toolkit changes its flags // (maximize, restore, …): take it off again before the frame is laid out (#2246). if let Some(window_frame) = self.services.window_frame.as_mut() { @@ -1779,6 +1784,11 @@ impl eframe::App for PhotocraftApp { if self.custom_titlebar { titlebar::release_after_os_resize(ctx, raw_input); } + if self.stroke_trace.enabled() { + // The OS's moves and the control channel's (injected just above). + let moves = raw_input.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count(); + self.stroke_trace.begin_frame(frame_start, moves); + } } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -1792,6 +1802,8 @@ impl eframe::App for PhotocraftApp { return; } let t0 = gpu_canvas::now_ms(); + self.perf.frame_refresh_ms = 0.0; + self.stroke_trace.cpu_prev(frame.info().cpu_usage); // A text field's right-click menu, before any field is drawn (text_field_menu.rs). text_field_menu::show(&ctx); // Each painting tool keeps its own brush (#218), so switch the active tool's brush in @@ -1893,7 +1905,13 @@ impl eframe::App for PhotocraftApp { egui::Modal::new(egui::Id::new("screen-color-wait")).frame(egui::Frame::NONE).backdrop_color(egui::Color32::TRANSPARENT).show(&ctx, |_| {}); ctx.set_cursor_icon(egui::CursorIcon::Wait); } - self.perf.frame(gpu_canvas::now_ms() - t0); + let ui_ms = gpu_canvas::now_ms() - t0; + self.perf.frame(ui_ms); + if self.stroke_trace.enabled() { + self.stroke_trace.canvas(self.perf.frame_refresh_ms); + let info = &self.perf.gpu_info; + self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || format!("{} {}", info.backend, info.adapter).trim().to_string()); + } // Synthetic input is injected one press/release step per frame: keep frames coming until // the queue is empty, then release control replies waiting on it. if !self.synthetic.is_empty() { diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index fc6d0c7e78..a472a4125c 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -338,6 +338,48 @@ fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() { assert_eq!(c.since(99), None, "not a key of this stroke"); } +#[test] +fn the_stroke_trace_times_each_frame_of_a_stroke_and_ends_on_release() { + // The app's hooks around the canvas (`raw_input_hook` starts a frame, `ui` ends it); the + // canvas times the live stroke and the release's commit itself. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Brush; + app.stroke_trace.output = crate::stroke_trace::Output::Stderr; + let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + let t0 = crate::gpu_canvas::now_ms(); + let moves = ctx.input(|i| i.raw.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count()); + app.stroke_trace.begin_frame(t0, moves); + app.stroke_trace.cpu_prev(Some(0.004)); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + let stroking = app.live_stroke.is_some(); + app.stroke_trace.end_frame(crate::gpu_canvas::now_ms() - t0, stroking, || "test".into()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + move_to(&mut h, 20.0, 50.0); + button(&mut h, 20.0, 50.0, true); + for x in [40.0, 60.0, 80.0, 100.0, 120.0] { + move_to(&mut h, x, 50.0); + } + assert!(h.state().stroke_trace.last.is_none(), "the stroke is still being drawn"); + button(&mut h, 120.0, 50.0, false); + h.run_steps(2); + let s = h.state().stroke_trace.last.clone().expect("releasing ends the stroke's trace"); + assert!(s.frames >= 6, "{s:?}"); + assert!(s.stroke[1] > 0.0 && s.ui[0] >= s.stroke[0], "{s:?}"); + assert_eq!(s.moves[1], 1.0, "one move per test frame: {s:?}"); + assert_eq!(s.backend, "test"); +} + /// 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. diff --git a/crates/ui-egui/src/stroke_trace.rs b/crates/ui-egui/src/stroke_trace.rs new file mode 100644 index 0000000000..206cc7e81a --- /dev/null +++ b/crates/ui-egui/src/stroke_trace.rs @@ -0,0 +1,297 @@ +//! Stroke latency trace (#1343): where each frame's time goes while a brush stroke is drawn, so +//! a tester on any platform can report a per-stage breakdown instead of one input-to-pixel number. +//! +//! Set `PHOTOCRAFT_STROKE_TRACE` before launching: `1` prints a summary to stderr when each stroke +//! ends; a file path also appends every frame of every stroke to that CSV. The last summary is in +//! the control channel's `perf` output (`strokeTrace`). +//! +//! Per frame of a stroke it records the interval since the previous frame started, the pointer +//! moves the frame delivered, the live stroke's render time, the canvas refresh (composite and +//! upload), the UI build, and eframe's CPU time for the frame before (UI, tessellation and GPU +//! submission, without the vsync wait). What the app can't see (the OS input queue before a frame +//! starts, frames queued for the display, the compositor) is the rest of the input-to-photon time; +//! the summary names the GPU backend because GL ignores the frame-latency setting. + +use std::fmt::Write as _; + +/// One frame of a stroke. Times in ms. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Row { + /// Frame start, from [`crate::gpu_canvas::now_ms`]. + pub t: f64, + /// Since the previous frame started (0 for the first frame the trace saw). + pub interval: f64, + /// Pointer moves the frame delivered. + pub moves: usize, + /// Rendering the stroke's new points (the live stroke, or the commit on release). + pub stroke: f64, + /// Canvas refresh: composite and upload. + pub canvas: f64, + /// Building the UI (`App::ui`), the two above included. + pub ui: f64, + /// eframe's CPU time for the previous frame: UI, tessellation and GPU submission. + pub cpu_prev: Option, +} + +/// Percentiles of a stroke's frames, for the summary line and the control channel. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Summary { + pub frames: usize, + /// GPU backend and adapter ("vulkan NVIDIA GeForce …"). + pub backend: String, + /// `[p50, p95]` of each stage, in ms. + pub interval: [f64; 2], + pub moves: [f64; 2], + pub stroke: [f64; 2], + pub canvas: [f64; 2], + pub ui: [f64; 2], + /// eframe's CPU time per frame (UI, tessellation, submission). + pub cpu: [f64; 2], + /// Frame interval minus that CPU time: waiting on vsync / the swap chain, or idle. + pub wait: [f64; 2], +} + +impl Summary { + /// One line for stderr. + pub fn line(&self) -> String { + let f = |name: &str, [p50, p95]: [f64; 2]| format!("{name} {p50:.2}/{p95:.2}"); + [ + format!("stroke trace: {} frames on {} (p50/p95 ms):", self.frames, self.backend), + f("interval", self.interval), + f("moves/frame", self.moves), + f("stroke", self.stroke), + f("canvas", self.canvas), + f("ui", self.ui), + f("cpu", self.cpu), + f("wait", self.wait), + ] + .join(" ") + } +} + +/// Where the trace goes ([`StrokeTrace::from_env`]). +// The web build has no clock, so it never traces. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) enum Output { + #[default] + Off, + /// A summary per stroke on stderr. + Stderr, + /// The summary on stderr, and every frame appended to this CSV. + Csv(std::path::PathBuf), +} + +#[derive(Debug, Default)] +pub struct StrokeTrace { + pub(crate) output: Output, + /// The frame being built (started by `begin_frame`). + frame: Option, + last_start: Option, + /// The current stroke's frames. + rows: Vec, + /// Strokes finished so far (numbers the CSV rows). + strokes: usize, + /// The last finished stroke's summary. + pub last: Option, +} + +/// `[p50, p95]` of `v` (nearest rank; zeros when empty). +fn percentiles(mut v: Vec) -> [f64; 2] { + v.retain(|x| x.is_finite()); + if v.is_empty() { + return [0.0, 0.0]; + } + v.sort_by(f64::total_cmp); + let at = |p: f64| v.get(((p * (v.len() - 1) as f64).round() as usize).min(v.len() - 1)).copied().unwrap_or(0.0); + let r = |x: f64| (x * 100.0).round() / 100.0; + [r(at(0.5)), r(at(0.95))] +} + +impl StrokeTrace { + /// Reads `PHOTOCRAFT_STROKE_TRACE` (off on the web, where there is no clock). + pub fn from_env() -> Self { + #[cfg(not(target_arch = "wasm32"))] + if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE").filter(|v| !v.is_empty()) { + let output = if v == "1" || v == "stderr" { Output::Stderr } else { Output::Csv(v.into()) }; + return Self { output, ..Default::default() }; + } + Self::default() + } + + pub fn enabled(&self) -> bool { + self.output != Output::Off + } + + /// A frame starts (the raw input hook) with `moves` pointer moves. + pub fn begin_frame(&mut self, now: f64, moves: usize) { + if !self.enabled() { + return; + } + let interval = self.last_start.map_or(0.0, |t| now - t); + self.last_start = Some(now); + self.frame = Some(Row { t: now, interval, moves, ..Default::default() }); + } + + /// eframe's CPU time for the previous frame, in seconds (`Frame::info().cpu_usage`). + pub fn cpu_prev(&mut self, secs: Option) { + if let Some(f) = self.frame.as_mut() { + f.cpu_prev = secs.map(|s| f64::from(s) * 1000.0); + } + } + + /// Time spent rendering stroke points this frame. + pub fn stroke(&mut self, ms: f64) { + if let Some(f) = self.frame.as_mut() { + f.stroke += ms; + } + } + + /// Time spent refreshing the canvas this frame. + pub fn canvas(&mut self, ms: f64) { + if let Some(f) = self.frame.as_mut() { + f.canvas += ms; + } + } + + /// The frame's UI is built (`ui` ms). `stroking`: a stroke is being drawn (a frame that + /// rendered stroke points counts too, such as the release's commit). A stroke's frames are + /// kept until the first frame without one, which ends it. + pub fn end_frame(&mut self, ui: f64, stroking: bool, backend: impl FnOnce() -> String) { + let Some(mut f) = self.frame.take() else { return }; + if stroking || f.stroke > 0.0 { + f.ui = ui; + // The first frame of a stroke doesn't follow a stroke frame: its interval is idle time. + if self.rows.is_empty() { + f.interval = 0.0; + } + self.rows.push(f); + } else if !self.rows.is_empty() { + self.finish(backend()); + } + } + + fn finish(&mut self, backend: String) { + let rows = std::mem::take(&mut self.rows); + self.strokes += 1; + let col = |g: fn(&Row) -> Option| percentiles(rows.iter().filter_map(g).collect()); + let summary = Summary { + frames: rows.len(), + backend, + interval: col(|r| (r.interval > 0.0).then_some(r.interval)), + moves: col(|r| Some(r.moves as f64)), + stroke: col(|r| Some(r.stroke)), + canvas: col(|r| Some(r.canvas)), + ui: col(|r| Some(r.ui)), + // A frame's cpu_prev belongs to the frame before it, so pair it with that interval. + cpu: percentiles(rows.iter().skip(1).filter_map(|r| r.cpu_prev).collect()), + wait: percentiles(rows.windows(2).filter_map(|w| Some((w[1].interval - w[1].cpu_prev?).max(0.0))).collect()), + }; + if self.enabled() { + eprintln!("{}", summary.line()); + } + #[cfg(not(target_arch = "wasm32"))] + if let Output::Csv(path) = &self.output + && let Err(e) = append_csv(path, self.strokes, &rows) + { + eprintln!("stroke trace: can't write {}: {e}", path.display()); + } + self.last = Some(summary); + } +} + +/// The CSV text for `rows` of stroke `stroke` (with the header when `header`). +// The web build has no clock, so it never traces. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn csv(stroke: usize, rows: &[Row], header: bool) -> String { + let mut s = String::new(); + if header { + s.push_str("stroke,t_ms,interval_ms,moves,stroke_ms,canvas_ms,ui_ms,cpu_prev_ms\n"); + } + for r in rows { + let cpu = r.cpu_prev.map_or(String::new(), |c| format!("{c:.3}")); + let _ = writeln!(s, "{stroke},{:.3},{:.3},{},{:.3},{:.3},{:.3},{cpu}", r.t, r.interval, r.moves, r.stroke, r.canvas, r.ui); + } + s +} + +#[cfg(not(target_arch = "wasm32"))] +fn append_csv(path: &std::path::Path, stroke: usize, rows: &[Row]) -> std::io::Result<()> { + use std::io::Write as _; + let header = std::fs::metadata(path).map_or(true, |m| m.len() == 0); + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?; + f.write_all(csv(stroke, rows, header).as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn on() -> StrokeTrace { + StrokeTrace { output: Output::Stderr, ..Default::default() } + } + + fn frame(t: &mut StrokeTrace, now: f64, cpu: f32, stroking: bool) { + t.begin_frame(now, 3); + t.cpu_prev(Some(cpu / 1000.0)); + if stroking { + t.stroke(1.0); + t.canvas(2.0); + } + t.end_frame(4.0, stroking, || "vulkan Test GPU".into()); + } + + #[test] + fn a_stroke_is_its_frames_from_the_first_stroke_frame_to_the_first_without() { + let mut t = on(); + frame(&mut t, 0.0, 5.0, false); + frame(&mut t, 100.0, 5.0, true); + for k in 1..=10 { + frame(&mut t, 100.0 + 16.0 * f64::from(k), 6.0, true); + } + assert!(t.last.is_none(), "still drawing"); + frame(&mut t, 276.0, 6.0, false); + let s = t.last.clone().expect("the stroke ended"); + assert_eq!(s.frames, 11); + assert_eq!(s.interval, [16.0, 16.0], "the idle gap before the stroke isn't a frame interval"); + assert_eq!((s.stroke, s.canvas, s.ui, s.moves), ([1.0, 1.0], [2.0, 2.0], [4.0, 4.0], [3.0, 3.0])); + assert_eq!((s.cpu, s.wait), ([6.0, 6.0], [10.0, 10.0])); + assert!(s.line().contains("11 frames on vulkan Test GPU") && s.line().contains("wait 10.00/10.00"), "{}", s.line()); + // Off: nothing is kept. + let mut off = StrokeTrace::default(); + frame(&mut off, 0.0, 1.0, true); + frame(&mut off, 16.0, 1.0, false); + assert!(off.last.is_none() && off.rows.is_empty()); + } + + #[test] + fn csv_rows_and_odd_numbers() { + let rows = [Row { t: 1.0, interval: 16.5, moves: 2, stroke: 0.25, canvas: 1.0, ui: 3.0, cpu_prev: None }]; + assert_eq!(csv(1, &rows, true), "stroke,t_ms,interval_ms,moves,stroke_ms,canvas_ms,ui_ms,cpu_prev_ms\n1,1.000,16.500,2,0.250,1.000,3.000,\n"); + assert_eq!(percentiles(vec![]), [0.0, 0.0]); + assert_eq!(percentiles(vec![f64::NAN, 2.0, f64::INFINITY]), [2.0, 2.0]); + assert_eq!(percentiles((1..=100).map(f64::from).collect()), [51.0, 95.0]); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_csv_gets_one_header_and_every_strokes_frames() { + let dir = std::env::temp_dir().join(format!("photocraft-stroke-trace-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("trace.csv"); + let _ = std::fs::remove_file(&path); + let mut t = StrokeTrace { output: Output::Csv(path.clone()), ..Default::default() }; + for start in [0.0, 1000.0] { + for k in 0..3 { + frame(&mut t, start + 16.0 * f64::from(k), 5.0, true); + } + frame(&mut t, start + 48.0, 5.0, false); + } + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 7, "{text}"); + assert!(lines[0].starts_with("stroke,") && lines[1].starts_with("1,") && lines[6].starts_with("2,")); + } +} diff --git a/docs/development.md b/docs/development.md index 17c7e67dce..f13690102e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -119,6 +119,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) | | `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). | | `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild | +| `PHOTOCRAFT_STROKE_TRACE=1` (or `=`) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | ### Live design tokens @@ -129,6 +130,26 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. Keys are the field names of `theme::Tokens` (`crates/ui-egui/src/theme.rs`). Edit and save the file while the app runs to see changes immediately, with no recompile. This is compiled out of release builds. +### Stroke latency trace + +To find where a stroke's input-to-pixel time goes on a given machine (#1343), launch with `PHOTOCRAFT_STROKE_TRACE=1` (a summary per stroke on standard error) or `PHOTOCRAFT_STROKE_TRACE=strokes.csv` (also every frame, appended to that file). Paint a few strokes with the mouse or pen. Each summary gives p50/p95 in ms: + +```text +stroke trace: 62 frames on vulkan NVIDIA GeForce RTX 5090 (p50/p95 ms): interval 8.33/8.53 moves/frame 1.00/2.00 stroke 0.80/1.53 canvas 0.40/0.75 ui 0.91/1.41 cpu 2.20/2.84 wait 6.04/6.64 +``` + +| Column | What it is | +|---|---| +| `interval` | Time between frame starts while drawing (the display's refresh period when the app keeps up) | +| `moves/frame` | Pointer moves the OS delivered per frame (control-channel `ui.pointer` input bypasses raw input and counts 0) | +| `stroke` | Rendering the stroke's new points (the live stroke; on the last frame, the commit) | +| `canvas` | Canvas refresh: composite and upload | +| `ui` | Building the UI, the two above included | +| `cpu` | eframe's CPU time per frame: UI, tessellation and GPU submission, without the vsync wait | +| `wait` | `interval − cpu`: waiting on vsync or the swap chain, or idle | + +What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`. + ## Driving the app programmatically Start the app with a private token file. It then accepts authenticated JSON lines on `127.0.0.1:7878`: From 9df59c96b820e08ff63ecb94f2eecfc6c87d17a9 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:58:14 +0400 Subject: [PATCH 019/167] docs: write product name as PhotoCraft in development guide (#5228) --- docs/development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index f13690102e..3c539f70bc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -264,7 +264,7 @@ A typical agent loop (place inputs under the configured read root and outputs un `crates/cms` is our own pure-Rust ICC engine (v2/v4 parsing, matrix/TRC and LUT profiles, all four intents, black point compensation). It ships CC0 built-in profiles, including a synthetic -"Photocraft Coated CMYK", because Adobe's CMYK profiles are proprietary (see `crates/cms/README.md`). +"PhotoCraft Coated CMYK", because Adobe's CMYK profiles are proprietary (see `crates/cms/README.md`). - Documents carry an optional embedded ICC profile (`Document::icc_profile`); `edit.assignProfile` and `edit.convertToProfile` change it. Mode changes (`image.mode.*`) convert through cms. From d11be6de78735205917a2efa5024a2e96052942d Mon Sep 17 00:00:00 2001 From: LorenzoFasolino Date: Wed, 14 Oct 2026 07:58:18 +0200 Subject: [PATCH 020/167] docs: drop stale missing-tool rows, only Art History Brush is missing (#5063) Task T-0002 del lab artcraft-lab. File toccati: docs/brush-parity.md | 5 +++-- docs/gaps.md | 6 +++--- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 7 ++++--- 4 files changed, 13 insertions(+), 10 deletions(-) Evidenze: artifacts/T-0002/ (nel lab, non in questo repo). --- docs/brush-parity.md | 5 +++-- docs/gaps.md | 6 +++--- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 7 ++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..96280dc856 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-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed) · **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 @@ -99,7 +99,7 @@ tablets against Photoshop side by side. | Blur, Sharpen, Smudge (premultiplied, Sample All Layers, spacing) | done | BUG-207-2/3, #2171 | | Dodge, Burn, Sponge | done | | | Background Eraser, Magic Eraser | done | | -| Color Replacement | missing as a tool | engine command exists | +| Color Replacement | done | | | Quick Selection (brush-driven) | done | | ## Presets @@ -115,6 +115,7 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Stale "missing tool" row corrected against main: Color Replacement (`paint.colorReplacement`) is a live toolbar tool; no brush-feel estimate changed | | 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 25a286aab8..c952bac81b 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-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **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 @@ -54,8 +54,7 @@ what an agent can't finish alone. scripted check there) is the only real oracle. ### G6. Missing tools -- **Missing (4 of 68):** Color Replacement (engine command exists), Art - History Brush, Add Anchor Point, Delete Anchor Point. Convert Point landed (#3204). Freeform +- **Missing (1 of 68):** Art History Brush. Convert Point landed (#3204). Freeform Pen and Curvature Pen landed (#1054); Color Sampler landed (#1046, `view.colorSamplers.*`); Horizontal and Vertical Type Mask landed (#1055, `type.createSelection`); Perspective Crop landed (#1043, `image.perspectiveCrop`); Frame landed (#2914). @@ -263,6 +262,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a7c802904b..c11ad8aff8 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-12 · **Last updated:** 2026-10-12 · **Change:** minor (`.atn` import maps 112 events; Free Transform H/V skew fields; 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 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (`.atn` import maps 112 events; Free Transform H/V skew fields; 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; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **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); @@ -444,7 +444,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush and Frame (Perspective Crop landed 2026-10-10, #1043: `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. The only tool still missing is Art History Brush: Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | | 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f2092fd3e2..5abc8727a4 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-13 · **Change:** minor (smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **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, @@ -34,13 +34,13 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | | Retouch | Remove, Spot Healing, Healing, Patch, Content-Aware Move, Red Eye | | -| Brush | Brush, Pencil, Mixer Brush | Color Replacement (`paint.colorReplacement` exists) | +| Brush | Brush, Pencil, Mixer Brush | | | Stamp | Clone Stamp, Pattern Stamp | | | History | History Brush | Art History Brush | | Eraser | Eraser, Background Eraser, Magic Eraser | | | Fill | Gradient, Paint Bucket | | | Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | | -| Pen | Pen, Freeform Pen, Curvature Pen (#1054), Convert Point (#3204; also ⌥ with Pen) | Add Anchor Point, Delete Anchor Point | +| Pen | Pen, Freeform Pen, Curvature Pen (#1054), Convert Point (#3204; also ⌥ with Pen), Add Anchor Point, Delete Anchor Point (P cycles them; #4144 auto add/delete with the Pen itself) | | | Type | Horizontal, Vertical, Vertical Type Mask, Horizontal Type Mask | | | Path selection | Path Selection, Direct Selection | | | Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) | @@ -217,6 +217,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | | 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped | | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | | 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 | From 2388d821655a5f6081bd5e50f498426c0854ce1a Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:58:22 -0400 Subject: [PATCH 021/167] Docs: oracle floor, open issues, workspace size and layer filters match main (#5376) (#5382) - ROADMAP.md: the oracle floor is 145 of 258 (crates/io/tests/corpus.rs, gaps.md G1), not 133 of 256; 953 open issues, not 579. - docs/architecture.md: ~539,000 lines in ~1,310 files and ~8,040 #[test] functions, recounted 2026-10-13. - README.md: "a layer kind filter" instead of "layer filters"; only the Kind filter exists. - AGENTS.md: drop an empty bullet at the end of section 7. Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 1 - README.md | 2 +- ROADMAP.md | 7 ++++--- docs/architecture.md | 9 +++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e44dbe32a8..6adc5923db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still - `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics. - `plan/` (local, gitignored): research, parity plan, execution plan, estimates. - `log/` (local, gitignored): the dev log. -- ## 8. Keeping the native format complete diff --git a/README.md b/README.md index 5fd2650415..b272705e40 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗃️ Layers, done properly

- Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into. + Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into. diff --git a/ROADMAP.md b/ROADMAP.md index 98e8c1184c..f41b71bfc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules); architecture and the PSD writer are the serial parts. **Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to -end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets +end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows (retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen; @@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut | Rank | Milestone | Estimate | |---|---|---:| -| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h | +| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h | | 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h | | 3 | User bug backlog to under 100 open | 250–400 h | | 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h | @@ -135,6 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | | 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | diff --git a/docs/architecture.md b/docs/architecture.md index 7856636ccb..67eebe9cce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # PhotoCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (workspace size and test counts recounted; current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026 ## Current state (2026-10-10) @@ -8,9 +8,9 @@ What exists on `main` today. The numbered sections below are the original design 2026-09-30; where the code differs, this summary wins. - **Workspace:** 25 library crates under `crates/`, 3 apps (`photocraft` desktop, `photocraft-cli`, - `photocraft-web`), `xtask`, an example plug-in and 5 fuzz crates; ~393,000 lines of Rust in - ~1,020 files; ~5,460 `#[test]` functions (ui-egui 1,677, engine 1,237, algo 408, io 377, - codecs 325, psd 263). Crate map and layers: [`AGENTS.md`](../AGENTS.md) §2 and + `photocraft-web`), `xtask`, an example plug-in and 5 fuzz crates; ~539,000 lines of Rust in + ~1,310 files; ~8,040 `#[test]` functions (ui-egui 2,809, engine 1,796, algo 572, io 539, + codecs 426, psd 341), counted 2026-10-13. Crate map and layers: [`AGENTS.md`](../AGENTS.md) §2 and `xtask/src/layers.rs` (enforced by `cargo xtask layers`). - **UI toolkit:** decided: egui/eframe on wgpu (no webview, no JavaScript). The same Rust builds to WebAssembly for the browser (trunk + wasm-bindgen). @@ -588,5 +588,6 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged | | 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` | | 2026-09-30 | major | Design plan v1 | From a954194277121a1673cfd579d0182929bb21a705 Mon Sep 17 00:00:00 2001 From: Zer0C00L01 Date: Wed, 14 Oct 2026 00:58:27 -0500 Subject: [PATCH 022/167] PSD import: ResolutionInfo stays pixels per inch when the display unit is cm (#5280) hRes and vRes are stored as pixels per inch. The unit that follows only controls how that number is shown, so a 300 ppi file with unit cm opened at 762 ppi. Export already writes the document ppi and does not divide by 2.54. Fixes #5201 Co-authored-by: Cursor Agent --- crates/io/src/psd_export.rs | 1 + crates/io/src/psd_import.rs | 5 +++-- crates/io/tests/psd_structure.rs | 11 ++++++++--- crates/io/tests/resolution_axes.rs | 20 +++++++++++++++++--- crates/psd/src/resources.rs | 20 ++++++++++++-------- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 9db3d4799f..47861dbcc3 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1636,6 +1636,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c // Resources. let mut resources = vec![ + // hRes/vRes are always pixels per inch. The display unit written here is inches. 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()), diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 444c2d6e5b..211bba4da9 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -673,8 +673,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) match r.id { ids::RESOLUTION_INFO => { if let Ok(ri) = photocraft_psd::ResolutionInfo::from_bytes(&r.data) { - let ppi = |res: f64, unit: u16| if unit == 2 { res * 2.54 } else { res }; - let (x, y) = (ppi(ri.h_res(), ri.h_res_unit), ppi(ri.v_res(), ri.v_res_unit)); + // Fixed 16.16 hRes/vRes are pixels per inch. The unit shorts only + // say how to show that number (1 = px/in, 2 = px/cm). + let (x, y) = (ri.h_res(), ri.v_res()); doc.resolution_dpi = x as f32; warnings.extend(crate::unequal_resolution_warning(x, y)); } diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index e893adaf94..5627dca3f4 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -460,13 +460,18 @@ fn guides_and_alpha_names_roundtrip() { } #[test] -fn resolution_in_cm_converted() { +fn resolution_info_cm_display_unit_stays_pixels_per_inch() { let mut f = testgen::small(Version::Psd, Compression::Raw); - let mut ri = photocraft_psd::ResolutionInfo::from_dpi(100.0); + let mut ri = photocraft_psd::ResolutionInfo::from_dpi(300.0); ri.h_res_unit = 2; + ri.v_res_unit = 2; f.resources[0] = photocraft_psd::ImageResource::new(1005, ri.to_bytes()); let (d, _) = psd_to_document(&f); - assert!((d.resolution_dpi - 254.0).abs() < 1e-3); + assert!((d.resolution_dpi - 300.0).abs() < 1e-3, "{}", d.resolution_dpi); + let out = document_to_psd(&d); + let written = out.resolution().unwrap(); + assert!((written.h_res() - 300.0).abs() < 1e-3, "{}", written.h_res()); + assert!((written.v_res() - 300.0).abs() < 1e-3, "{}", written.v_res()); } #[test] diff --git a/crates/io/tests/resolution_axes.rs b/crates/io/tests/resolution_axes.rs index 58b67d93c4..f7f8cbf006 100644 --- a/crates/io/tests/resolution_axes.rs +++ b/crates/io/tests/resolution_axes.rs @@ -59,13 +59,27 @@ fn psd_with_equal_axes_opens_silently() { let r = import("x.psd", &psd(ResolutionInfo::from_dpi(300.0))).unwrap(); assert!((r.document.resolution_dpi - 300.0).abs() < 1e-3, "{}", r.document.resolution_dpi); assert!(resolution_notes(&r).is_empty(), "{:?}", r.warnings); - // Each axis is read in its own unit: 100 pixels per centimetre on both is 254 ppi on both. - let mut cm = ResolutionInfo::from_dpi(100.0); +} + +/// Resource 1005 stores 300 ppi even when the display unit is centimetres. Scaling +/// that fixed-point value by 2.54 opened the file at 762 ppi (#5201). +#[test] +fn resolution_info_cm_display_unit_is_pixels_per_inch() { + let mut cm = ResolutionInfo::from_dpi(300.0); cm.h_res_unit = 2; cm.v_res_unit = 2; + cm.width_unit = 2; + cm.height_unit = 2; let r = import("x.psd", &psd(cm)).unwrap(); - assert!((r.document.resolution_dpi - 254.0).abs() < 1e-3, "{}", r.document.resolution_dpi); + assert!((r.document.resolution_dpi - 300.0).abs() < 1e-3, "{}", r.document.resolution_dpi); assert!(resolution_notes(&r).is_empty(), "{:?}", r.warnings); + + let out = export(&r.document, "out.psd", &ExportOptions::default()).unwrap(); + let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let written = file.resolution().unwrap(); + assert!((written.h_res() - 300.0).abs() < 1e-3, "{}", written.h_res()); + assert!((written.v_res() - 300.0).abs() < 1e-3, "{}", written.v_res()); + assert_eq!((written.h_res_unit, written.v_res_unit), (1, 1)); } /// A layered TIFF opens through the PSD importer with the TIFF's own resolution tags. diff --git a/crates/psd/src/resources.rs b/crates/psd/src/resources.rs index c20efbfc9e..4d78a062be 100644 --- a/crates/psd/src/resources.rs +++ b/crates/psd/src/resources.rs @@ -45,20 +45,24 @@ pub struct ImageResource { } /// Fixed-point resolution info (resource 1005). +/// +/// `h_res_fixed` and `v_res_fixed` are always pixels per inch (16.16 fixed). +/// `h_res_unit` and `v_res_unit` only say how to display that number +/// (1 = pixels/inch, 2 = pixels/cm) and do not rescale the stored value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ResolutionInfo { - /// Horizontal resolution, 16.16 fixed point (pixels per inch/cm). + /// Horizontal resolution, 16.16 fixed point, always pixels per inch. pub h_res_fixed: u32, - /// 1 = pixels per inch, 2 = pixels per cm. + /// Display unit for the horizontal resolution (1 = px/in, 2 = px/cm). pub h_res_unit: u16, - /// Display unit for width (1 in, 2 cm, 3 pt, 4 pica, 5 column). + /// Ruler unit for width (1 in, 2 cm, 3 pt, 4 pica, 5 column). pub width_unit: u16, - /// Vertical resolution, 16.16 fixed. + /// Vertical resolution, 16.16 fixed point, always pixels per inch. pub v_res_fixed: u32, - /// Vertical resolution unit. + /// Display unit for the vertical resolution (1 = px/in, 2 = px/cm). pub v_res_unit: u16, - /// Display unit for height. + /// Ruler unit for height (1 in, 2 cm, 3 pt, 4 pica, 5 column). pub height_unit: u16, } @@ -68,11 +72,11 @@ impl ResolutionInfo { let f = (dpi * 65536.0).round().clamp(0.0, u32::MAX as f64) as u32; ResolutionInfo { h_res_fixed: f, h_res_unit: 1, width_unit: 1, v_res_fixed: f, v_res_unit: 1, height_unit: 1 } } - /// Horizontal resolution as float. + /// Horizontal resolution in pixels per inch. pub fn h_res(&self) -> f64 { f64::from(self.h_res_fixed) / 65536.0 } - /// Vertical resolution as float. + /// Vertical resolution in pixels per inch. pub fn v_res(&self) -> f64 { f64::from(self.v_res_fixed) / 65536.0 } From e12b7f73525f9623692c5085a928061a225680f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=C2=B7=20VanTan?= <77483789+VanTan666@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:58:31 +0200 Subject: [PATCH 023/167] fix(prefs): preserve selected themes during legacy migration (#5179) --- crates/engine/src/prefs.rs | 3 ++- crates/engine/src/prefs/tests.rs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 5e728cb546..b163d40d88 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1553,7 +1553,8 @@ impl Session { } }; interface.insert("appearanceMode".into(), json!(mode)); - interface.insert(slot.into(), json!(theme.name())); + // A partially migrated file may already contain a newer theme selection (#5139). + interface.entry(slot).or_insert_with(|| json!(theme.name())); } let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 36a5b69abc..6b56cbc7ba 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -67,6 +67,17 @@ fn appearance_defaults_and_legacy_theme_migrate() { } } +#[test] +fn legacy_theme_migration_preserves_saved_theme_slots() { + for (legacy, slot, saved) in [("proMedium", "darkTheme", "solarizedDark"), ("studioLight", "lightTheme", "adwaita")] { + let mut interface = json!({"theme": legacy}); + interface[slot] = json!(saved); + let mut session = Session::new(); + session.load_prefs_json(&json!({"interface": interface}).to_string()).unwrap(); + assert_eq!(session.prefs_value()["interface"][slot], saved, "explicit {slot} survives migration"); + } +} + #[test] fn appearance_choices_validate_and_legacy_theme_still_selects() { let mut s = Session::new(); From 077e513686ce4f79e957d3ce71340495cbcdb0af Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 07:58:36 +0200 Subject: [PATCH 024/167] Color Range presets: weigh dark colours like Photoshop (#5243) (#5293) Reds/Yellows/Greens/Cyans/Blues/Magentas selected hue closeness times chroma. Measured against Photoshop 2025 on 126 colours per preset (Reds, Yellows, Blues; hue +-40 degrees, saturation 5..100 %, brightness 33..100 %), Photoshop also multiplies by (2 - max): bright colours were already exact, darker ones came out up to 61 levels short. With the factor every sample matches within one level. The muted navy #3d3f54 from the issue now gets 35/255, as in Photoshop (it was 21): Photoshop also leaves it below 50 %. --- crates/algo/src/selection.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index ad858df2a2..94f744d0be 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -682,18 +682,23 @@ fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) { /// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60, /// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the -/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so -/// neutrals are never selected and muted colours only partly. +/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma +/// (max − min), so neutrals are never selected and muted colours only partly. Darker colours +/// weigh more, by `2 − max`: measured against Photoshop 2025's presets on 126 colours each for +/// Reds, Yellows and Blues (hue ±40°, saturation 5..100 %, brightness 33..100 %), this matches +/// its selection to within one level, where chroma alone fell up to 61 levels short (#5243). pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec { px.iter() .map(|&[r, g, b, a]| { if a <= 0.0 { return 0.0; } - let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0))); + let rgb = [r, g, b].map(|v| v.clamp(0.0, 1.0)); + let (h, chroma) = hue_chroma(rgb); + let max = rgb[0].max(rgb[1]).max(rgb[2]); let dh = (h - center).rem_euclid(360.0); let dh = dh.min(360.0 - dh); - ((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0) + ((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma * (2.0 - max)).clamp(0.0, 1.0) }) .collect() } @@ -1180,6 +1185,17 @@ mod tests { assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5); } + #[test] + fn hue_families_weigh_dark_colours_like_photoshop() { + // Photoshop 2025's Blues on an 8-bit sRGB document (selection levels 0..255, #5243): the + // muted navy #3d3f54, a dark blue (0, 0, 84) and a mid blue (61, 61, 153). + let c = |r: u8, g: u8, b: u8| [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0]; + let blues = hue_range(&[c(0x3d, 0x3f, 0x54), c(0, 0, 84), c(61, 61, 153)], 240.0); + for (got, want) in blues.iter().zip([35.0, 140.0, 129.0]) { + assert!((got * 255.0 - want).abs() <= 1.0, "{} vs Photoshop {want}", got * 255.0); + } + } + #[test] fn tonal_ranges() { let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0]; From 33b360d5ee2a45e22053265239f52875ce3110ce Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 16:58:42 +1100 Subject: [PATCH 025/167] Reject clipboard origins that cannot form a new document (#4586) * Reject clipboard origins that cannot be moved into a new document. * Fill Clip.resolution in the unrepresentable-origin clipboard test. main added resolution to Clip; the synthetic overflow fixture omitted it and failed engine lib tests. * Format the clipboard origin test (cargo fmt) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/edit_cmds.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index f0541136ad..20ef23a41b 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -495,9 +495,12 @@ fn new_from_clipboard(s: &mut Session) -> Result { if w == 0 || h == 0 { return Err(EngineError::Other("the clipboard image has no size".into())); } + let (Some(dx), Some(dy)) = (b.x0.checked_neg(), b.y0.checked_neg()) else { + return Err(EngineError::Other("the clipboard origin is outside the coordinate range for a new document".into())); + }; let fmt = clip.surface.format(); let target = PixelFormat::new(fmt.mode, fmt.sample, true); - let moved = if b.x0 == 0 && b.y0 == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, -b.x0, -b.y0) }; + let moved = if dx == 0 && dy == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, dx, dy) }; let mut doc = Document::new("Untitled", photocraft_geom::Size::new(w, h), fmt.mode, fmt.sample); if let Some(r) = clip.resolution.filter(|r| r.is_finite() && *r > 0.0) { doc.resolution_dpi = r; @@ -1044,6 +1047,19 @@ mod tests { s } + #[test] + fn new_clipboard_document_rejects_an_unrepresentable_origin() { + let mut s = Session::new(); + let mut surface = Surface::new(PixelFormat::RGBA8); + surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]); + s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None }); + let result = new_from_clipboard(&mut s); + assert!(result.unwrap_err().to_string().contains("clipboard origin")); + assert!(s.execute("file.newFromClipboard", json!({})).is_err()); + assert!(s.documents().is_empty()); + assert_eq!(s.clipboard.as_ref().unwrap().surface.rgba(i32::MIN, 0), [1.0, 0.0, 0.0, 1.0]); + } + #[test] fn injected_clear_buffer_failure_leaves_the_document_unchanged() { let mut s = session(); From 79e2107f71cbe12e5dd4bb9aaf79f91449c50453 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 16:58:46 +1100 Subject: [PATCH 026/167] Report channel copy allocation failures without losing the clipboard. (#4587) --- crates/engine/src/channel_clip.rs | 9 +++++---- crates/engine/src/channel_clip/tests.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index fc52cf0a43..fea1048235 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -59,15 +59,16 @@ pub(crate) fn copy(s: &mut Session) -> Result> { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, true); - let values = src.read_region(area); - let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1))); - let mut px = Vec::with_capacity(values.len() / n * 2); + let values = crate::allocation::read_region(src, area, "copying a channel")?; + let cover = sel.map(|m| crate::allocation::read_region(m, area, "copying a channel selection").map(|v| (v, m.format().channels().max(1)))).transpose()?; + let len = (values.len() / n).checked_mul(2).ok_or_else(|| EngineError::Other("the channel is too large to copy".into()))?; + let mut px = crate::allocation::capacity(len, "copying a channel")?; for (i, p) in values.chunks_exact(n).enumerate() { px.push(p.get(k).copied().unwrap_or(0.0)); px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0))); } let mut surface = Surface::new(fmt); - surface.write_region(area, &px); + surface.try_write_region(area, &px)?; surface.prune(); let bounds = surface.content_bounds(); if bounds.is_empty() { diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index 39cb029b3d..be7e51dfd7 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -163,3 +163,22 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() { assert_eq!(s.active().unwrap().history.entries(), history); } } + +#[test] +fn channel_copy_allocation_failure_keeps_clipboard_and_document() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let clip = s.clipboard.as_ref().unwrap().surface.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = copy(&mut s); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.clipboard.as_ref().unwrap().surface, clip); + } +} From 7c916d9ecdd8a36c1e1f6d5ac9691fe604cd2bd7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 16:58:51 +1100 Subject: [PATCH 027/167] Validate selection modification limits before allocating the canvas mask. (#4591) --- crates/engine/src/selection_cmds.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index 8efb8d7b94..74de286f04 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -461,7 +461,12 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result { // continue past the canvas. Border has no such option: its band always follows the canvas edge, // or a Select All would border to nothing. let at_bounds = op == "border" || b(p, "applyAtCanvasBounds", false); - let (area, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let area = doc.bounds(); + if area.size().area() > MAX_MODIFY_PIXELS as u64 { + return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() }); + } + let (_, m) = current_mask(doc); let (w, h) = (area.width() as usize, area.height() as usize); // Feather reaches 3σ = 3 r (σ = r, as in Photoshop); the others reach r. A margin past that // keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both @@ -765,6 +770,28 @@ mod tests { assert!(err.to_string().contains("detectFaces"), "{err}"); } + #[test] + fn modify_rejects_oversized_canvases_before_reading_the_mask() { + let mut s = Session::new(); + let mut doc = photocraft_doc::Document::new( + "large", + photocraft_geom::Size::new(300_000, 300_000), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ); + let mut selection = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); + selection.write_pixel(0, 0, &[1.0]); + doc.selection = Some(selection); + s.add_document(doc, None); + let before = s.active().unwrap().doc.clone(); + for op in ["expand", "contract", "border", "smooth", "feather"] { + let error = s.execute(&format!("select.modify.{op}"), json!({"radius": 1})).unwrap_err(); + assert!(error.to_string().contains("too large to modify")); + assert_eq!(*s.active().unwrap().doc, *before); + assert!(!s.active().unwrap().history.can_undo()); + } + } + #[test] fn color_range_replace_is_bounded_by_existing_selection() { for params in [json!({"color": "#ff0000", "fuzziness": 0}), json!({"select": "reds"})] { From 2da2889546ec0c40f02628a1db0af13d9260e30d Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 07:58:55 +0200 Subject: [PATCH 028/167] Background: an opacity, fill, blend mode or Select and Mask layer mask makes it Layer 0 (#4837) The Background has none of them. layer.setProps applied them to it as it was, and Select and Mask's Layer Mask output gave it a mask. Like Photoshop (and like PhotoCraft already does for moving, transforming, erasing, styles and Layer > Layer Mask), it now becomes a normal layer, "Layer 0", first. Values that leave its look alone (opacity and fill 100 %, Normal) keep it the Background. Fixes #4836 --- crates/engine/src/background_cmds.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/commands.rs | 7 +++++++ crates/engine/src/smartselect_cmds.rs | 1 + 3 files changed, 34 insertions(+) diff --git a/crates/engine/src/background_cmds.rs b/crates/engine/src/background_cmds.rs index 81c47501d3..e733b54809 100644 --- a/crates/engine/src/background_cmds.rs +++ b/crates/engine/src/background_cmds.rs @@ -144,6 +144,32 @@ mod tests { (s, [bg, a, b]) } + #[test] + fn opacity_fill_or_a_blend_mode_turn_the_background_into_a_layer() { + // Like Photoshop: the Background takes none of them, so it becomes "Layer 0" first. + for (key, value) in [("opacity", json!(0.5)), ("fill", json!(0.5)), ("blend", json!("Multiply"))] { + let (mut s, [bg, ..]) = with_background(); + s.execute("layer.setProps", json!({"layer": bg.0, key: value})).unwrap(); + let l = doc(&s).layer(bg).unwrap(); + assert!(!doc(&s).is_background(bg) && l.name == "Layer 0", "{key}"); + assert!(l.opacity < 1.0 || l.fill_opacity < 1.0 || l.blend == photocraft_color::BlendMode::Multiply, "{key}: applied"); + } + // Values that leave its look alone keep it the Background. + let (mut s, [bg, ..]) = with_background(); + s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 1.0, "fill": 1.0, "blend": "Normal", "visible": false})).unwrap(); + assert!(doc(&s).is_background(bg)); + } + + #[test] + fn select_and_mask_output_to_a_layer_mask_turns_the_background_into_a_layer() { + let (mut s, [bg, ..]) = with_background(); + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 6, "height": 6})).unwrap(); + s.execute("select.refineEdge", json!({"output": "layerMask"})).unwrap(); + let l = doc(&s).layer(bg).unwrap(); + assert!(l.mask.is_some() && !doc(&s).is_background(bg) && l.name == "Layer 0"); + } + #[test] fn makes_the_layer_the_background_at_the_bottom() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a32c5feb8d..318b105d12 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -688,6 +688,13 @@ fn build() -> Vec { // Clipping and channel changes reach the layers around it: recomposite everything. let local = p.get("clipped").is_none() && p.get("channels").is_none(); s.edit(label, |doc, _| { + // The Background has no opacity, fill or blend mode: like Photoshop, setting + // one that changes its look turns it into a normal layer ("Layer 0") first. + let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0); + let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal); + if num("opacity") || num("fill") || blends { + crate::extra_cmds::background_to_layer(doc, id); + } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let Some(v) = p.get("name").and_then(Value::as_str) { l.name = v.to_string(); diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 651db7cdc0..d8c2b345a9 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -417,6 +417,7 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { s.edit("Select and Mask", |doc, _| { let surface = region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); + crate::extra_cmds::background_to_layer_for_mask(doc, id); let existing = doc.layer(id).and_then(|l| l.mask.as_ref()).cloned().unwrap_or_else(LayerMask::reveal_all); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..existing }); doc.selection = None; From 4bbcede5725fdb8bdb4bfd501eef58b5af140834 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 07:59:00 +0200 Subject: [PATCH 029/167] Fibers: fill an empty layer, as Photoshop does (#5241) (#5289) Fibers renders from the foreground and background colours without reading the layer, but its output area was the layer's content grown by a zero halo: on an empty layer that is empty, so nothing was drawn (and a following transform found nothing to transform). On a partly filled layer only the content's bounding box was filled. It now writes the reference bounds (the selection's, else the canvas) like the global filters, without reading the whole layer as they do. --- crates/algo/src/lib.rs | 14 +++++++++++--- crates/algo/src/tests.rs | 4 ++++ crates/engine/src/filters_ext/tests.rs | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 78dae7b06f..49ab15871c 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -600,6 +600,13 @@ impl FilterParams { self.halo() == Halo::Bounds } + /// Whether the filter renders new pixels without reading the layer's own (Fibers): it fills + /// the reference bounds even where the layer is transparent, as Photoshop's does on an + /// empty layer, while still reading no neighbourhood. + pub fn renders_from_nothing(&self) -> bool { + matches!(self, FilterParams::Fibers { .. }) + } + /// Human-readable name. pub fn label(&self) -> &'static str { match self { @@ -905,12 +912,13 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec) -> Rect { let mut area = match params.halo_for(bounds) { Halo::Bounds => bounds, + Halo::Radius(_) if params.renders_from_nothing() => bounds, Halo::Radius(r) => { if content.is_empty() { Rect::EMPTY diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 9a90012df9..31def5f99d 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -446,6 +446,10 @@ fn halos_and_output_area() { assert_eq!(output_area(&FilterParams::Twirl { angle: 1.0 }, content, R, None), R); assert_eq!(output_area(&FilterParams::BoxBlur { radius: 1.0 }, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(8, 8, 12, 12)); assert!(output_area(&FilterParams::Invert, Rect::EMPTY, R, None).is_empty()); + // Fibers renders without reading the layer: an empty layer still fills the bounds (#5241). + let fibers = FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 0, foreground: [0.0; 4], background: [1.0; 4] }; + assert_eq!(output_area(&fibers, Rect::EMPTY, R, None), R); + assert_eq!(output_area(&fibers, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(0, 0, 12, 12)); } #[test] diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index b616101f7c..0f32c5367d 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -164,6 +164,27 @@ fn colour_filters_use_and_record_tool_colours() { } } +#[test] +fn fibers_fill_an_empty_layer() { + // #5241: Fibers renders from the colours, not the layer's pixels, so an empty layer fills + // like in Photoshop (and can be transformed afterwards). + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("filter.render.fibers", json!({"seed": 3})).unwrap(); + let px = pixels(&s); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 1.0), "every pixel is rendered opaque"); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + + // With a selection only the selection fills. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 6})).unwrap(); + s.execute("filter.render.fibers", json!({"seed": 3})).unwrap(); + let d = s.active().unwrap(); + let surf = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap(); + assert_eq!(surf.content_bounds(), Rect::new(4, 4, 14, 10)); +} + #[test] fn displace_reads_another_document() { let mut s = session(); From 15f7618504d9eaeb5ed910de9baa7a64c171a288 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 07:59:04 +0200 Subject: [PATCH 030/167] Object Selection: say so when the sampled layer is empty (#5246) (#5298) select.object samples the active layer by default. On an empty layer (scripts often add one above the photo) it found nothing and returned without an error; the failure only surfaced at the next command that needed a selection. It now fails with an error that names the cause and the sampleAllLayers option, also when the layer's pixels lie outside the rectangle or point. --- crates/engine/src/smartselect_cmds.rs | 38 ++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index d8c2b345a9..ea77e022b0 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -217,10 +217,23 @@ impl ObjectFinderCache { } } +/// Object Selection samples only the active layer by default: when that layer has no pixels in +/// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer +/// above the photo first). +fn check_sampled_pixels(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Result<()> { + let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) }; + if surf.content_bounds().intersect(&area).is_empty() { + let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }; + return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))); + } + Ok(()) +} + /// `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)?; + check_sampled_pixels(&d.doc, d.active_layer, all_layers, Rect::from_xywh(x.floor() as i32, y.floor() as i32, 1, 1))?; 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), @@ -248,7 +261,10 @@ fn object_selection(s: &mut Session, p: &Value) -> Result { 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))?; + let all_layers = b(p, "sampleAllLayers", false); + let d = s.active().ok_or(EngineError::NoDocument)?; + check_sampled_pixels(&d.doc, d.active_layer, all_layers, rect)?; + let region = with_sampler(s, all_layers, |smp, doc| grabcut::object_select(smp, doc.bounds(), rect, 160_000))?; apply(s, "Object Selection", region.as_ref(), mode(p, "replace")) } @@ -731,6 +747,26 @@ mod tests { assert!(cov(&s, 100, 80) >= 0.5); } + #[test] + fn object_selection_on_an_empty_active_layer_says_so() { + // #5246: an empty layer above the photo used to find nothing without a word. + let (mut s, inside) = disc(8); + s.execute("layer.new.layer", json!({})).unwrap(); + for p in [json!({"rect": [40, 20, 120, 120]}), json!({"point": [100, 80]})] { + let e = s.execute("select.object", p.clone()).unwrap_err().to_string(); + assert!(e.contains("active layer is empty") && e.contains("sampleAllLayers"), "{p}: {e}"); + assert!(s.active().unwrap().doc.selection.is_none()); + } + // Sampling all layers finds the disc below. + s.execute("select.object", json!({"rect": [40, 20, 120, 120], "sampleAllLayers": true})).unwrap(); + assert!(sel_iou(&s, 200, 160, &inside) > 0.95); + // A layer with pixels elsewhere: the rectangle holds none of them. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("paint.pencil", json!({"points": [[5, 5], [10, 5]], "size": 2, "color": "#000000"})).unwrap(); + let e = s.execute("select.object", json!({"rect": [60, 60, 100, 80]})).unwrap_err().to_string(); + assert!(e.contains("no pixels in that area"), "{e}"); + } + #[test] fn select_subject_centred_object() { let (mut s, inside) = disc(8); From 7e28900df31a6a64d16b9d252cfd7551b57b7352 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 07:59:09 +0200 Subject: [PATCH 031/167] layer.setProps: refuse opacity and fill outside 0..1 (#5242) (#5291) Opacity and Fill are fractions in layer.setProps but percentages in the Layer Style commands, and a value above 1 was clamped silently: 55 gave an opaque layer. Out-of-range or non-numeric values now fail with an error that names the range, and the layer is left alone. --- crates/engine/src/commands.rs | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 318b105d12..46361d9513 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -681,6 +681,16 @@ fn build() -> Vec { has_layer, |s, p| { let id = layer_param(s, p)?; + // Opacity and Fill are fractions here, while the Layer Style commands take + // percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242). + let unit = |key: &str| -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + match v.as_f64() { + Some(x) if (0.0..=1.0).contains(&x) => Ok(Some(x as f32)), + _ => Err(bad("layer.setProps", format!("{key} must be a number in 0..1 (1 = 100 %), got {v}"))), + } + }; + let (opacity, fill) = (unit("opacity")?, unit("fill")?); let only_visibility = p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce"))); let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" }; @@ -702,11 +712,11 @@ fn build() -> Vec { if let Some(v) = p.get("visible").and_then(Value::as_bool) { l.visible = v; } - if let Some(v) = p.get("opacity").and_then(Value::as_f64) { - l.opacity = (v as f32).clamp(0.0, 1.0); + if let Some(v) = opacity { + l.opacity = v; } - if let Some(v) = p.get("fill").and_then(Value::as_f64) { - l.fill_opacity = (v as f32).clamp(0.0, 1.0); + if let Some(v) = fill { + l.fill_opacity = v; } if let Some(v) = p.get("blend").and_then(Value::as_str) { l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; @@ -1554,3 +1564,30 @@ mod registry_tests { assert_eq!(registry.specs.len(), 2); } } + +#[cfg(test)] +mod set_props_tests { + use super::*; + + #[test] + fn opacity_and_fill_outside_zero_to_one_are_refused_not_clamped() { + // #5242: `55` (a percentage, as the Layer Style commands take) used to become 100 %. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.setProps", json!({"opacity": 0.55, "fill": 0.4})).unwrap(); + let layer = |s: &Session| { + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + (l.opacity, l.fill_opacity) + }; + assert_eq!(layer(&s), (0.55, 0.4)); + for bad in [json!({"opacity": 55}), json!({"opacity": -0.1}), json!({"fill": 1.5}), json!({"opacity": "50%"}), json!({"opacity": 0.5, "fill": 40})] { + let e = s.execute("layer.setProps", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("0..1"), "{bad}: {e}"); + assert_eq!(layer(&s), (0.55, 0.4), "{bad} left the layer alone"); + } + s.execute("layer.setProps", json!({"opacity": 0, "fill": 1})).unwrap(); + assert_eq!(layer(&s), (0.0, 1.0)); + } +} From 7a0e60c1047567e37b4693aa182578f63f9a43b6 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:59:13 -0400 Subject: [PATCH 032/167] test(engine): add Color Range fuzziness 0 exact-match test (TOOL-214-3) (#3581) * test(engine): add Color Range fuzziness 0 exact-match test (#214) Adds an explicit test for Sampled Colors at Fuzziness 0 (point sampling and hex colors) asserting that exact matches are fully selected and adjacent shades are rejected. Updates scorecard TOOL-214-3 to done. * docs: regenerate scorecard (TOOL-214-3 done) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017WeVmjnnPPKdxVTpr2Vjtw --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/selection_cmds.rs | 39 +++++++++++++++++++++++++++++ docs/scorecard.md | 4 +-- scorecard/tools.toml | 4 +-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index 74de286f04..be0639418d 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -1004,6 +1004,45 @@ mod tests { assert!(lighter > redder + 0.15 && redder > 0.0, "lighter {lighter}, redder {redder}"); } + #[test] + fn color_range_fuzziness_zero_selects_exact_match_only() { + // Sampled Colors at Fuzziness 0 selects only the exact match (TOOL-214-3, #214). + let mut s = Session::new(); + s.execute("file.new", json!({"width": 50, "height": 10})).unwrap(); + s.edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + let v = |c: u8| f32::from(c) / 255.0; + // Exact target color [200, 140, 120] + bg.fill_rect(Rect::new(0, 0, 10, 10), &[v(200), v(140), v(120), 1.0]); + // Off by 1 level in red [201, 140, 120] + bg.fill_rect(Rect::new(10, 0, 20, 10), &[v(201), v(140), v(120), 1.0]); + // Off by 1 level in green [200, 141, 120] + bg.fill_rect(Rect::new(20, 0, 30, 10), &[v(200), v(141), v(120), 1.0]); + // Off by 2 levels in blue [200, 140, 122] (crosses 8-bit Lab grid threshold) + bg.fill_rect(Rect::new(30, 0, 40, 10), &[v(200), v(140), v(122), 1.0]); + // Exact target color again at a different position + bg.fill_rect(Rect::new(40, 0, 50, 10), &[v(200), v(140), v(120), 1.0]); + Ok(()) + }) + .unwrap(); + + // Sampled via eyedropper point at [5, 5] with Fuzziness 0 + s.execute("select.colorRange", json!({"points": [[5, 5]], "fuzziness": 0})).unwrap(); + assert_eq!(coverage(&s, 5, 5), 1.0, "sampled pixel itself is fully selected"); + assert_eq!(coverage(&s, 45, 5), 1.0, "identical color elsewhere on canvas is fully selected"); + assert_eq!(coverage(&s, 15, 5), 0.0, "1 level off in red is unselected at fuzziness 0"); + assert_eq!(coverage(&s, 25, 5), 0.0, "1 level off in green is unselected at fuzziness 0"); + assert_eq!(coverage(&s, 35, 5), 0.0, "1 level off in blue is unselected at fuzziness 0"); + + // Sampled via hex color with Fuzziness 0 + s.execute("select.colorRange", json!({"color": "#c88c78", "fuzziness": 0})).unwrap(); + assert_eq!(coverage(&s, 5, 5), 1.0, "exact color match from hex is fully selected"); + assert_eq!(coverage(&s, 45, 5), 1.0, "identical color elsewhere on canvas is fully selected"); + assert_eq!(coverage(&s, 15, 5), 0.0, "1 level off in red is unselected for hex at fuzziness 0"); + assert_eq!(coverage(&s, 25, 5), 0.0, "1 level off in green is unselected for hex at fuzziness 0"); + assert_eq!(coverage(&s, 35, 5), 0.0, "1 level off in blue is unselected for hex at fuzziness 0"); + } + /// Red squares (from `session`), a blue square, a black and a mid-grey strip on white. fn session_colours() -> Session { let mut s = session(); diff --git a/docs/scorecard.md b/docs/scorecard.md index 92f9315ebd..56a16654bd 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,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) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | -| [Tools](#tools) | 28 | 14 | 1 | | +| [Tools](#tools) | 29 | 13 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -147,7 +147,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | | TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | -| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) | +| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open | | TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas_tool_menu.rs TRANSFORM_MENU: modes, Content-Aware Scale / Puppet Warp greyed, Rotate 180 / 90 CW / 90 CCW and Flip turn the open box (transform_tool::intercept, #2832) | | TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | done | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs build_mesh: traced outline of the opaque region grown by Expansion, triangulated with a Density-spaced lattice (conforming Delaunay); separate shapes get separate meshes (#3105) | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index cbff50e333..1123c6aab6 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -127,9 +127,9 @@ note = "canvas.rs marquee_corners: Shift square or circle, Alt from the centre, [[item]] id = "TOOL-214-3" target = "Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only" -status = "partial" +status = "done" issue = 214 -note = "Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447)" +note = "color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets" [[item]] id = "TOOL-214-4" From c0cb78090b694241a88fd62b6f47ed652d82fd8a Mon Sep 17 00:00:00 2001 From: prekabreki <133610573+prekabreki@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:59:18 +0200 Subject: [PATCH 033/167] panic_hunt: count panics that Session::execute contains (#4982) (#5219) Session::execute catches a command's panic and returns an ordinary Err (the last-resort guard in jobs.rs), so the unwind never reached panic_hunt's own catch_unwind and a panicking command counted as a pass. The harness now installs a panic hook that counts every panic in the process, including on rayon workers, and a run fails if the count moved. Runs are serialised so a panic is charged to the run that caused it. No production code changes. Test: contained_panics_count_and_graceful_errors_do_not. A contained panic fails the run and a graceful Err passes; the first assertion fails without the hook. The full --ignored fuzz stays green, so no command panics today. Co-authored-by: prekabreki Co-authored-by: Claude Opus 5.5 --- crates/engine/tests/panic_hunt.rs | 59 +++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index fc68b21164..893ba5c23d 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -5,7 +5,8 @@ use photocraft_engine::{Session, command_specs}; use serde_json::{Value, json}; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::mpsc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, Once, PoisonError, mpsc}; use std::time::Duration; fn fresh() -> Session { @@ -22,19 +23,41 @@ fn fresh() -> Session { /// 0.3 s even on a heavily loaded machine, so this only catches real hangs. const DEADLINE: Duration = Duration::from_secs(4); -/// Run `id` with `p` on a fresh session, on a worker thread. `None` if it timed out, else whether -/// it finished without panicking (returning Ok or a graceful Err both count). -fn run_once(id: &str, p: &Value) -> Option { - let (tx, rx) = mpsc::channel(); - let (id, p) = (id.to_string(), p.clone()); - std::thread::spawn(move || { - let r = catch_unwind(AssertUnwindSafe(|| { - let mut s = fresh(); - let _ = s.execute(&id, p); +/// Panics anywhere in the process, counted by a hook. `Session::execute` contains a command's +/// panic and returns an ordinary `Err`, so the unwind never reaches `catch_unwind` here; the hook +/// still sees it, on whichever thread (rayon workers included) it happened (#4982). +static PANICS: AtomicUsize = AtomicUsize::new(0); +/// One run at a time, so a panic is charged to the run that caused it. +static RUN: Mutex<()> = Mutex::new(()); + +/// Run `f` on a worker thread. `None` if it timed out, else whether it finished without +/// panicking, contained or not (returning Ok or a graceful Err both count). +fn run_watched(f: impl FnOnce() + Send + 'static) -> Option { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + PANICS.fetch_add(1, Ordering::SeqCst); + default(info); })); + }); + let _one = RUN.lock().unwrap_or_else(PoisonError::into_inner); + let before = PANICS.load(Ordering::SeqCst); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let r = catch_unwind(AssertUnwindSafe(f)); let _ = tx.send(r.is_ok()); }); - rx.recv_timeout(DEADLINE).ok() + rx.recv_timeout(DEADLINE).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) +} + +/// Run `id` with `p` on a fresh session; see [`run_watched`]. +fn run_once(id: &str, p: &Value) -> Option { + let (id, p) = (id.to_string(), p.clone()); + run_watched(move || { + let mut s = fresh(); + let _ = s.execute(&id, p); + }) } /// `Err` with a reason on panic or hang, `Ok` otherwise. @@ -80,3 +103,17 @@ fn no_command_panics_or_hangs_on_adversarial_params() { } assert!(bad.is_empty(), "{} commands panicked or hung:\n{}", bad.len(), bad.join("\n")); } + +/// #4982: a panic the code under test contains still fails the run; a graceful `Err` does not. +#[test] +fn contained_panics_count_and_graceful_errors_do_not() { + let contained = run_watched(|| { + let _ = catch_unwind(|| panic!("contained, as Session::execute does")); + }); + assert_eq!(contained, Some(false)); + let graceful = run_watched(|| { + let mut s = fresh(); + assert!(s.execute("layer.delete", json!({"layer": 999999})).is_err()); + }); + assert_eq!(graceful, Some(true)); +} From c7366b8938cf722af8e098bea25c8cc55abb4f37 Mon Sep 17 00:00:00 2001 From: Deeev201 Date: Wed, 14 Oct 2026 18:59:23 +1300 Subject: [PATCH 034/167] Drop shadow: knocked out beneath the whole shape when the layer's mode blends with it (#5304) Layer Knocks Out Drop Shadow only knocked the shadow out through see-through fill: at 100 % fill the layer was taken to cover it. That holds in Normal mode, but a layer in Exclusion, Difference, Multiply, Screen and the rest blends with what's beneath it, shadow and all: a white Exclusion bar with a drop shadow over black type came out as a blank white bar instead of white type on black (Photoshop's merged image of the same file shows the shadow kept out from under the bar), and a Multiply layer was darkened by its own shadow. shadow_see_through() now knocks it out wholly in those modes, for the CPU compositor and the GPU plan alike. Co-authored-by: Daniel van Lith Co-authored-by: Claude Opus 5.5 --- crates/compose/src/effects.rs | 21 ++++++++++++++++----- crates/compose/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/gpu/src/plan.rs | 5 +++-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 6966254547..5acdd79314 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -13,7 +13,8 @@ //! Interior effects are painted relative to the layer's shape and then take the shape's alpha //! (an overlay recolours a half-transparent edge without adding coverage). The outside parts //! of strokes blend onto the backdrop with their own modes, an upper stroke covering the -//! lower ones. Drop shadows are knocked out only through see-through fill. +//! lower ones. Drop shadows are knocked out through see-through fill, and beneath the whole shape +//! when the layer's mode lets what's under it show through (see [`shadow_see_through`]). //! //! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows / //! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons, @@ -1850,6 +1851,16 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) { } } +/// How far the layer lets a knocked-out drop shadow show beneath its own shape (Layer Knocks Out +/// Drop Shadow), 0 = covered, 1 = knocked out wholly. In a normal mode an opaque layer covers the +/// shadow anyway, so only see-through fill knocks it out (and anti-aliased edges are not +/// attenuated twice). In any other mode the layer blends with what's beneath it, shadow and all: +/// an Exclusion or Difference layer would invert it, Multiply darken by it, Screen show it as +/// grey. Photoshop keeps it out from under the shape, so here it's knocked out wholly. +pub fn shadow_see_through(layer: &Layer) -> f32 { + if matches!(layer.blend, BlendMode::Normal | BlendMode::Dissolve | BlendMode::PassThrough) { 1.0 - layer.fill_opacity.clamp(0.0, 1.0) } else { 1.0 } +} + /// 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 @@ -1916,10 +1927,10 @@ pub(crate) fn composite_with_effects_prepared( if let Effect::DropShadow(s) = e { let mut m = fx(i, 0); if s.knocks_out { - // The layer hides the shadow beneath it only where its fill is see-through: at - // 100 % fill the layer covers it anyway (and anti-aliased edges are not - // attenuated twice), at 0 % the shape shows the bare backdrop. - knock_out_exterior(&mut m, &shape, layer.fill_opacity); + // The layer hides the shadow beneath it where its fill is see-through (at 100 % + // fill in a normal mode it covers it anyway; at 0 % the shape shows the bare + // backdrop), and wholly in a mode that blends with it ([`shadow_see_through`]). + knock_out_exterior(&mut m, &shape, 1.0 - shadow_see_through(layer)); } paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f9e49ac04e..778d9cf29a 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1473,6 +1473,36 @@ fn shadow_knockout_needs_see_through_fill() { assert!(close4(on, px(&base, 10, 20)), "{on:?}"); } +#[test] +fn shadow_knockout_beneath_a_layer_in_a_blending_mode() { + // Layer Knocks Out Drop Shadow keeps the shadow out from under the layer's shape. In Normal + // mode an opaque layer covers it anyway; in a mode that blends with what's beneath, the shadow + // would show through: under a white Exclusion square on white, it read as white (white over + // the black shadow) instead of black (white over white), so a logo's Exclusion bar with a + // drop shadow came out blank. A red Multiply square came out black instead of red. + for (blend, colour, inside, depth) in [ + (photocraft_color::BlendMode::Exclusion, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]), + (photocraft_color::BlendMode::Multiply, [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]), + ] + .into_iter() + .flat_map(|c| SampleType::ALL.map(|depth| (c, depth))) + .map(|((b, c, i), depth)| (b, c, i, depth)) + { + let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, depth); + d.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4])); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), colour); + l.blend = blend; + l.effects.items = vec![Effect::DropShadow(shadow(0.0, 90.0))]; + d.layers.push(l); + assert!(close4(px(&d, 20, 20), inside), "{blend:?} {depth:?}: {:?}", px(&d, 20, 20)); + // Not knocked out, the shadow shows through the blend, as in Photoshop with the box unticked. + if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] { + s.knocks_out = false; + } + assert!(!close4(px(&d, 20, 20), inside), "{blend:?} {depth:?} without knockout: {:?}", px(&d, 20, 20)); + } +} + #[test] fn linked_pattern_overlay_anchors_at_the_effects_reference_point() { use photocraft_doc::pattern::Pattern; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index b8c083c7af..48abbc0930 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1097,9 +1097,10 @@ impl<'a> Planner<'a> { }; for &(i, e) in &rev { if let Effect::DropShadow(s) = e { - // The layer hides the shadow beneath it where its fill is see-through. + // The layer hides the shadow beneath it where its fill is see-through, and wholly + // in a mode that blends with it (as the CPU renderer: `shadow_see_through`). let flags = if s.knocks_out { F_KNOCKOUT } else { 0 }; - let see_through = 1.0 - layer.fill_opacity.clamp(0.0, 1.0); + let see_through = photocraft_compose::effects::shadow_see_through(layer); w = self.paint_k( w, content, From 7ad75b5db2b113f2dc08fe5807a6b24c4c795482 Mon Sep 17 00:00:00 2001 From: abc2cb Date: Wed, 14 Oct 2026 08:59:27 +0300 Subject: [PATCH 035/167] Layer masks: Reveal Selection drops the selection, as Photoshop does (#5251) A mask made from the selection takes the selection with it in Photoshop: after Layer > Layer Mask > Reveal Selection (the Layers panel's mask button with a selection) the marching ants are gone. PhotoCraft kept the selection, so the usual next step, Invert (Cmd/Ctrl+I) on the new mask, inverted only the part inside the old selection and left the whole mask black, instead of flipping the mask. Hide Selection (the Alt-click variant) already dropped it. Reveal Selection now clears the selection in the same history step as the mask: one undo brings the selection back and removes the mask. Tests: adjust_cmds::tests::reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask (8/16/32-bit). Tests that deselected right after Reveal Selection no longer need to (Deselect is disabled without a selection); layer_mask_depth and the automation agent tasks now expect the selection to be gone. Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/automation/tests/agent_tasks.rs | 4 +-- crates/engine/src/adjust_cmds/tests.rs | 27 +++++++++++++++++-- crates/engine/src/commands.rs | 17 +++++++++++- crates/engine/src/file_cmds/tests.rs | 3 --- crates/engine/src/filters.rs | 1 - crates/engine/src/mask_view_cmds/tests.rs | 1 - .../engine/src/rasterize_style_cmds/tests.rs | 1 - crates/engine/src/selection_cmds.rs | 1 - crates/engine/src/smartselect_cmds.rs | 1 - crates/engine/src/vector_cmds/tests.rs | 1 - crates/engine/tests/layer_mask_depth.rs | 10 +++++-- crates/ui-egui/src/mask_thumbs_ui/tests.rs | 1 - 12 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 271993d2bb..e1585e2040 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -166,7 +166,7 @@ async fn agent_completes_ten_scripted_tasks() { let outside = pixel(&c, 1, 1).await; assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 5: centre is red {inside:?}"); assert!(outside[0] < 0.1, "task 5: corner shows the photo {outside:?}"); - run(&c, "select.deselect", json!({})).await; + assert_eq!(inspect(&c).await["hasSelection"], false, "task 5: the selection became the mask"); // 6. Save a selection to a channel and load it back. run(&c, "select.rect", json!({"x": 4, "y": 6, "width": 20, "height": 10})).await; @@ -413,7 +413,7 @@ async fn agent_completes_ten_more_scripted_tasks() { 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; + assert_eq!(doc["hasSelection"], false, "task 17: the selection became the mask"); // 18. Expand a selection and read its bounds back. run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await; diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 8fa053e86e..9f614732de 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -353,7 +353,6 @@ fn invert_with_the_mask_targeted_inverts_the_mask() { let mut s = session(depth, "rgb"); s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let (pixels, off_canvas) = (rgba(&s, 3, 3), mask_at(&s, -500, 3)); let steps = s.active().unwrap().history.past_len(); s.execute("image.adjustments.invert", json!({"target": "mask"})).unwrap(); @@ -372,6 +371,31 @@ fn invert_with_the_mask_targeted_inverts_the_mask() { } } +/// Photoshop: a mask made from the selection (Reveal Selection, the Layers panel's mask button) +/// takes the selection with it, as Hide Selection does. So ⌘I on the new mask right away inverts +/// all of it; with the selection kept it inverted only the inside, leaving the mask all black. +/// Undo brings the selection back with the mask gone, in one step. +#[test] +fn reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask() { + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap(); + let selection = s.active().unwrap().doc.selection.clone(); + let steps = s.active().unwrap().history.past_len(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "depth {depth}: the selection became the mask"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step"); + assert_eq!((mask_at(&s, 3, 3), mask_at(&s, 30, 3)), (0.0, 1.0)); + s.execute("image.adjustments.invert", json!({"target": "mask"})).unwrap(); + assert_eq!((mask_at(&s, 3, 3), mask_at(&s, 30, 3)), (1.0, 0.0), "depth {depth}: inside and outside both flip"); + s.undo(); + s.undo(); + let st = s.active().unwrap(); + assert_eq!(st.doc.selection, selection, "undo restores the selection"); + assert!(st.doc.layer(st.active_layer.unwrap()).unwrap().mask.is_none(), "and removes the mask"); + } +} + // ---------- Image › Adjustments as smart filters (FILE-215-4) ---------- /// A painted pixel layer converted to a smart object, over the painted Background. @@ -492,7 +516,6 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { s.execute("layer.delete", json!({})).unwrap(); s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let pixels = rgba(&s, 23, 3); s.execute("filter.blur.gaussianBlur", json!({"radius": 3, "target": "mask"})).unwrap(); assert!(mask_at(&s, 23, 3) > 0.0 && mask_at(&s, 23, 3) < 1.0, "the mask's edge is blurred"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 46361d9513..a954eca7e2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -805,7 +805,10 @@ fn build() -> Vec { |s, p| { let sel = s.active().and_then(|d| d.doc.selection.clone()).ok_or(EngineError::Other("no selection".into()))?; let mask = LayerMask { surface: sel, ..LayerMask::reveal_all() }; - set_mask(s, p, "Add Layer Mask", Some(mask)) + // The selection becomes the mask, so Photoshop drops it (as Hide Selection does): a + // following Invert (⌘I) on the targeted mask then inverts all of it, not just the + // part inside the old selection. Same undo step as the mask. + set_mask_and_deselect(s, p, "Add Layer Mask", mask) } ), cmd!("layer.layerMask.delete", "Delete", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask( @@ -1444,6 +1447,15 @@ fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { /// Add (`Some`) or remove (`None`) a layer mask. Adding acts on every selected layer that can take /// one ([`crate::layer_multi_cmds::mask_targets`]), in one history step. fn set_mask(s: &mut Session, p: &Value, label: &str, mask: Option) -> Result { + set_mask_with(s, p, label, mask, false) +} + +/// [`set_mask`] that also drops the selection the mask was made from, in the same history step. +fn set_mask_and_deselect(s: &mut Session, p: &Value, label: &str, mask: LayerMask) -> Result { + set_mask_with(s, p, label, Some(mask), true) +} + +fn set_mask_with(s: &mut Session, p: &Value, label: &str, mask: Option, deselect: bool) -> Result { let ids = if mask.is_some() { crate::layer_multi_cmds::mask_targets(s, p)? } else { vec![layer_param(s, p)?] }; s.edit(label, |doc, _| { let mask = mask.clone().map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; @@ -1453,6 +1465,9 @@ fn set_mask(s: &mut Session, p: &Value, label: &str, mask: Option) -> } doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.mask = mask.clone(); } + if deselect { + doc.selection = None; + } Ok(()) })?; Ok(Value::Null) diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 99ddce15de..9b6567c2a8 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -586,7 +586,6 @@ fn flatten_all_layer_effects_and_masks() { s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 40})).unwrap(); s.execute("select.inverse", json!({})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); assert!(composite(&s, 15, 15)[1] > 0.99 && composite(&s, 15, 15)[0] > 0.99, "white shows through the mask"); let r = s.execute("file.scripts.flattenAllMasks", json!({})).unwrap(); assert_eq!(r["flattened"], 1); @@ -633,7 +632,6 @@ fn flatten_all_layer_effects_keeps_masks() { .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(); @@ -652,7 +650,6 @@ fn flatten_all_layer_effects_keeps_masks() { .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; diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index a9b6564309..9cd6ff4600 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -460,7 +460,6 @@ mod tests { if reveal_selection { s.execute("select.all", json!({})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); } else { s.execute("layer.layerMask.revealAll", json!({})).unwrap(); } diff --git a/crates/engine/src/mask_view_cmds/tests.rs b/crates/engine/src/mask_view_cmds/tests.rs index 33e36b1421..c73ca0d0ab 100644 --- a/crates/engine/src/mask_view_cmds/tests.rs +++ b/crates/engine/src/mask_view_cmds/tests.rs @@ -10,7 +10,6 @@ fn session() -> (Session, u64, u64) { s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); s.execute("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 20})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let st = s.active_mut().unwrap(); st.saved_revision = st.revision; (s, masked, bg) diff --git a/crates/engine/src/rasterize_style_cmds/tests.rs b/crates/engine/src/rasterize_style_cmds/tests.rs index 9b5ef7d359..57db9adbae 100644 --- a/crates/engine/src/rasterize_style_cmds/tests.rs +++ b/crates/engine/src/rasterize_style_cmds/tests.rs @@ -78,7 +78,6 @@ fn fill_opacity_masks_and_layer_opacity_survive_the_bake() { s.execute("layer.setProps", json!({"layer": id, "fill": 0.4, "opacity": 0.7})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 34, "height": 48})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let square = json!({"subpaths": [{"closed": true, "knots": [[4, 4], [60, 4], [60, 26], [4, 26]]}]}); s.execute("layer.vectorMask.add", json!({"layer": id, "path": square})).unwrap(); bake_and_check(&mut s, id, depth); diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index be0639418d..ecc812a51c 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -877,7 +877,6 @@ mod tests { let mut s = session(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.execute("view.layerMask", json!({"mode": "gray"})).unwrap(); s.execute("select.magicWand", json!({"x": 7, "y": 7, "tolerance": 0, "antiAlias": false})).unwrap(); assert_eq!(coverage(&s, 2, 2), 1.0, "gray mask view selects the mask"); diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index ea77e022b0..b535fd1525 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -1007,7 +1007,6 @@ mod tests { fn refine_edge_works_on_layer_mask_without_selection() { let mut s = blurred_edge_selected(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); assert!(s.active().unwrap().doc.selection.is_none()); assert!(s.active().unwrap().doc.layers[0].mask.is_some()); assert!(s.is_enabled("select.refineEdge")); diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 4c2a9a9071..c11de7c813 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -646,7 +646,6 @@ fn rasterizing_a_vector_mask_keeps_what_the_masks_show() { if let Some((feather, density)) = pm { s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 64})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.edit("mask settings", |doc, _| { let m = doc.layer_mut(id).unwrap().mask.as_mut().unwrap(); (m.feather, m.density) = (feather, density); diff --git a/crates/engine/tests/layer_mask_depth.rs b/crates/engine/tests/layer_mask_depth.rs index 082761286c..7093164a5f 100644 --- a/crates/engine/tests/layer_mask_depth.rs +++ b/crates/engine/tests/layer_mask_depth.rs @@ -45,7 +45,12 @@ fn newly_created_layer_masks_follow_document_depth() { assert_eq!(m.value(20, 20), white); if selected { assert_eq!(m.value(0, 0), 0.0); - assert_eq!(d.doc.selection, before.selection, "the selection is unchanged"); + if command == "layer.layerMask.revealSelection" { + // The selection became the mask: Photoshop drops it, as Hide Selection does. + assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); + } else { + assert_eq!(d.doc.selection, before.selection, "the selection is unchanged"); + } } else { assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse"); assert_eq!(m.value(-300_000, 300_000), white); @@ -133,7 +138,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() { before.selection.as_ref().unwrap().read_region(before.bounds()), "{depth}-bit coverage is unchanged" ); - assert_eq!(after.selection, before.selection); + let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() }; + assert_eq!(after.selection, kept, "{command}"); assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask"); } } diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 50ff96f083..eda8224fa0 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -223,7 +223,6 @@ fn dragging_the_mask_thumbnail_to_trash_asks_to_apply_or_delete_it() { s.execute("layer.layerMask.delete", json!({"layer": masked})).unwrap(); s.execute("select.rect", json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({"layer": masked})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let mut h = harness(s, 0, 1.0, 290.0); let drag_mask = |h: &mut Harness<'_, PhotocraftApp>| { let from = recorded(&h.ctx, masked).unwrap().0.into_iter().find(|(k, _)| *k == MaskKind::Pixel).unwrap().1.center(); From ff57499774f65a733413b45a645516476741c43b Mon Sep 17 00:00:00 2001 From: Timothy Daly <100327160+timothydaly1@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:59:32 -0400 Subject: [PATCH 036/167] PSD: Mt16/Mt32 blocks mark the merged image's transparency (#5178) The merged image carries its transparency in the first extra channel when the layer count is stored negative, or when a "Saving Merged Transparency" global block (Mtrn, Mt16, Mt32) is present. Photoshop saves 32-bit documents the second way: a positive layer count, Mt32, and a fourth channel named "Transparency" (psd-tools transparentbg). merged_has_alpha only knew the first, so opening such a file added a "Transparency" alpha channel to the document, and the oracle compared it as if it were opaque white (83.7 % of pixels off; now 0.9 %, all on the shape's edge). Co-authored-by: Claude Opus 5.5 --- crates/io/tests/psd32_export.rs | 33 +++++++++++++++++++++++++++++++++ crates/psd/src/file.rs | 8 ++++++-- docs/file-format-parity.md | 3 ++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/io/tests/psd32_export.rs b/crates/io/tests/psd32_export.rs index cbb4a31524..9ff2b4113b 100644 --- a/crates/io/tests/psd32_export.rs +++ b/crates/io/tests/psd32_export.rs @@ -203,3 +203,36 @@ fn merged_oracle_does_not_treat_extra_channels_as_transparency() { assert_eq!(merged_composite(&file).unwrap(), vec![[2.0, 2.0, 2.0, 1.0]], "{mode:?}"); } } + +// Photoshop saves a transparent 32-bit document with a positive layer count and an `Mt32` +// ("Saving Merged Transparency") global block: the merged image's first extra channel, named +// "Transparency", is its transparency (psd-tools transparentbg.psd). The oracle reads it as the +// composite's alpha, and opening the file doesn't turn it into an alpha channel. +#[test] +fn mt32_marks_the_merged_transparency() { + let mut doc = Document::new("transparent", Size::new(2, 1), ColorMode::Rgb, SampleType::F32); + let fmt = doc.pixel_format(); + let mut layer = photocraft_doc::Layer::raster("shape", fmt); + if let Some(s) = layer.surface_mut() { + s.fill_rect(photocraft_geom::Rect::new(0, 0, 1, 1), &[0.0, 0.0, 0.0, 1.0]); + } + doc.layers.push(layer); + let mut file = PsdFile::from_bytes(&export_psd(&doc)).unwrap(); + file.header.channels = 4; + if let Some(li) = file.layer_info.as_mut() { + li.merged_alpha = false; + } + file.image_data.compression = Compression::Raw; + let plane = |v: [f32; 2]| v.into_iter().flat_map(f32::to_be_bytes).collect::>(); + file.image_data.data = [plane([0.0, 1.0]), plane([0.0, 1.0]), plane([0.0, 1.0]), plane([1.0, 0.0])].concat(); + let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); + file.resources.retain(|r| r.id != 1045); + file.resources.push(photocraft_psd::ImageResource::new(1045, name)); + assert!(!file.merged_has_alpha(), "a positive layer count alone"); + file.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"Mt32", Vec::new())); + assert!(file.merged_has_alpha()); + let merged = merged_composite(&file).unwrap(); + assert_eq!(merged.iter().map(|p| p[3]).collect::>(), vec![1.0, 0.0]); + let opened = import("transparent.psd", &file.to_bytes().unwrap()).unwrap(); + assert!(opened.document.channels.is_empty(), "{:?}", opened.document.channels.iter().map(|c| &c.name).collect::>()); +} diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 1e86665ad8..effaa1fa11 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -272,7 +272,10 @@ impl PsdFile { self.image_data.decode(&self.header) } - /// `true` if the merged image carries a transparency channel. + /// `true` if the merged image carries a transparency channel: its first extra channel, when + /// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`, + /// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive + /// layer count (psd-tools transparentbg.psd). pub fn merged_has_alpha(&self) -> bool { let Some(color) = self.header.color_mode.color_channels() else { return false; @@ -280,8 +283,9 @@ impl PsdFile { if self.header.channels <= color { return false; } + let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); match &self.layer_info { - Some(li) => li.merged_alpha, + Some(li) => li.merged_alpha || marked, None => true, } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cb2ca13acd..590b9febc5 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-13 · **Change:** minor (palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | | 2026-10-12 | minor | DDS textures open, read-only (#1327) | From 74812f4874e7c5dd21a78d18b6ea76b393aad61f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 07:59:38 +0200 Subject: [PATCH 037/167] IO: abr fit and gradient eval check their own bounds (#5218) fit() computed (y + 1) * h in u32, which passes u32::MAX for a plane over 2^32 / max rows; the ABR and pattern parsers' edge caps keep real inputs below that, but the guard lived in another crate. Spans are now computed in u64. gradient_bake::eval indexed stops[0] and stops[i + 1] relying on bake's two-stop check; it now handles an empty or lone stop itself. Same results for every input that worked before. Co-authored-by: Claude Opus 5.5 --- crates/io/src/abr_map.rs | 26 ++++++++++++++++++++++++-- crates/io/src/gradient_bake.rs | 21 +++++++++++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index c21800f13d..53cfd205ff 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -527,10 +527,17 @@ fn fit(w: u32, h: u32, v: Vec, max: u32) -> (u32, u32, Vec) { let k = (w.max(h) as f32 / max as f32).max(1.0); let (nw, nh) = (((w as f32 / k).round() as u32).clamp(1, max), ((h as f32 / k).round() as u32).clamp(1, max)); let mut out = vec![0.0f32; nw as usize * nh as usize]; + // Source rows/columns `[i * n / m, (i + 1) * n / m)` (at least one) for output index `i` of + // `m`; in u64, as `(i + 1) * n` passes u32 for a plane over 2^32 / `max` long. The parsers' + // edge caps keep today's inputs below that, but this function should not depend on them. + let span = |i: u32, n: u32, m: u32| { + let at = |k: u32| usize::try_from(u64::from(k) * u64::from(n) / u64::from(m)).unwrap_or(usize::MAX); + (at(i), at(i + 1).max(at(i).saturating_add(1)).min(n as usize)) + }; for y in 0..nh { - let (y0, y1) = ((y * h / nh) as usize, (((y + 1) * h / nh) as usize).max((y * h / nh) as usize + 1).min(h as usize)); + let (y0, y1) = span(y, h, nh); for x in 0..nw { - let (x0, x1) = ((x * w / nw) as usize, (((x + 1) * w / nw) as usize).max((x * w / nw) as usize + 1).min(w as usize)); + let (x0, x1) = span(x, w, nw); let (mut sum, mut n) = (0.0, 0usize); for yy in y0..y1 { for xx in x0..x1 { @@ -593,3 +600,18 @@ fn pattern_tile(p: &PsdPattern) -> Option { #[cfg(test)] mod tests; + +#[cfg(test)] +mod fit_tests { + use super::fit; + + /// A tall, narrow plane: `(y + 1) * h` passes `u32::MAX` once `h` is over 2^32 / `max` rows. + /// The ABR and pattern parsers cap edges well below that, but `fit` must not rely on it. + #[test] + fn fit_tall_planes_without_overflow() { + let h = 5_000_000u32; + let (w, nh, out) = fit(1, h, vec![1.0; h as usize], 1024); + assert_eq!((w, nh), (1, 1024)); + assert!(out.iter().all(|v| (*v - 1.0).abs() < 1e-6), "every output row averages real input rows"); + } +} diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index e452ae9e1f..0d34816a79 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -106,12 +106,15 @@ fn smooth_ease(u: f32) -> f32 { /// Colour at `t` for sorted stops (positions in 0..1) in `m`'s space. fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32, m: Method) -> [f32; 3] { let n = stops.len(); - if t <= stops[0].0 { - return stops[0].1; + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 3] }; + // A lone stop (even at a NaN position, which neither test below catches) is the colour. + if t <= first.0 || n < 2 { + return first.1; } - if t >= stops[n - 1].0 { - return stops[n - 1].1; + if t >= last.0 { + return last.1; } + // From here `stops[i + 1]` and the tangents' `n - 2` are in range: `i < n - 1` and `n >= 2`. let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap_or(0); let (t0, t1) = (stops[i].0, stops[i + 1].0); let h = (t1 - t0).max(1e-6); @@ -281,4 +284,14 @@ mod tests { let s = bake(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.25], 0.0, Method::Classic); assert!((at(&s, 0.25)[0] - 0.5).abs() < 0.01); } + + /// `eval` guards its own stops instead of relying on `bake`'s two-stop check. + #[test] + fn eval_with_fewer_than_two_stops_does_not_index_out_of_range() { + assert_eq!(eval(&[], &[], 1.0, 0.5, Method::Smooth), [0.0; 3]); + let lone = [(f32::NAN, [0.25, 0.5, 0.75])]; + for m in [Method::Classic, Method::Smooth] { + assert_eq!(eval(&lone, &[], 1.0, 0.5, m), [0.25, 0.5, 0.75]); + } + } } From 706638b2842a330cfc5c305c7860d0b7c9921960 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 07:59:43 +0200 Subject: [PATCH 038/167] Tests: bad-layer-id checks use an id that can't exist (#3343) (#3351) * Tests: bad-layer-id checks use an id that can't exist (#3343) Layer ids come from one process-wide counter (shared with document and smart-object ids, and bumped by ensure_ids_above on file loads). A full `cargo test` run passes ids like 9999, so a layer a test just created can get the "bad" id, the command succeeds and the assertion fails at random (seen on CI in type_extra_cmds::tests::vertical_orientation_renders_columns_inside_the_canvas). Use u64::MAX, which the counter never reaches, for every "no such layer" sentinel in the engine and ui-egui tests. Test-only change. Co-Authored-By: Claude Opus 5.5 * Tests: use u64::MAX for bad-layer-id sentinels added on main since (#3343) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/align_cmds.rs | 2 +- crates/engine/src/automate_cmds/tests.rs | 4 ++-- crates/engine/src/background_cmds.rs | 2 +- crates/engine/src/cutout_cmds.rs | 2 +- crates/engine/src/extra_cmds.rs | 4 ++-- crates/engine/src/fill_key_cmds.rs | 2 +- crates/engine/src/fx_view_cmds.rs | 2 +- crates/engine/src/fx_visibility_cmds.rs | 2 +- crates/engine/src/gradient_fill_cmds/tests.rs | 2 +- crates/engine/src/group_view_cmds.rs | 2 +- crates/engine/src/layer_copy_cmds/tests.rs | 4 ++-- crates/engine/src/layer_menu_cmds/tests.rs | 2 +- crates/engine/src/layer_multi_cmds.rs | 8 ++++---- crates/engine/src/layer_style.rs | 2 +- crates/engine/src/mask_view_cmds/tests.rs | 2 +- crates/engine/src/pattern_fill_cmds.rs | 2 +- crates/engine/src/rasterize_style_cmds/tests.rs | 2 +- crates/engine/src/retouch_cmds/tests.rs | 6 +++--- crates/engine/src/smart_cmds/tests.rs | 2 +- crates/engine/src/smartselect_cmds.rs | 2 +- crates/engine/src/tests.rs | 6 +++--- crates/engine/src/type_caret_cmds.rs | 2 +- crates/engine/src/type_cmds.rs | 2 +- crates/engine/src/type_extra_cmds/tests.rs | 7 ++++--- crates/engine/src/variables_cmds/tests.rs | 2 +- crates/engine/src/vector_cmds/tests.rs | 2 +- crates/engine/tests/eraser_locked.rs | 2 +- crates/engine/tests/explicit_layer_target.rs | 2 +- crates/engine/tests/rasterize_selected_layers.rs | 2 +- crates/engine/tests/selection_layer_commands.rs | 2 +- crates/ui-egui/src/layer_style/color_picker/tests.rs | 2 +- crates/ui-egui/src/mask_thumbs_ui/tests.rs | 2 +- crates/ui-egui/src/rasterize_prompt_tests.rs | 2 +- 33 files changed, 46 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index b54f4b7a73..c7df82902f 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -381,7 +381,7 @@ mod tests { let m = r["aligned"][0]["matrix"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); assert!((m[2] + 6.0).abs() < 1.0 && (m[5] + 5.0).abs() < 1.0, "{r}"); assert!(s.execute("edit.autoAlignLayers", json!({"projection": "sideways"})).is_err()); - assert!(s.execute("edit.autoAlignLayers", json!({"reference": 9999})).is_err()); + assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err()); } #[test] diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index b60dc2940e..67852534c3 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -50,7 +50,7 @@ fn browse_runs_a_script_file() { assert_eq!((d.size.width, d.size.height), (20, 30)); assert!(s.execute("file.scripts.browse", json!({"script": "no.such.command"})).is_err()); // A failing step stops the script and is reported. - let r = s.execute("file.scripts.browse", json!({"steps": [["layer.delete", {"layer": 999_999}], ["image.imageRotation.90cw", {}]]})).unwrap(); + let r = s.execute("file.scripts.browse", json!({"steps": [["layer.delete", {"layer": u64::MAX}], ["image.imageRotation.90cw", {}]]})).unwrap(); assert_eq!(r["results"].as_array().unwrap().len(), 1); } @@ -348,7 +348,7 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { }); s.actions.list.push(Action { name: "Bad".into(), - steps: vec![("layer.delete".into(), json!({"layer": 999_999}))], + steps: vec![("layer.delete".into(), json!({"layer": u64::MAX}))], disabled: Default::default(), ..Default::default() }); diff --git a/crates/engine/src/background_cmds.rs b/crates/engine/src/background_cmds.rs index e733b54809..0c067b3842 100644 --- a/crates/engine/src/background_cmds.rs +++ b/crates/engine/src/background_cmds.rs @@ -233,7 +233,7 @@ mod tests { assert!(s.execute(CMD, json!({"layer": bg.0})).is_err()); // Bad params. let (mut s, [_, a, _]) = session(8); - for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] { + for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": u64::MAX}), json!({"layer": [a.0]})] { assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); } assert!(doc(&s).background().is_none()); diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index d5cc734668..2f6e2b70b5 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -254,7 +254,7 @@ mod tests { let (mut s, _) = disc(8); let before = s.active().unwrap().revision; - assert!(s.execute(CMD, json!({"layer": 999_999})).is_err()); + assert!(s.execute(CMD, json!({"layer": u64::MAX})).is_err()); // 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); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 1f3748d7b9..3a27c99be3 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -1549,7 +1549,7 @@ mod tests { // Layer 1 is already the only visible layer: ⌥-clicking it shows everything. assert_eq!(s.execute("layer.showOnly", json!({})).unwrap()["shownAlone"], false); assert!(doc(&s).layers.iter().all(|l| l.visible)); - assert!(s.execute("layer.showOnly", json!({"layer": 9999})).is_err()); + assert!(s.execute("layer.showOnly", json!({"layer": u64::MAX})).is_err()); assert!(Session::new().execute("layer.showOnly", json!({})).is_err()); } @@ -1624,7 +1624,7 @@ mod tests { 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()); + assert!(s.execute("layer.toggleLockTransparency", json!({"layer": u64::MAX})).is_err()); } #[test] diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index 6b94e6cd47..ce3b478a70 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -264,7 +264,7 @@ mod tests { for id in IDS { assert!(s.execute(id, json!([1, 2])).is_err(), "{id}"); assert!(s.execute(id, json!("x")).is_err(), "{id}"); - assert!(s.execute(id, json!({"layer": 99_999})).is_err(), "{id}"); + assert!(s.execute(id, json!({"layer": u64::MAX})).is_err(), "{id}"); assert!(s.execute(id, json!({"target": {"channel": 42}})).is_err(), "{id}"); } } diff --git a/crates/engine/src/fx_view_cmds.rs b/crates/engine/src/fx_view_cmds.rs index 03241d8405..3598ea56b0 100644 --- a/crates/engine/src/fx_view_cmds.rs +++ b/crates/engine/src/fx_view_cmds.rs @@ -118,7 +118,7 @@ mod tests { let bg = s.active().unwrap().doc.layers[0].id.0; for p in [ json!({"layer": bg}), - json!({"layer": 9999}), + json!({"layer": u64::MAX}), json!({"layer": "x"}), json!({"layer": -1}), json!({"expanded": "yes", "all": true}), diff --git a/crates/engine/src/fx_visibility_cmds.rs b/crates/engine/src/fx_visibility_cmds.rs index bc24d84b18..7911bcd3cc 100644 --- a/crates/engine/src/fx_visibility_cmds.rs +++ b/crates/engine/src/fx_visibility_cmds.rs @@ -161,7 +161,7 @@ mod tests { let before = state(&s, a); for p in [ json!({"layer": bg}), - json!({"layer": 9999}), + json!({"layer": u64::MAX}), json!({"layer": "x"}), json!({"layer": -1}), json!({"layer": a, "index": 2}), diff --git a/crates/engine/src/gradient_fill_cmds/tests.rs b/crates/engine/src/gradient_fill_cmds/tests.rs index bb357df050..efcdb494d9 100644 --- a/crates/engine/src/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/gradient_fill_cmds/tests.rs @@ -273,7 +273,7 @@ fn commands_fail_gracefully() { // A zero-length drag still makes a valid gradient. s.execute(CREATE, json!({"from": [3, 3], "to": [3, 3]})).unwrap(); for p in [ - json!({"layer": 999_999}), + json!({"layer": u64::MAX}), json!({"from": [0]}), json!({"to": [1e30, 0]}), json!({"angle": "x"}), diff --git a/crates/engine/src/group_view_cmds.rs b/crates/engine/src/group_view_cmds.rs index 32a22f5e39..b95acff6a7 100644 --- a/crates/engine/src/group_view_cmds.rs +++ b/crates/engine/src/group_view_cmds.rs @@ -142,7 +142,7 @@ mod tests { let bg = s.active().unwrap().doc.layers[0].id.0; for p in [ json!({"layer": bg}), - json!({"layer": 9999}), + json!({"layer": u64::MAX}), json!({"layer": "x"}), json!({"layer": -1}), json!({"expanded": "yes", "all": true}), diff --git a/crates/engine/src/layer_copy_cmds/tests.rs b/crates/engine/src/layer_copy_cmds/tests.rs index a81ebbdc47..93bd7b94ef 100644 --- a/crates/engine/src/layer_copy_cmds/tests.rs +++ b/crates/engine/src/layer_copy_cmds/tests.rs @@ -130,7 +130,7 @@ fn bad_params_and_states_fail_without_changing_anything() { json!({"document": -1}), json!({"document": "1"}), json!({"document": 1, "source": 9}), - json!({"document": 1, "layers": [999_999]}), + json!({"document": 1, "layers": [u64::MAX]}), json!({"document": 1, "layers": "all"}), json!({"document": 1, "layers": []}), json!({"document": 1, "at": [1.0]}), @@ -314,7 +314,7 @@ fn duplicate_destinations_that_are_not_new_fail_cleanly() { json!({"document": []}), json!({"document": {}}), json!({"document": "other"}), - json!({"document": "new", "layer": 999_999}), + json!({"document": "new", "layer": u64::MAX}), ] { let before = (doc(&s, 0).clone(), s.documents().len(), s.active_index()); let err = s.execute("layer.duplicate", p.clone()).unwrap_err().to_string(); diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 708bee5fce..46688500b1 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -588,7 +588,7 @@ fn transfer_effects_moves_or_copies_between_layers() { for bad in [ json!({"from": a.0, "to": a.0}), json!({"from": a.0}), - json!({"from": a.0, "to": 9999}), + json!({"from": a.0, "to": u64::MAX}), json!({"from": a.0, "to": b.0, "effect": 7}), json!({"from": a.0, "to": b.0, "effect": -1}), json!({"from": b.0, "to": a.0}), diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 21e9e2caac..80da3bdd81 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1625,8 +1625,8 @@ mod tests { let all = doc(&s).bounds(); assert!(damage(&s).is_none_or(|r| r.contains_rect(&all)), "{:?}", damage(&s)); // Bad params still fail cleanly. - assert!(s.execute("layer.setProps", json!({"layer": 999_999, "visible": true})).is_err()); - assert!(s.execute("layer.translate", json!({"layer": 999_999, "dx": 1})).is_err()); + assert!(s.execute("layer.setProps", json!({"layer": u64::MAX, "visible": true})).is_err()); + assert!(s.execute("layer.translate", json!({"layer": u64::MAX, "dx": 1})).is_err()); } /// #1771: a new empty layer or group changes no pixels, so it (and undoing or redoing it) @@ -1805,7 +1805,7 @@ mod tests { let mut locked = (*before).clone(); locked.layer_mut(a).unwrap().locks.position = true; assert!(moved(&locked, &ids, 1, 1).is_err()); - assert!(moved(&before, &[LayerId(999_999)], 1, 1).is_err()); + assert!(moved(&before, &[LayerId(u64::MAX)], 1, 1).is_err()); } #[test] @@ -1894,7 +1894,7 @@ mod tests { assert_eq!(s.active().unwrap().doc.layer(a).unwrap().name, "x"); s.execute("layer.showLayers", json!({"layer": a.0})).unwrap(); assert!(s.active().unwrap().doc.layer(a).unwrap().visible); - assert!(s.execute("layer.hideLayers", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.hideLayers", json!({"layer": u64::MAX})).is_err()); assert!(s.execute("layer.hideLayers", json!({})).is_err()); assert!(!s.is_enabled("layer.align.topEdges")); } diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index fbbc949e20..5f7b8f0eee 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -1206,7 +1206,7 @@ mod tests { let layer = s.active().unwrap().active_layer.unwrap().0; s.execute("layer.layerStyle.clear", json!({"layer": layer, "effect": 0})).unwrap(); assert!(matches!(effects(&s)[..], [Effect::ColorOverlay { .. }])); - for bad in [json!({"effect": 5}), json!({"effect": -1}), json!({"effect": "x"}), json!({"effect": 0, "layer": 999_999})] { + for bad in [json!({"effect": 5}), json!({"effect": -1}), json!({"effect": "x"}), json!({"effect": 0, "layer": u64::MAX})] { assert!(s.execute("layer.layerStyle.clear", bad).is_err()); } assert_eq!(effects(&s).len(), 1, "bad params change nothing"); diff --git a/crates/engine/src/mask_view_cmds/tests.rs b/crates/engine/src/mask_view_cmds/tests.rs index c73ca0d0ab..ce9bffa744 100644 --- a/crates/engine/src/mask_view_cmds/tests.rs +++ b/crates/engine/src/mask_view_cmds/tests.rs @@ -95,7 +95,7 @@ fn bad_params_and_no_mask_fail_gracefully() { for p in [ json!({"layer": bg, "mode": "gray"}), json!({"layer": bg}), - json!({"layer": 999_999, "mode": "gray"}), + json!({"layer": u64::MAX, "mode": "gray"}), json!({"layer": "x"}), json!({"layer": -1}), json!({"mode": 3}), diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index daa7e9660f..b9d75e6cb5 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -225,7 +225,7 @@ mod tests { json!([1, 2]), json!({"layer": -1}), json!({"layer": "x"}), - json!({"layer": 999_999}), + json!({"layer": u64::MAX}), json!({"document": 999_999}), json!({"pattern": "nope"}), json!({"pattern": ""}), diff --git a/crates/engine/src/rasterize_style_cmds/tests.rs b/crates/engine/src/rasterize_style_cmds/tests.rs index 57db9adbae..b4391cb6f2 100644 --- a/crates/engine/src/rasterize_style_cmds/tests.rs +++ b/crates/engine/src/rasterize_style_cmds/tests.rs @@ -152,7 +152,7 @@ fn disabled_and_bad_params_fail_gracefully() { let (mut s, id) = scene(8); let bg = doc(&s).layers[0].id.0; assert!(s.execute(ID, json!({"layer": bg})).is_err(), "the Background has no effects"); - assert!(s.execute(ID, json!({"layer": 9999})).is_err(), "no such layer"); + assert!(s.execute(ID, json!({"layer": u64::MAX})).is_err(), "no such layer"); assert!(s.execute(ID, json!({"layer": u64::MAX})).is_err(), "no such layer"); assert_eq!(layer(&s, id).effects.items.len(), 5, "a bad call changes nothing"); s.execute("layer.layerStyle.hideAllEffects", json!({})).unwrap(); diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 9a0ae22f08..e20706857a 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -993,7 +993,7 @@ fn patch_fails_gracefully() { json!({"offset": [0, -30]}), json!({"offset": [10, 0], "target": "mask"}), json!({"offset": [10, 0], "target": {"channel": 7}}), - json!({"offset": [10, 0], "layer": 999}), + json!({"offset": [10, 0], "layer": u64::MAX}), ] { assert!(s.execute("paint.patch", p.clone()).is_err(), "{p}"); } @@ -1269,7 +1269,7 @@ fn content_aware_move_fails_gracefully() { json!({"offset": [0, -30]}), json!({"offset": [10, 0], "target": "mask"}), json!({"offset": [10, 0], "target": {"channel": 7}}), - json!({"offset": [10, 0], "layer": 999}), + json!({"offset": [10, 0], "layer": u64::MAX}), json!({"offset": [10, 0], "scaleX": 0}), json!({"offset": [10, 0], "scaleY": 0.5}), json!({"offset": [10, 0], "scaleX": 1e9}), @@ -1727,7 +1727,7 @@ fn remove_bad_params_are_errors() { json!({"points": []}), json!({"points": "x"}), json!({"points": [[1, 1]], "size": "big", "closeLoops": "yes", "sampleAllLayers": 3}), - json!({"points": [[1, 1]], "layer": 999_999}), + json!({"points": [[1, 1]], "layer": u64::MAX}), ] { let _ = s.execute("paint.remove", p); } diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index a764712c9f..1caaff6ce2 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -243,7 +243,7 @@ fn smart_filter_commands_check_an_explicit_layer_target() { assert_eq!(active(&s), curves, "the active layer stays"); // A target that isn't a smart object with filters is still refused, and the active layer stays. assert!(matches!(s.execute("layer.smartFilter.setParams", json!({"layer": curves, "params": {}})), Err(EngineError::Disabled(..)))); - assert!(s.execute("layer.smartFilter.setParams", json!({"layer": 999_999, "params": {}})).is_err()); + assert!(s.execute("layer.smartFilter.setParams", json!({"layer": u64::MAX, "params": {}})).is_err()); assert_eq!(active(&s), curves); } diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index b535fd1525..380b6a7200 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -720,7 +720,7 @@ mod tests { // 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()); + assert!(s.object_finder.lookup(&doc, Some(LayerId(u64::MAX)), 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); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 08e17bd39c..83458f05a6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -553,7 +553,7 @@ fn clipping_masks_apply_to_every_selected_layer() { // A `layer` param still acts on that layer alone. s.execute("layer.releaseClippingMask", json!({"layer": ids[2]})).unwrap(); assert_eq!(clipped(&s), [false, true, false]); - assert!(s.execute("layer.createClippingMask", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.createClippingMask", json!({"layer": u64::MAX})).is_err()); } #[test] @@ -992,8 +992,8 @@ fn move_to_copy_rejects_bad_params() { for p in [ json!({"layer": a, "copy": true}), json!({"layer": a, "target": "x", "copy": true}), - json!({"layer": a, "target": 9_999_999, "copy": true}), - json!({"layer": 9_999_999, "target": a, "copy": true}), + json!({"layer": a, "target": u64::MAX, "copy": true}), + json!({"layer": u64::MAX, "target": a, "copy": true}), json!({"layer": a, "target": a, "position": "into", "copy": true}), ] { assert!(s.execute("layer.moveTo", p.clone()).is_err(), "{p}"); diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs index fbc9322c62..419d305ee6 100644 --- a/crates/engine/src/type_caret_cmds.rs +++ b/crates/engine/src/type_caret_cmds.rs @@ -482,7 +482,7 @@ mod tests { bad(&mut s, "type.navigate", json!({"layer": id, "index": 9, "move": "end"})); bad(&mut s, "type.navigate", json!({"layer": id, "index": 0, "move": "lineNext", "x": "no"})); bad(&mut s, "type.navigate", json!({"layer": pixel, "index": 0, "move": "start"})); - assert!(matches!(s.execute("type.caret", json!({"layer": 9_999_999, "index": 0})), Err(EngineError::NoLayer(_)))); + assert!(matches!(s.execute("type.caret", json!({"layer": u64::MAX, "index": 0})), Err(EngineError::NoLayer(_)))); // A point on the named layer but outside its text still returns a caret, marked outside. let far = s.execute("type.hitTest", json!({"layer": id, "x": 390, "y": 290})).unwrap(); assert_eq!(far["inside"], false); diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 4bf193b679..d6b92e840c 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -1184,7 +1184,7 @@ mod tests { st.saved_revision = st.revision; } let rev = s.active().unwrap().revision; - let unknown = [(doc_id, LayerId(9999)), (photocraft_doc::DocId(u64::MAX), id)]; + let unknown = [(doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; assert!(s.refresh_type_layers(&[&[(doc_id, id)][..], &unknown].concat()).is_empty()); assert!(text_layer(&s, id.0).cache.is_some()); let st = s.active().unwrap(); diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 9f604eed1b..7386927bb7 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -108,8 +108,9 @@ fn vertical_orientation_renders_columns_inside_the_canvas() { assert!(l.vertical && l.lines.len() == 2); let [x0, y0, x1, y1] = l.bounds().unwrap(); assert!(x1 > 0.0 && x0 < -40.0 && y0 >= -1e-3 && y1 > 60.0, "{:?}", l.bounds()); - // Bad layer ids fail cleanly. - assert!(s.execute("type.orientation.vertical", json!({"layer": 9999})).is_err()); + // Bad layer ids fail cleanly. Not a small number like 9999: ids come from one process-wide + // counter, so in a full test run a layer made above can get it (#3343). + assert!(s.execute("type.orientation.vertical", json!({"layer": u64::MAX})).is_err()); } #[test] @@ -299,7 +300,7 @@ fn type_attaches_to_a_path_and_releases_it() { .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()); + assert!(s.execute("type.attachToPath", json!({"shape": u64::MAX})).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); diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs index 28fc14a36f..c2a30cab68 100644 --- a/crates/engine/src/variables_cmds/tests.rs +++ b/crates/engine/src/variables_cmds/tests.rs @@ -105,7 +105,7 @@ fn listed_pixel_methods_can_be_used_to_redefine_variables() { #[test] fn bad_params_are_errors() { let (mut s, _p, badge, _t) = session(); - assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": 999999, "type": "visibility"}]})).is_err()); + assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": u64::MAX, "type": "visibility"}]})).is_err()); assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": badge.0, "type": "nope"}]})).is_err()); assert!(s.execute("image.applyDataSet", json!({"name": "missing"})).is_err()); } diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index c11de7c813..7e1c871dff 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -924,7 +924,7 @@ fn combine_shapes_fails_gracefully() { let (mut s, a, _) = two_selected_rects(8); let before = doc(&s).clone(); let history = s.active().unwrap().history.past_len(); - let bad_params = [json!({"layer": 999_999}), json!({"layer": a, "subpath": 99}), json!({"subpath": 99}), json!({"tolerance": "x", "layer": 999_999})]; + let bad_params = [json!({"layer": u64::MAX}), json!({"layer": a, "subpath": 99}), json!({"subpath": 99}), json!({"tolerance": "x", "layer": u64::MAX})]; for cmd in [ "layer.combineShapes.unite", "layer.combineShapes.subtractFrontShape", diff --git a/crates/engine/tests/eraser_locked.rs b/crates/engine/tests/eraser_locked.rs index ae160ca277..d7405a7311 100644 --- a/crates/engine/tests/eraser_locked.rs +++ b/crates/engine/tests/eraser_locked.rs @@ -122,7 +122,7 @@ fn eraser_bad_params_fail_gracefully() { assert!(s.execute("paint.stroke", json!({"erase": true})).is_err()); assert!(s.execute("paint.stroke", json!({"erase": true, "points": []})).is_err()); assert!(s.execute("paint.stroke", json!({"erase": true, "points": "x"})).is_err()); - assert!(erase(&mut s, json!({"layer": 99999})).is_err()); + assert!(erase(&mut s, json!({"layer": u64::MAX})).is_err()); assert!(erase(&mut s, json!({"target": "mask"})).is_err(), "no mask"); // Absurd coordinates are refused up front (a stroke that long would run billions of dabs). assert!(erase(&mut s, json!({"points": [[0, 0], [f64::MAX, 0.0]]})).is_err()); diff --git a/crates/engine/tests/explicit_layer_target.rs b/crates/engine/tests/explicit_layer_target.rs index 6479b9c6d7..6d968c410a 100644 --- a/crates/engine/tests/explicit_layer_target.rs +++ b/crates/engine/tests/explicit_layer_target.rs @@ -67,7 +67,7 @@ fn a_named_layer_of_the_wrong_kind_or_that_does_not_exist_is_refused() { let r = s.execute("layer.smartObjects.rasterize", json!({"layer": pixels})); assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}"); // A layer that doesn't exist falls back to the active layer's check, and still fails. - assert!(s.execute("layer.smartObjects.rasterize", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.smartObjects.rasterize", json!({"layer": u64::MAX})).is_err()); assert!(s.execute("layer.smartObjects.rasterize", json!({"layer": "nope"})).is_err()); assert_eq!(s.active().unwrap().doc, before); assert_eq!(active(&s), pixels); diff --git a/crates/engine/tests/rasterize_selected_layers.rs b/crates/engine/tests/rasterize_selected_layers.rs index 40b962aca8..a738b51681 100644 --- a/crates/engine/tests/rasterize_selected_layers.rs +++ b/crates/engine/tests/rasterize_selected_layers.rs @@ -140,6 +140,6 @@ fn bad_targets_fail_gracefully() { } let mut sc = scene(); for id in ["layer.rasterize.layer", "layer.rasterize.shape", "layer.rasterize.smartObject"] { - assert!(sc.s.execute(id, json!({"layer": 9_999_999})).is_err(), "{id}: no such layer"); + assert!(sc.s.execute(id, json!({"layer": u64::MAX})).is_err(), "{id}: no such layer"); } } diff --git a/crates/engine/tests/selection_layer_commands.rs b/crates/engine/tests/selection_layer_commands.rs index 8d144d44de..44f63cf5d2 100644 --- a/crates/engine/tests/selection_layer_commands.rs +++ b/crates/engine/tests/selection_layer_commands.rs @@ -148,6 +148,6 @@ fn bad_targets_fail_gracefully() { } let mut sc = scene(); for id in ["layer.layerMask.revealAll", "layer.layerStyle.pasteLayerStyle", "layer.layerStyle.clear"] { - assert!(sc.s.execute(id, json!({"layer": 9_999_999})).is_err(), "{id}: no such layer"); + assert!(sc.s.execute(id, json!({"layer": u64::MAX})).is_err(), "{id}: no such layer"); } } diff --git a/crates/ui-egui/src/layer_style/color_picker/tests.rs b/crates/ui-egui/src/layer_style/color_picker/tests.rs index 9bfe484573..2d87c81ef8 100644 --- a/crates/ui-egui/src/layer_style/color_picker/tests.rs +++ b/crates/ui-egui/src/layer_style/color_picker/tests.rs @@ -163,7 +163,7 @@ fn stale_targets_and_invalid_colors_never_apply_or_leave_preview() { app.ui.dialog_mut(picker).unwrap().fields.insert(TARGET.into(), json!(null)); } _ => { - app.ui.dialog_mut(parent).unwrap().fields.insert("layer".into(), json!(999999)); + app.ui.dialog_mut(parent).unwrap().fields.insert("layer".into(), json!(u64::MAX)); } } let current_parent = app.ui.dialogs.iter().find(|d| d.id == parent).cloned(); diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index eda8224fa0..163c7cbae8 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -594,7 +594,7 @@ fn mask_view_gestures_fail_gracefully_without_a_mask() { h.run_steps(2); // A stale view (layer gone) draws nothing instead of panicking. h.state_mut().session.active_mut().unwrap().channel_view.layer_mask = Some(photocraft_engine::mask_view_cmds::LayerMaskView { - layer: photocraft_doc::LayerId(987_654), + layer: photocraft_doc::LayerId(u64::MAX), mode: photocraft_engine::mask_view_cmds::MaskViewMode::Gray, }); assert!(canvas_px(&h).is_none()); diff --git a/crates/ui-egui/src/rasterize_prompt_tests.rs b/crates/ui-egui/src/rasterize_prompt_tests.rs index 510723beb6..9e6a10583c 100644 --- a/crates/ui-egui/src/rasterize_prompt_tests.rs +++ b/crates/ui-egui/src/rasterize_prompt_tests.rs @@ -137,7 +137,7 @@ fn a_stale_or_bad_prompt_fails_gracefully() { app.run("layer.rasterize.type", json!({})).unwrap(); assert!(crate::dialogs::confirm(&mut app, id).is_err()); // Garbage fields. - for f in [json!({"__rasterize": "plaid", "layer": 1}), json!({"__rasterize": "type"}), json!({"__rasterize": "type", "layer": 999_999})] { + for f in [json!({"__rasterize": "plaid", "layer": 1}), json!({"__rasterize": "type"}), json!({"__rasterize": "type", "layer": u64::MAX})] { let id = app.ui.open_dialog(crate::state::DialogKind::Command, f.as_object().unwrap().clone()); assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{f}"); } From ed7df56a2455375240959bae6e5273a71d858d84 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:02:34 +0200 Subject: [PATCH 039/167] fix(format): recover autosaves with missing sidecars Discover committed autosave bundles whose JSON sidecar was never published. Rebuild recovery metadata from the readable manifest and retain these bundles during orphan cleanup. Fixes #4967 --- crates/format/src/autosave.rs | 66 +++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 4767413617..06150e60ed 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -199,12 +199,33 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec { if bundle.join(crate::store::MANIFEST).is_file() { out.push(RecoveryEntry { info, bundle }); } + } else if e.file_type().is_ok_and(|ft| ft.is_dir()) + && let Some(entry) = sidecarless_entry(recovery_dir, &p) + { + out.push(entry); } } out.sort_by(|a, b| b.info.saved_at.cmp(&a.info.saved_at).then(a.info.key.cmp(&b.info.key))); out } +/// A committed `.pcraft` bundle (its manifest is written last) whose sidecar was never +/// published, e.g. a crash during a document's first autosave. The metadata is rebuilt from the +/// manifest (read like a load reads it) and its modification time. Incomplete bundles (no +/// readable manifest) and bundles with anything at the sidecar's path are not entries. +fn sidecarless_entry(recovery_dir: &Path, bundle: &Path) -> Option { + let key = bundle.file_name()?.to_str()?.strip_suffix(".pcraft")?; + if key.is_empty() || sanitize(key) != key || std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok() { + return None; + } + let meta = std::fs::symlink_metadata(bundle.join(crate::store::MANIFEST)).ok().filter(|m| m.is_file())?; + let source = crate::store::DirSource { root: bundle.to_path_buf() }; + let manifest = crate::store::read_manifest(&source, &crate::LoadOptions::default()).ok()?; + let saved_at = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs()); + let info = RecoveryInfo { key: key.to_string(), document_name: manifest.document.name, original_path: None, saved_at, revision: 0 }; + Some(RecoveryEntry { info, bundle: bundle.to_path_buf() }) +} + /// Load a recovered document. pub fn recover(entry: &RecoveryEntry) -> Result { crate::load_path(&entry.bundle) @@ -292,8 +313,9 @@ impl RecoveryStore { const ORPHAN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); /// Best-effort removal of recovery data nothing can ever load or clean up: -/// - `.pcraft` bundle folders with no `.json` sidecar (a crash during a document's -/// first autosave: the sidecar is written after the bundle, and `list_recovery` skips them); +/// - `.pcraft` bundle folders with no `.json` sidecar and no readable manifest (a +/// crash in the middle of a document's first autosave). A sidecar-less bundle whose manifest +/// was committed is complete and listed by `list_recovery`, so it is kept; /// - `*.pcsave.tmp` files left by an interrupted atomic write (see [`crate::atomic::temp_name`]) /// that another process created. /// @@ -321,7 +343,8 @@ fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) { // Anything at the sidecar's path (even a broken one) keeps the bundle. let has_sidecar = std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok(); let parts = [path.clone(), path.join("tiles"), path.join("blobs")]; - if !has_sidecar && parts.iter().all(|p| untouched_for(p, min_age, true)) { + // A committed bundle is recoverable work: keep exactly what `list_recovery` lists. + if !has_sidecar && sidecarless_entry(recovery_dir, &path).is_none() && parts.iter().all(|p| untouched_for(p, min_age, true)) { let _ = std::fs::remove_dir_all(&path); } else { bundles.extend(parts); @@ -437,4 +460,41 @@ mod tests { assert_eq!(recover(&entries[0]).unwrap(), *doc); let _ = std::fs::remove_dir_all(&dir); } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_committed_bundle_without_a_sidecar_is_listed_and_survives_the_sweep() { + let dir = std::env::temp_dir().join(format!( + "pcraft-nosidecar-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) + )); + let doc = + Arc::new(Document::new("Lost", photocraft_doc::Size { width: 4, height: 4 }, photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8)); + let saver = Autosaver::new(&dir, "first"); + saver.request(doc.clone(), 1, None, SaveOptions::default()); + assert!(saver.flush().is_some_and(|r| r.is_ok())); + // The crash: the bundle was committed, the sidecar never published. + std::fs::remove_file(dir.join("first.json")).unwrap(); + + let entries = list_recovery(&dir); + assert_eq!(entries.len(), 1, "the committed bundle is discoverable"); + assert_eq!((entries[0].info.key.as_str(), entries[0].info.document_name.as_str()), ("first", "Lost")); + assert_eq!(recover(&entries[0]).unwrap(), *doc); + + sweep_orphans(&dir, std::time::Duration::ZERO); + assert!(dir.join("first.pcraft").is_dir(), "aged cleanup keeps a committed bundle"); + + // Incomplete bundles (no manifest, or one that does not read) are neither listed nor kept. + let partial = dir.join("partial.pcraft"); + std::fs::create_dir_all(partial.join("tiles")).unwrap(); + let broken = dir.join("broken.pcraft"); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write(broken.join("manifest.json"), b"{").unwrap(); + assert_eq!(list_recovery(&dir).len(), 1); + sweep_orphans(&dir, std::time::Duration::ZERO); + assert!(!partial.exists() && !broken.exists()); + assert!(dir.join("first.pcraft").is_dir()); + let _ = std::fs::remove_dir_all(&dir); + } } From 7250e6e29a9248ac17bc0cb20296e03e7cbed86a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 09:21:47 -0400 Subject: [PATCH 040/167] Tests: autosave retry waits for the finished write, not a mid-write listing Since ed7df56a, list_recovery also lists a committed bundle whose sidecar is not yet published (revision 0). The worker commits the manifest before it writes the sidecar, so the retry test could catch the bundle between the two and read revision 0 (seen on windows-latest; 215/300 local runs on Linux under load). Wait until the entry carries the expected revision. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,12 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From c699e3cda69f4852b43c0b191ab0091aaeff9d6a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 10:16:05 -0400 Subject: [PATCH 041/167] Release 0.9.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 e7bfc1112c..54085cf447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4613,7 +4613,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.8.2" +version = "0.9.0" dependencies = [ "arboard", "async-io", @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.8.2" +version = "0.9.0" dependencies = [ "crc32fast", "flate2", @@ -4667,7 +4667,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4680,7 +4680,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.8.2" +version = "0.9.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4702,7 +4702,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4716,7 +4716,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.8.2" +version = "0.9.0" dependencies = [ "moxcms", "rayon", @@ -4724,7 +4724,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.8.2" +version = "0.9.0" dependencies = [ "exr", "flate2", @@ -4745,7 +4745,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-cms", "serde", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.8.2" +version = "0.9.0" dependencies = [ "criterion", "photocraft-color", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4779,7 +4779,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "craft-print", @@ -4814,7 +4814,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "crc32fast", @@ -4835,14 +4835,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.8.2" +version = "0.9.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.8.2" +version = "0.9.0" dependencies = [ "half", "log", @@ -4859,14 +4859,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.8.2" +version = "0.9.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "crc32fast", @@ -4895,14 +4895,14 @@ dependencies = [ [[package]] name = "photocraft-ops" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4913,7 +4913,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4928,7 +4928,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "photocraft-psd", @@ -4939,7 +4939,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4949,7 +4949,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "image", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.8.2" +version = "0.9.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4976,7 +4976,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.8.2" +version = "0.9.0" dependencies = [ "memory-stats", "photocraft-color", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "parley", @@ -5001,7 +5001,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.8.2" +version = "0.9.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5042,7 +5042,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5053,7 +5053,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.8.2" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -8651,7 +8651,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.8.2" +version = "0.9.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2c329d980d..663d268c3a 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.2" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From e34cdcbcca143ccb60bffbad6641697681174d91 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 03:44:47 +1100 Subject: [PATCH 042/167] Keep channel pastes transactional when pixel allocation fails. (#4588) Co-authored-by: Brandon Thomas --- crates/engine/src/channel_clip.rs | 11 +++++++---- crates/engine/src/channel_clip/tests.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index fea1048235..86a49b0be6 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -96,10 +96,13 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); } if !area.is_empty() { - let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = (area.width() as usize).checked_mul(area.height() as usize).ok_or_else(|| EngineError::Other("the channel paste is too large".into()))?; + let mut rgba = crate::allocation::filled(len, [0.0f32; 4], "pasting into a channel")?; src.read_rgba_into(area, &mut rgba); - let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1))); - let mut px = surf.read_region(area); + let weight = limit + .map(|m| crate::allocation::read_region(m, area, "reading the channel paste selection").map(|v| (v, m.format().channels().max(1)))) + .transpose()?; + let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?; for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() { let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]); @@ -107,7 +110,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: *c += (v - *c) * w.clamp(0.0, 1.0); } } - surf.write_region(area, &px); + surf.try_write_region(area, &px)?; surf.prune(); } doc.selection = None; diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index be7e51dfd7..3d89c8f647 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -164,6 +164,25 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() { } } +#[test] +fn channel_paste_allocation_failure_is_transactional() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("edit.copy", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = paste(&mut s, &json!({}), 1, true, None, "Paste"); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + paste(&mut s, &json!({}), 1, true, None, "Paste").unwrap(); + assert!(s.undo()); + assert_eq!(*s.active().unwrap().doc, *before); + } +} + #[test] fn channel_copy_allocation_failure_keeps_clipboard_and_document() { for depth in [8, 16, 32] { From c4bf9eb04526f7c261a1834d24639ee7166d7c48 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 03:44:54 +1100 Subject: [PATCH 043/167] Bound selection sampling buffers and report allocation failures. (#4594) Co-authored-by: Brandon Thomas --- crates/engine/src/selection_cmds.rs | 41 ++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index ecc812a51c..eda55c7d73 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -50,10 +50,17 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; let area = d.doc.bounds(); + // Match the decoders' raster budget before either the layer or composite path allocates. + let len = area.size().area(); + let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { + return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); + } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => { - let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = usize::try_from(len).map_err(|_| EngineError::Other("the canvas is too large to sample for a selection".into()))?; + let mut px = crate::allocation::filled(len, [0.0f32; 4], "sampling pixels for a selection")?; surf.read_rgba_into(area, &mut px); Ok((area, px)) } @@ -770,6 +777,38 @@ mod tests { assert!(err.to_string().contains("detectFaces"), "{err}"); } + #[test] + fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { + let mut s = Session::new(); + s.add_document( + photocraft_doc::Document::new( + "large", + photocraft_geom::Size::new(300_000, 300_000), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ), + None, + ); + for all_layers in [false, true] { + assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample")); + } + assert!(!s.active().unwrap().history.can_undo()); + } + + #[test] + fn selection_sampling_allocation_failure_keeps_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = s.execute("select.similar", json!({"sampleAllLayers": false})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + } + #[test] fn modify_rejects_oversized_canvases_before_reading_the_mask() { let mut s = Session::new(); From 93a16218de5d6567e41f7edc909360e0a3a73b10 Mon Sep 17 00:00:00 2001 From: Timothy Daly <100327160+timothydaly1@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:44:59 -0400 Subject: [PATCH 044/167] Gradient Map is driven by the W3C luminosity, as in Photoshop (#5018) Photoshop's Gradient Map picks each pixel's place on the gradient by 0.3 R + 0.59 G + 0.11 B, the luminosity of the blend modes. We used the Rec. 601 grey (0.299, 0.587, 0.114). That is a few thousandths off, but it is enough to land two levels away on steep stretches of a gradient. The photoshop corpus's rgb8 and rgb16 gradient-map.psd now pass (they were 2 levels off on 0.8-1.7% of pixels). Sampling the gradient through a 256- or 1024-entry table didn't help; the weights did. CPU (compose::adjust) and GPU (the shader's lum) both change. Lab documents keep their Gradient Map driven by L, and grey pixels map as before. Photoshop oracles 145 -> 147 of 258; floor 145 -> 147. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/compose/src/adjust.rs | 17 ++++++++++++++++- crates/gpu/src/compose.wgsl | 4 ++-- crates/io/tests/corpus.rs | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/scorecard.md | 4 ++-- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 22a8cf063a..726b375e36 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -204,7 +204,10 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth if p[3] <= 0.0 { continue; } - let mut t = rgb_to_gray([p[0], p[1], p[2]]); + // Driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: photoshop + // corpus rgb8 and rgb16 gradient-map.psd match within a level (2 levels off with + // 0.299, 0.587, 0.114). + let mut t = photocraft_color::blend::lum([p[0], p[1], p[2]]); if *reverse { t = 1.0 - t; } @@ -1525,6 +1528,18 @@ mod tone_tests { } } + #[test] + fn gradient_map_follows_the_w3c_luminosity() { + // Photoshop's Gradient Map is driven by 0.3 R + 0.59 G + 0.11 B (photoshop corpus rgb8 and + // rgb16 gradient-map.psd), not the Rec. 601 grey. + let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; + for (c, t) in [([0.0, 1.0, 0.0], 0.59), ([1.0, 0.0, 0.0], 0.3), ([0.0, 0.0, 1.0], 0.11), ([0.2, 0.6, 0.9], 0.513)] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut b); + assert!((b.px[0][0] - t).abs() < 1e-5, "{c:?}: {}", b.px[0][0]); + } + } + #[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 e9d1b370ca..03a8690032 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -707,8 +707,8 @@ fn adjust(c: vec3) -> vec3 { } return vec3(g); } - case 13: { // Gradient map - var t = gray(c); + case 13: { // Gradient map (driven by the W3C luminosity) + var t = lum(c); if (p0.x > 0.5) { t = 1.0 - t; } var o = vec3(lut(0, t), lut(1, t), lut(2, t)); if (p0.y > 0.5) { o = clamp(o + bayer4(adj_px) / 255.0, vec3(0.0), vec3(1.0)); } diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 776f27f89d..0689c03ea2 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 145, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 147, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 590b9febc5..5229842b6e 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-13 · **Change:** minor (PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 @@ -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; 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 145/258, 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) | +| 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 147/258, 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) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | diff --git a/docs/gaps.md b/docs/gaps.md index c952bac81b..b932848afb 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-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **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 @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 145/258 (56%; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% + oracles 147/258 (57%; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), 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, fixed 2026-10-12), PSB smart object @@ -262,6 +262,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | diff --git a/docs/scorecard.md b/docs/scorecard.md index 56a16654bd..2ee12e28ea 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [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) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 147/256, round trip ≥ 258/256 | | [Tools](#tools) | 29 | 13 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 146 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 145 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 147 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| From d12dc6b7188204889b6bac4d5fca7e1fa24f8af3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:45:05 +0200 Subject: [PATCH 045/167] fix(layers): merging layers keeps pixels that lie outside the canvas (#5067) (#5124) --- crates/engine/src/commands.rs | 2 +- crates/engine/src/layer_multi_cmds.rs | 33 ++++++++++++++++++++++++++- crates/engine/src/pixels.rs | 33 ++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a954eca7e2..38a1b4d7e0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1550,7 +1550,7 @@ fn merge_group(s: &mut Session, id: LayerId) -> Result { 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))); + let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers_uncropped(&tmp, fmt))); merged.id = group.id; merged.opacity = group.opacity; merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend }; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 80da3bdd81..2accc4ea86 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1270,7 +1270,7 @@ fn merge_layers(s: &mut Session) -> Result { let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); let mut merged = Layer::raster(top.name.clone(), fmt); merged.locks = top.locks; - *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); let mid = merged.id; // Hidden selected layers are discarded, as in Photoshop. for id in &ids[..ids.len() - 1] { @@ -2051,6 +2051,37 @@ mod tests { assert!(!s.is_enabled("layer.distribute.leftEdges")); } + #[test] + fn merge_keeps_pixels_outside_the_canvas() { + // #5067: the merged layer was cropped to the 100x80 canvas. + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let m = LayerId(s.execute("layer.mergeLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let ml = doc(&s).layer(m).unwrap(); + assert_eq!(layer_bounds(ml).unwrap(), Rect::new(-30, -20, 140, 120)); + assert_eq!(ml.surface().unwrap().pixel(-10, -5)[3], 1.0); + assert_eq!(ml.surface().unwrap().pixel(130, 110)[3], 1.0); + } + + #[test] + fn merge_down_and_merge_group_keep_pixels_outside_the_canvas() { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + s.execute("layer.mergeDown", json!({"layer": b.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(a).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.mergeGroup", json!({"layer": g.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(g).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + } + #[test] fn merge_selected_into_top_most() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index cde7fa41db..b2641a5c16 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -28,6 +28,36 @@ pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, photocraft_compose::composite_to_surface(solo, area, format, background) } +/// [`composite_layers`] without cropping to the canvas: pixels of the layers that lie outside it are kept +/// (merging layers must not lose them, #5067). +pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::PixelFormat) -> Surface { + let canvas = solo.bounds(); + // The canvas grown to every visible layer's content and effects, so no layer is clipped by `composite_bounds`. + let reach = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| { + let margin = if photocraft_compose::effects::has_effects(layer) { photocraft_compose::effects::margin(layer) } else { 0 }; + let bounds = photocraft_compose::layer_bounds(layer, canvas); + if bounds.is_empty() { bounds } else { bounds.inflate(margin) } + }) + .filter(|bounds| !bounds.is_empty()) + .fold(canvas, |a, b| a.union(&b)); + let area = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| photocraft_compose::composite_bounds(layer, reach).unwrap_or(canvas)) + .filter(|bounds| !bounds.is_empty()) + .reduce(|a, b| a.union(&b)) + .unwrap_or(Rect::EMPTY); + if area.is_empty() { + return Surface::new(format); + } + photocraft_compose::composite_to_surface(solo, area, format, None) +} + /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the /// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale). @@ -339,7 +369,8 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // 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); + // A Background stays canvas-sized; any other layer keeps the pixels outside the canvas (#5067). + let s = if doc.is_background(lower.id) { composite_layers(&tmp, format, None) } else { composite_layers_uncropped(&tmp, format) }; let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s)); merged.id = lower.id; merged.blend = lower.blend; From 1ab33d5113c5df285f1199ea31e84eab85f16288 Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:15:11 +0530 Subject: [PATCH 046/167] Map .atn duplicate-into-new-document (Mk) to layer.duplicate (#5371) * Map .atn duplicate-into-new-document (Mk) to layer.duplicate Photoshop records Layer > Duplicate Layer with Destination: New as a Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy name), verified against a ScriptListener/XTensions export. Import maps it to layer.duplicate {document: new}; export writes it back, so the steps round-trip instead of becoming kept Photoshop steps that fail at playback. A Dplc with a T document reference (another open doc) now imports as a kept step rather than misplaying as a same-doc copy. * Fix Rust formatting in ATN command tests * Fix rustfmt formatting in ATN command tests --- crates/engine/src/atn_cmds.rs | 45 +++++++++++++++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 21 ++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 391c21abd8..ebeda48687 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -983,6 +983,26 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { + // Duplicate layer/group into a new document: `Mk` with a document class, a layer + // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` + // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another + // open document) stays a kept Photoshop step: document indices mean nothing at replay. + if let Some(r) = get_ref(d, "null") + && ref_class(r).as_deref() == Some("Dcmn") + && matches!(target(r), Target::Class) + && let Some(u) = get_ref(d, "Usng") + && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) + && let Some(mut p) = layer_target(u) + { + p.insert("document".into(), "new".into()); + if let Some(n) = get_text(d, "Nm ") { + p.insert("documentName".into(), n.into()); + } + if let Some(n) = get_text(d, "LyrN") { + p.insert("name".into(), n.into()); + } + return step("layer.duplicate", Value::Object(p)); + } 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") { @@ -1117,6 +1137,21 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.duplicate" => { + // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). + // Anything else falls through to the `Dplc` event below. + if jstr(p, "document") != Some("new") { + return None; + } + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + if let Some(n) = jstr(p, "documentName") { + d = d.with("Nm ", text(n)); + } + if let Some(n) = jstr(p, "name") { + d = d.with("LyrN", text(n)); + } + d + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1876,6 +1911,11 @@ 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") => { + // A `T` document reference (duplicate into another open document) has no + // replayable meaning here: keep the step verbatim rather than misplay it. + if get_ref(d, "T ").is_some() { + return Mapped::Unsupported { harmless: false }; + } 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()); @@ -1897,6 +1937,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { + // Duplicate into a new document is a `Mk` (handled above); let it fall through. + if id == "layer.duplicate" && jstr(p, "document") == Some("new") { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -2696,6 +2740,7 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", + "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index bdb839fa30..871300372c 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -212,6 +212,12 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), + // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of + // a recorded Duplicate Layer with Destination: New, plus a named layer). + ("layer.duplicate", json!({"document": "new"})), + ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), + ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), + ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -448,6 +454,17 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + // Duplicate into another open document: the `T` reference means nothing at + // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. + item( + "Dplc", + "Duplicate", + Some( + desc("null") + .with("null", target_ref("Lyr ")) + .with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }])), + ), + ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -457,8 +474,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); - assert_eq!(imp.unsupported.len(), 4); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } From 3888e95e477f5f1e62581d8360057267b4dae9b9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:45:19 +0200 Subject: [PATCH 047/167] Text: non-finite numbers and negative indices from PSD files are sanitized (#4429) (#4433) Co-authored-by: Claude Opus 5.5 --- crates/text/src/engine_data.rs | 3 +- crates/text/src/layout.rs | 2 +- crates/text/src/lib.rs | 2 +- crates/text/src/psd.rs | 76 +++++++++++++++++++++++++--------- crates/text/src/raster.rs | 4 ++ crates/text/src/served.rs | 10 ++++- 6 files changed, 73 insertions(+), 24 deletions(-) diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index c8115c3e1b..a68d093669 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -46,7 +46,8 @@ impl Value { pub fn as_f64(&self) -> Option { match self { Value::Int(i) => Some(*i as f64), - Value::Real(r) => Some(*r), + // `inf`, `nan` and `1e999` parse as reals but are never a valid style number. + Value::Real(r) if r.is_finite() => Some(*r), _ => None, } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 55f9e0a539..e2bd223b3b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -454,7 +454,7 @@ impl Layouter { } pub fn layout(&mut self, fonts: &mut FontDb, t: &TextLayer, dpi: f32) -> TextLayout { - let k = if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; let runs = t.char_runs(); let paras = t.paragraph_runs(); let vertical = t.orientation == Orientation::Vertical; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7909199a6f..ad5e90ae35 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -92,7 +92,7 @@ impl TextEngine { acc += r.len; at < acc })?; - let em = style_run.style.size_pt * if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let em = style_run.style.size_pt * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; if em.is_nan() || em <= 0.0 { return None; } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 3f8ce72902..0092fcf92e 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -105,8 +105,8 @@ fn enum_value(d: &Descriptor, key: &str) -> Option { fn dnum(v: Option<&D>) -> Option { match v { - Some(D::Double(x)) => Some(*x), - Some(D::UnitFloat { value, .. }) => Some(*value), + Some(D::Double(x)) if x.is_finite() => Some(*x), + Some(D::UnitFloat { value, .. }) if value.is_finite() => Some(*value), Some(D::Integer(i)) => Some(f64::from(*i)), _ => None, } @@ -360,7 +360,7 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { }); } } - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; if let Some(e) = engine_data(&t.text) { // Without an `Ornt` key, the engine data's writing direction says it (2 = vertical). if enum_value(&t.text, "Ornt").is_none() && e.path(&["EngineDict", "Rendered", "Shapes", "WritingDirection"]).and_then(E::as_f64) == Some(2.0) { @@ -582,6 +582,8 @@ fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle { let g = |key: &str| lookup(d, base, key); let num = |key: &str| g(key).and_then(E::as_f64); + // An f32 that a huge finite f64 overflowed to infinity is dropped too. + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); let flag = |key: &str| g(key).and_then(E::as_bool); let mut s = CharStyle::default(); if let Some(ps) = g("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)) { @@ -591,18 +593,18 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C s.italic = r.italic; s.postscript_name = Some(ps.clone()); } - if let Some(v) = num("FontSize") { - s.size_pt = v as f32 * k; + if let Some(v) = numk("FontSize", k) { + s.size_pt = v; } if flag("AutoLeading") == Some(false) - && let Some(v) = num("Leading") + && let Some(v) = numk("Leading", k) { - s.leading_pt = Some(v as f32 * k); + s.leading_pt = Some(v); } - s.tracking = num("Tracking").unwrap_or(0.0) as f32; - s.baseline_shift_pt = num("BaselineShift").unwrap_or(0.0) as f32 * k; - s.horizontal_scale = num("HorizontalScale").unwrap_or(1.0) as f32; - s.vertical_scale = num("VerticalScale").unwrap_or(1.0) as f32; + s.tracking = numk("Tracking", 1.0).unwrap_or(0.0); + s.baseline_shift_pt = numk("BaselineShift", k).unwrap_or(0.0); + s.horizontal_scale = numk("HorizontalScale", 1.0).unwrap_or(1.0); + s.vertical_scale = numk("VerticalScale", 1.0).unwrap_or(1.0); s.faux_bold = flag("FauxBold").unwrap_or(false); s.faux_italic = flag("FauxItalic").unwrap_or(false); s.underline = flag("Underline").unwrap_or(false); @@ -615,14 +617,17 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C } } s.kerning = if flag("AutoKerning") == Some(false) { Kerning::Off } else { Kerning::Metrics }; - s.kern = num("Kerning").filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + s.kern = numk("Kerning", 1.0).unwrap_or(0.0); s.caps = match g("FontCaps").and_then(E::as_i64) { Some(1) => Caps::SmallCaps, Some(2) => Caps::AllCaps, _ => Caps::Normal, }; if let Some(c) = g("FillColor") { - let v = arr_f(c.get("Values")); + // Positions matter (alpha, then the channels): a non-finite entry voids the colour. + let v: Vec = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default(); + let n_raw = c.get("Values").and_then(E::as_array).map_or(0, <[E]>::len); + let v = if v.len() == n_raw && v.iter().all(|x| (*x as f32).is_finite()) { v } else { Vec::new() }; let a = v.first().copied().unwrap_or(1.0) as f32; s.color = match v.len() { 5 => Color { mode: ColorMode::Cmyk, c: [v[1] as f32, v[2] as f32, v[3] as f32, v[4] as f32], alpha: a }, @@ -636,6 +641,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { let num = |key: &str| lookup(d, base, key).and_then(E::as_f64); + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); ParagraphStyle { align: match num("Justification").map(|v| v as i64) { Some(1) => TextAlign::Right, @@ -646,12 +652,12 @@ pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { Some(6) => TextAlign::JustifyAll, _ => TextAlign::Left, }, - first_line_indent_pt: num("FirstLineIndent").unwrap_or(0.0) as f32 * k, - start_indent_pt: num("StartIndent").unwrap_or(0.0) as f32 * k, - end_indent_pt: num("EndIndent").unwrap_or(0.0) as f32 * k, - space_before_pt: num("SpaceBefore").unwrap_or(0.0) as f32 * k, - space_after_pt: num("SpaceAfter").unwrap_or(0.0) as f32 * k, - auto_leading: num("AutoLeading").unwrap_or(1.2) as f32, + first_line_indent_pt: numk("FirstLineIndent", k).unwrap_or(0.0), + start_indent_pt: numk("StartIndent", k).unwrap_or(0.0), + end_indent_pt: numk("EndIndent", k).unwrap_or(0.0), + space_before_pt: numk("SpaceBefore", k).unwrap_or(0.0), + space_after_pt: numk("SpaceAfter", k).unwrap_or(0.0), + auto_leading: numk("AutoLeading", 1.0).unwrap_or(1.2), hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false), ..Default::default() } @@ -1089,7 +1095,7 @@ pub fn set_text_index(tysh: &[u8], index: i32) -> Option> { /// Updates (or creates) an EngineData tree for `layer`. pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E { - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; let fresh = || E::Dict(vec![("EngineDict".into(), E::dict()), ("ResourceDict".into(), E::dict())]); // The template comes from the PSD: it may not be a dictionary or may lack `EngineDict`. let mut e = match template { @@ -1334,3 +1340,33 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec let bounds = if warped { [l, t, r, b] } else { [0.0; 4] }; write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } + +#[cfg(test)] +mod nonfinite_tests { + use super::*; + use crate::engine_data::parse; + + #[test] + fn non_finite_style_numbers_fall_back_to_defaults() { + let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap(); + let s = char_style(None, &d, &[], 1.0); + let dflt = CharStyle::default(); + assert_eq!(s.size_pt, dflt.size_pt); + assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0)); + assert_eq!(s.color, Color::BLACK); + // A finite f64 too big for f32 is dropped as well. + let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt); + assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0); + let d = parse(b"<< /FontSize 24.0 >>").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); + } + + #[test] + fn infinite_dpi_does_not_write_a_zero_size() { + let layer = + TextLayer { text: "x".into(), runs: vec![TextRun { len: 1, style: CharStyle { size_pt: 12.0, ..Default::default() } }], ..Default::default() }; + let out = String::from_utf8_lossy(&crate::engine_data::write(&build_engine_data(&layer, None, f32::INFINITY))).into_owned(); + assert!(out.contains("/FontSize 12.0"), "{out}"); + } +} diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index 431d020b23..bca52e38cf 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -88,6 +88,10 @@ impl Coverage { } let (dir, (x0, y0), (x1, y1)) = if p0.1 < p1.1 { (1.0f32, p0, p1) } else { (-1.0f32, p1, p0) }; let (x0, y0, x1, y1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); + // A finite f64 beyond f32 range becomes infinite here. + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return; + } if y1 <= 0.0 || y0 >= h { return; } diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 73c5d7f30b..0198aea97a 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -127,7 +127,7 @@ pub fn language_script(tag: &str) -> Option<&'static str> { /// The Subresource Integrity value (`sha256-`) for a 64-digit hex SHA-256. fn sri(hex: &str) -> Option { - if hex.len() != 64 { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let bytes: Option> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); @@ -444,4 +444,12 @@ mod tests { assert_eq!(language_script(tag), want, "{tag}"); } } + + #[test] + fn sri_rejects_non_hex_digits() { + let ok = "0".repeat(64); + assert!(sri(&ok).is_some()); + let plus = format!("+f{}", "0".repeat(62)); + assert!(sri(&plus).is_none()); + } } From 56c5b355a4cdc8d5cb1d92fe79b34a1b7a36ec3b Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 18:45:24 +0200 Subject: [PATCH 048/167] feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5140) * feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5060) The Elliptical and Single Row/Column Marquee are options of select.rect ("ellipse":true, "line"), but the command-list filters only looked at the id and the label ("Rectangular Selection"), so 'ellipse' and 'marquee' found nothing. Commands can now carry search keywords. command.list / engine.commands rows include them as "keywords", and the three filters (CLI commands --filter, the engine.commands RPC and the MCP command_list) share one matcher over id, label and keywords. select.rect gets the Photoshop marquee names as keywords. * Command list replies: document the optional keywords key The reply-shape test from #3684 pins the exact keys of each row; select.rect now also carries keywords, so the test and docs/control-protocol.md allow that one optional string. --- apps/photocraft-cli/src/lib.rs | 4 +--- apps/photocraft-cli/tests/cli.rs | 5 +++++ crates/automation/src/rpc.rs | 20 +++++++------------- crates/automation/src/server.rs | 7 +++---- crates/automation/tests/mcp.rs | 3 +++ crates/engine/src/commands.rs | 26 ++++++++++++++++++++++++-- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ docs/control-protocol.md | 3 ++- 8 files changed, 77 insertions(+), 23 deletions(-) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index a81cce61ce..b93d4ccc8f 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -541,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R { .map(|v| v.iter().collect::>()) .unwrap_or_default() .into_iter() - .filter(|c| { - needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n)) - }) + .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) .collect(); if a.has("--json") { return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false); diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 231eb2c321..3c28a8196e 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() { // Every flag still works in both forms. let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"])); assert!(out.contains("filter.blur.gaussianBlur"), "{out}"); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + for needle in ["ellipse", "marquee"] { + let (out, _) = ok(bin().args(["commands", "--filter", needle])); + assert!(out.contains("select.rect"), "{needle}: {out}"); + } ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([ "--format=jpg", "--quality", diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 507010b9c8..dc01f0f2b4 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -91,17 +91,9 @@ impl Headless { let all = self.command_list(); Ok(match str_of(&p, "filter").map(str::to_lowercase) { None => all, - Some(n) => Value::Array( - all.as_array() - .into_iter() - .flatten() - .filter(|c| { - let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")); - hay.to_lowercase().contains(&n) - }) - .cloned() - .collect(), - ), + Some(n) => { + Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect()) + } }) } "session.list" => Ok(self.session_list()), @@ -365,7 +357,7 @@ mod tests { /// Pins the reply shape documented in docs/control-protocol.md › Command list replies /// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with - /// exactly these keys, from `engine.commands` (filtered or not) and `command.list`. + /// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`. #[test] fn command_list_replies_are_documented_array_of_objects() { let h = session(); @@ -377,8 +369,10 @@ mod tests { let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}")); assert!(!items.is_empty(), "{name}"); for c in items { - let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect(); + // `keywords` is optional: only commands with other search names carry it (#5060). + let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect(); keys.sort_unstable(); + assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}"); assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}"); assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}"); assert!(c["needsDocument"].is_boolean(), "{c}"); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b31507b005..f115473c3e 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -205,7 +205,7 @@ pub struct SelectParams { #[derive(Debug, Default, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ListParams { - /// Only commands whose id or label contains this text (case-insensitive). + /// Only commands whose id, label or keywords contain this text (case-insensitive). #[serde(default)] pub filter: Option, /// Only commands that can run right now. @@ -821,9 +821,8 @@ impl PhotocraftMcp { .unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default()) .into_iter() .filter(|c| { - let hay = - format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase(); - needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) + needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)) + && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) }) .collect(); Ok(ok_json(&Value::Array(items))) diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index ca34d9f915..7386f3d58c 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -167,6 +167,9 @@ async fn command_list_filters() { let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await); assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all); assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string())); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await); + assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}"); 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"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 38a1b4d7e0..48ce4a9332 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -143,10 +143,32 @@ pub fn command_specs() -> &'static [CommandSpec] { 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!({ + let mut entry = 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, - }) + }); + if let (Some(k), Some(o)) = (search_keywords(c.id), entry.as_object_mut()) { + o.insert("keywords".into(), json!(k)); + } + entry +} + +/// Other names a command is looked for by, beyond its id and label: the Photoshop tools and +/// options it stands for (#5060). `select.rect` is also the Elliptical and Single Row/Column +/// Marquee, which an agent searches for as "ellipse" or "marquee". +pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] = + &[("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")")]; + +/// [`SEARCH_KEYWORDS`] of command `id`, if it has any. +pub fn search_keywords(id: &str) -> Option<&'static str> { + SEARCH_KEYWORDS.iter().find(|(k, _)| *k == id).map(|(_, words)| *words) +} + +/// Does a [`list_entry`] row match a command-list filter (`commands --filter`, `engine.commands`, +/// MCP `command_list`)? Its id, label or keywords contain `needle`, ignoring case. +pub fn list_entry_matches(entry: &Value, needle: &str) -> bool { + let needle = needle.to_lowercase(); + ["id", "label", "keywords"].iter().any(|k| entry.get(*k).and_then(Value::as_str).is_some_and(|v| v.to_lowercase().contains(&needle))) } pub fn find(id: &str) -> Option<&'static CommandSpec> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 83458f05a6..ee7dd3435b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -767,6 +767,38 @@ fn command_list_reports_enablement() { assert_eq!(find("layer.new.layer")["enabled"], false); } +/// The Elliptical and Single Row/Column Marquee are `select.rect` options: a command-list filter +/// for "ellipse" or "marquee" finds it through its keywords (#5060). +#[test] +fn command_list_filter_finds_the_marquees_by_keyword() { + let mut s = Session::new(); + let list = s.execute("command.list", json!({})).unwrap(); + let rows = list.as_array().unwrap(); + let found = |needle: &str| -> Vec { + rows.iter().filter(|c| crate::commands::list_entry_matches(c, needle)).map(|c| c["id"].as_str().unwrap().to_string()).collect() + }; + for needle in ["ellipse", "Elliptical Marquee", "marquee", "single row", "SELECT.RECT"] { + assert!(found(needle).contains(&"select.rect".to_string()), "{needle}: {:?}", found(needle)); + } + let rect = rows.iter().find(|c| c["id"] == "select.rect").unwrap(); + assert!(rect["keywords"].as_str().is_some_and(|k| k.contains("Elliptical Marquee")), "{rect}"); + // Commands without keywords don't get an empty field, and the filter still reads id and label. + assert!(rows.iter().find(|c| c["id"] == "file.new").unwrap().get("keywords").is_none()); + assert_eq!(found("gaussian blur"), ["filter.blur.gaussianBlur"]); + assert!(found("no command is called this").is_empty()); +} + +/// Every keyword entry names a real command, so a renamed id can't leave one behind. +#[test] +fn search_keywords_name_real_commands() { + for (id, words) in crate::commands::SEARCH_KEYWORDS { + assert!(crate::commands::find(id).is_some(), "keywords for unknown command {id}"); + assert!(!words.trim().is_empty(), "{id}"); + } + assert!(crate::commands::search_keywords("select.rect").is_some()); + assert!(crate::commands::search_keywords("no.such.command").is_none()); +} + /// 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() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 771fec92e9..4fa1c60c6e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -196,9 +196,10 @@ Each element describes one command, in registry order: | `enabled` | boolean | Whether the command can run in the current state | | `disabledReason` | string or `null` | Why the command can't run now (`null` when `enabled`) | | `needsDocument` | boolean | `true` when it is off because no document is open (#3664) | +| `keywords` | string, optional | Other names the command is found by (#5060), e.g. `select.rect`'s Elliptical Marquee; only present on commands that have them | Filtering narrows the same array without changing its elements: `serve`'s `engine.commands -{filter}` and the MCP tool's `filter` keep commands whose id or label contains the text +{filter}` and the MCP tool's `filter` keep commands whose id, label or keywords contain the text (case-insensitive), and the MCP tool's `enabled_only` keeps those with `enabled: true`. The desktop control channel's `engine.commands` takes no filter and always returns every command. The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). From 887dcd7ef94e7c91040e39f487433163a6d4b6b4 Mon Sep 17 00:00:00 2001 From: Roberto Dutra <139057693+dutraroberto@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:45:30 -0300 Subject: [PATCH 049/167] Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284) (#5295) * Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284) On Wayland the app opens its window through Xwayland when a pen is attached (#639), and it took any input device whose key bitmap has BTN_TOOL_PEN for a pen. A virtual keyboard made with uinput (RustDesk's, for one) claims every key code, that one included, so the window went through Xwayland on machines with no pen at all. On a fractionally scaled desktop (GNOME 46 at 125%) the X11 window is drawn at the logical size and upscaled by the compositor, so the UI came out blurred even though Auto UI Scale already followed the system's 1.25 (#1237): native Wayland, where winit negotiates wp_fractional_scale_v1 and renders at the physical 3840 px, was never used. A pen now also needs ABS_X and ABS_Y in its absolute-axis bitmap, as libinput asks of a tablet tool; a keyboard has no axes. Regression tests with the virtual keyboard's own bitmaps, a pen beside it, and a pen display. Co-Authored-By: Claude Fable 5.1 * Docs: on a fractionally scaled Wayland desktop, Xwayland is blurred and native Wayland has no file drops (#5284) Say where the trade-off is: a native Wayland window is sharp at 125%, 150% and 175% but takes no dropped files (#386); an Xwayland window takes drops and pen input (#639) but is enlarged by the compositor. List the ways to open through Xwayland and what counts as a pen. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 --- README.md | 2 +- apps/photocraft/src/tablet.rs | 73 ++++++++++++++++++++++++++++++++--- docs/development.md | 2 +- docs/ui-design.md | 9 +++++ 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b272705e40..d1e839323c 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. -On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. +On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). On macOS, the command-line tool comes as `photocraft-cli--macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself: diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index 75c1388f94..fc063d2fbc 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option< #[cfg(any(target_os = "linux", test))] const BTN_TOOL_PEN: u32 = 0x140; -/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line -/// is its key bitmap as hex words of the kernel's `long`, most significant first. +/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports. +#[cfg(any(target_os = "linux", test))] +const ABS_X: u32 = 0x00; +#[cfg(any(target_os = "linux", test))] +const ABS_Y: u32 = 0x01; + +/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose +/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y` +/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard +/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but +/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a +/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639). #[cfg(any(target_os = "linux", test))] pub fn has_pen(devices: &str) -> bool { - let bits = usize::BITS; - let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX); - devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| { - keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1) + devices.split("\n\n").any(|device| { + let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name)); + bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y)) }) } +/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most +/// significant first. Short or malformed bitmaps have no bits set. +#[cfg(any(target_os = "linux", test))] +fn bit_set(bitmap: &str, bit: u32) -> bool { + let bits = usize::BITS; + let word = usize::try_from(bit / bits).unwrap_or(usize::MAX); + bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1) +} + /// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland, /// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another /// X app would outlive a mouse stroke here), so nothing starts. @@ -238,6 +256,49 @@ mod tests { for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] { assert!(!has_pen(keys), "{keys:?}"); } + for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] { + assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}"); + } + } + + /// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has + /// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window + /// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are + /// the device's own, from `/proc/bus/input/devices`. + const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\ + N: Name=\"RustDesk UInput Keyboard\"\n\ + P: Phys=\n\ + S: Sysfs=/devices/virtual/input/input54\n\ + U: Uniq=\n\ + H: Handlers=sysrq rfkill kbd event256 leds \n\ + B: PROP=0\n\ + B: EV=20013\n\ + B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\ + B: MSC=10\n\ + B: LED=7\n"; + + #[test] + fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() { + assert!( + bit_set( + "ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe", + BTN_TOOL_PEN + ), + "the fixture claims the key" + ); + assert!(!has_pen(VIRTUAL_KEYBOARD)); + // Beside a real pen, the pen is still found; the keyboard's key bits never leak into the + // pen's device and the pen's axes never into the keyboard's. + let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}"); + assert!(has_pen(&both)); + let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::>().join("\n"); + assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions"); + // A pen display or pen-enabled touchscreen reports the pen with its own axes. + let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"; + assert!(has_pen(pen_display)); + // Axes without `ABS_X` or `ABS_Y` don't count. + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n")); + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n")); } #[test] diff --git a/docs/development.md b/docs/development.md index 3c539f70bc..f685f653cc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -111,7 +111,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root. Headless CLI modes require the `--automation-read-root` flag | | `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | -| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639) | +| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | diff --git a/docs/ui-design.md b/docs/ui-design.md index 13fd8b99f1..96a389d7f7 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -173,6 +173,15 @@ monitors. A 4K display the system runs at 100% stays at 100% (#1278). The fixed 300%) set an absolute UI scale, for example 200% on an unscaled 4K display. Canvas zoom shortcuts continue to control the document independently of UI scaling. +On a Wayland session with a fractional display scale, the interface is sharp only in a native +Wayland window, where the window is drawn at the display's physical resolution +(`wp_fractional_scale_v1`). Through Xwayland the compositor enlarges a lower-resolution window, so +everything is blurred and looks larger than the chosen scale (#1072). The window opens through +Xwayland when a pen is attached (#639), with Preferences › Performance › Linux display server = +X11, or with `WAYLAND_DISPLAY=` cleared; those are also the ways to get file drag and drop, which +native Wayland lacks (#386). Sharp text or drag and drop: on such a desktop it is one or the other +until winit supports Wayland drops. + Interface → UI Font Size changes interface text independently of UI Scale and canvas zoom. Tiny, Small (the default), Medium and Large use 10/12, 1, 14/12 and 16/12 of the theme's original font sizes, preserving the relative sizes of headings, captions and numeric fields. Apply or OK From 7b8e5034d04b4171da5ea0aa9bb0fe3e397099f3 Mon Sep 17 00:00:00 2001 From: shmuel biller Date: Wed, 14 Oct 2026 12:45:36 -0400 Subject: [PATCH 050/167] Fix/scale marker rollback layer state 4975 (#5391) * fix(engine): restore document targeting on failed compound edits * test(engine): prove failed scale-marker rollback preserves layer targets * fix(engine): resync tool mask target after restoring channel view * test(engine): cover channel and tool-mask rollback after failed compound --- crates/engine/src/analysis_cmds.rs | 22 ++++++++- crates/engine/src/analysis_cmds/tests.rs | 63 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 64af849e59..6ad25951db 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -177,6 +177,12 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> { pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target()); + // An inner command may select a layer that disappears when the compound rolls back. + // Selection, channel targeting and coalescing must roll back with the document (#4975). + let anchor = st.layer_anchor; + let channel_view = st.channel_view.clone(); + let coalesce = st.coalesce.clone(); + let was_clean = !st.is_dirty(); let journal = s.journal.len(); // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so // a failed second step can't leave them pinned to pixels the restored document no longer has. @@ -187,7 +193,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } let st = s.active_mut().ok_or(EngineError::NoDocument)?; st.history = history; - match r { + let result = match r { Ok(v) => { // As in `Session::edit`: undo targets what was selected just before the step (#1356). st.history.set_current_layers(prior); @@ -198,12 +204,26 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } Err(e) => { st.doc = before; + st.active_layer = prior.active; + st.selected_layers = prior.selected; + st.layer_anchor = anchor; + st.channel_view = channel_view; + st.coalesce = coalesce; st.color_samplers = samplers; st.revision += 1; + // A failed compound does not make an otherwise untouched document dirty. + if was_clean { + st.saved_revision = st.revision; + } st.last_damage = None; Err(e) } + }; + if result.is_err() { + // The app-wide tool colours/mask mode follow the restored channel target. + s.sync_tools_mask_target(); } + result } /// Current time as ISO 8601 UTC (`2026-10-01T12:34:56Z`); empty on the web (no clock). diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index e5272d558d..124f9abf1e 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -11,6 +11,69 @@ fn d(s: &Session) -> Arc { s.active().unwrap().doc.clone() } +#[test] +fn failed_compound_restores_layer_target_history_and_journal() { + let mut s = session(80, 60, 8); + let background = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Selected"})).unwrap(); + let selected = s.active().unwrap().active_layer.unwrap(); + { + let st = s.active_mut().unwrap(); + st.selected_layers = vec![background, selected]; + st.layer_anchor = Some(background); + st.coalesce = Some("previous-gesture".into()); + } + + let st = s.active().unwrap(); + let prior_doc = st.doc.clone(); + let prior_history = st.history.past_len(); + let prior_channel_view = st.channel_view.clone(); + let prior_mask_targeted = s.tools.mask_targeted; + let prior_journal = s.journal.len(); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + // Mirrors a scale-bar creation followed by a failed type.create: the + // inner command selects a new layer, which rollback must remove. + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask; + s.sync_tools_mask_target(); + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + + assert!(error.to_string().contains("synthetic second step failed")); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&prior_doc, &st.doc), "the original document is restored"); + assert_eq!(st.history.past_len(), prior_history, "no undo step remains"); + assert_eq!(s.journal.len(), prior_journal, "no rolled-back command is journaled"); + assert_eq!(st.active_layer, Some(selected)); + assert_eq!(st.selected_layers, vec![background, selected]); + assert_eq!(st.layer_anchor, Some(background)); + assert_eq!(st.channel_view, prior_channel_view); + assert_eq!(s.tools.mask_targeted, prior_mask_targeted, "tool mask target follows the restored channel"); + assert_eq!(st.coalesce.as_deref(), Some("previous-gesture")); + assert!(st.doc.layer(selected).is_some()); + + // Subsequent ordinary commands target the original layer, not the + // nonexistent scale bar. + s.execute("layer.setProps", json!({"layer": selected.0, "visible": false})).unwrap(); + assert!(!s.active().unwrap().doc.layer(selected).unwrap().visible); +} + +#[test] +fn failed_compound_preserves_a_clean_document() { + let mut s = session(80, 60, 8); + assert!(!s.active().unwrap().is_dirty()); + let prior_doc = d(&s); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + assert!(error.to_string().contains("synthetic second step failed")); + assert!(Arc::ptr_eq(&prior_doc, &d(&s))); + assert!(!s.active().unwrap().is_dirty(), "a rolled-back edit does not need saving"); +} + #[test] fn record_measurements_refuses_an_area_too_large_to_measure() { // #933: with no selection the whole document is measured, and `width * height` overflowed From 6b9e173477432430a09a5bc4ec7c76fab45f8e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A2=E6=96=87=E5=BD=AC?= <156820648+lu123bing@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:45:41 +0800 Subject: [PATCH 051/167] affinity: decoded layers are admitted against an aggregate pixel budget (#4960) (#5266) The archive limits bound what one import extracts (256 MiB an entry, 1 GiB in total), and one pixel layer is skipped past 64 megapixels, but nothing bounded what the layers materialized out of that cost: each bitmap's declared `BmpW`/`BmpH` sizes its RGBA output and its decode planes, with or without tile data, so a small document with many layers could still allocate without end. `Limits` gains `max_pixel_bytes` (1 GiB by default, the budget the other importers allow decoded pixels). `Reader` admits a layer before `decode` allocates it: the layer's RGBA joins the layers already materialized, and its working planes are counted with them, since every earlier layer is still live while this one decodes. Past the budget the import fails with `Error::Limit`, which `photocraft-io` reports and falls back from to the embedded preview. Tests: a synthetic 300-byte document declaring layers with no tiles at all - one layer inside the budget imports, the second past it is rejected before allocation, and a declared 2048x2048 layer is rejected under a 1 MiB budget. The fuzz targets get a small pixel budget so mutations reach the new rejection path. Co-authored-by: DeepSeek --- crates/affinity/README.md | 6 +- crates/affinity/fuzz/fuzz_targets/preview.rs | 2 +- crates/affinity/fuzz/fuzz_targets/stream.rs | 2 +- crates/affinity/src/container.rs | 7 +- crates/affinity/src/model.rs | 22 +++++- crates/affinity/src/raster.rs | 5 ++ crates/affinity/src/tests.rs | 83 +++++++++++++++++++- 7 files changed, 117 insertions(+), 10 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 4f0f9afe52..3e4e066156 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -132,8 +132,10 @@ test fixtures, not an exporter. ## Safety limits -Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by -default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the +Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import +and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels +are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB +zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve diff --git a/crates/affinity/fuzz/fuzz_targets/preview.rs b/crates/affinity/fuzz/fuzz_targets/preview.rs index 31e2bf973a..63858fe717 100644 --- a/crates/affinity/fuzz/fuzz_targets/preview.rs +++ b/crates/affinity/fuzz/fuzz_targets/preview.rs @@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = photocraft_affinity::container::header(data); let _ = photocraft_affinity::preview(data); - let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/fuzz/fuzz_targets/stream.rs b/crates/affinity/fuzz/fuzz_targets/stream.rs index fd6128a66a..1f3895279f 100644 --- a/crates/affinity/fuzz/fuzz_targets/stream.rs +++ b/crates/affinity/fuzz/fuzz_targets/stream.rs @@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|stream: &[u8]| { let _ = photocraft_affinity::stream::parse(stream); let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None); - let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index b1a60ed430..880b7236d1 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -26,11 +26,16 @@ pub struct Limits { pub max_entry: usize, /// Total bytes extracted over the whole import. pub max_total: usize, + /// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the + /// temporary planes the layer being read decodes through. The entry limits bound what is + /// extracted, not what the layers materialized out of it cost (#4960). + pub max_pixel_bytes: usize, } impl Default for Limits { fn default() -> Self { - Self { max_entry: 256 << 20, max_total: 1 << 30 } + // The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`). + Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 } } } diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 07d0adf788..7fba5a4597 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result { let mut archive = Archive::open(bytes, limits)?; let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; - Reader::new(&s, &mut archive).document() + Reader::new(&s, &mut archive, limits).document() } pub(crate) struct Reader<'s, 'a, 'b> { @@ -298,11 +298,27 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, active: HashSet, + /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so + /// far hold). + pixels_left: usize, } impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() } + fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self { + Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes } + } + + /// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's + /// output, which stays with the document; `planes` is the working set it decodes through and + /// releases. Every layer read so far is still live while this one decodes, so the peak is all + /// three together. + pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> { + let peak = rgba.saturating_add(planes); + if peak > self.pixels_left { + return Err(Error::Limit("decoded pixel layers exceed the pixel budget")); + } + self.pixels_left -= rgba; + Ok(()) } pub(crate) fn warn(&mut self, what: impl Into) { diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 8cdf9b833a..bb1ecc112f 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi return Ok(None); } }; + // A layer is materialized from its declared size, whatever the tiles hold: admit the output + // and the planes it decodes through before either is allocated (#4960). + let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?; + let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?; + r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?; let mut planes = Vec::with_capacity(channels); let mut cache = HashMap::new(); for c in 1..=channels { diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index b28e30f3d6..59ae99044a 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -251,9 +251,9 @@ mod archive { broken[90] ^= 0x55; let mut a = Archive::open(&broken, Limits::default()).unwrap(); assert!(a.read("doc.dat").is_err()); - let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap(); a.read("doc.dat").unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } @@ -459,3 +459,82 @@ mod shapes { assert_eq!(sub.segments[3][2], p(5.0, 20.0)); } } + +/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive +/// extracts; these cover what the decoded layers and their working planes cost, which is declared +/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all. +mod pixels { + use crate::Error; + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::synth::{self, F, Method, tag}; + + /// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles. + fn layer(id: u32, w: i32, h: i32) -> F { + F::Def( + id, + vec![tag(b"Rstr")], + vec![ + (tag(b"Desc"), F::Str(format!("Layer {id}"))), + (tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])), + ], + ) + } + + /// A document whose one page holds a pixel layer per `sizes` entry. + fn document(sizes: &[(i32, i32)]) -> Vec { + let children: Vec = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect(); + let data = synth::stream(&[( + 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, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))], + )]), + )], + ), + )]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) + } + + fn imported_layers(d: &Document) -> usize { + d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count() + } + + /// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two. + const ONE_LAYER: usize = 40 << 10; + + #[test] + fn a_pixel_layer_within_the_budget_imports() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap(); + assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings); + } + + #[test] + fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + // The document is a few hundred bytes: the sizes are in the headers, not the entries. + let bytes = document(&[(64, 64), (64, 64)]); + assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len()); + assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized"); + // Two layers fit once the budget covers their pixels together. + let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() }; + assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2); + } + + #[test] + fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() { + // 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget. + let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() }; + assert!( + matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))), + "16 MiB of declared pixels materialized under a 1 MiB budget" + ); + } +} From b059a6d8b3149af61f8276ee0be6751511603903 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:45:46 -0400 Subject: [PATCH 052/167] ICC parser: checked arithmetic on offsets and counts from the file (#4976) (#5227) Offsets and counts stored in a profile reach u32::MAX, which is usize::MAX on 32-bit targets such as wasm32. Several places added to or multiplied them before any bounds check, so a malformed profile could panic (overflow checks on) or read from a wrapped offset (checks off). - curv: reject an over-long count before computing the element length. - mAB/mBA: the matrix and CLUT read through one bounds-checked slice at the stored offset instead of adding to the offset. - mluc: the record offset is computed with checked arithmetic. - XYZ: one bounds-checked slice instead of off + 4 and off + 8. Co-authored-by: Claude Opus 5.5 --- crates/cms/src/profile.rs | 184 ++++++++++++++++++++++++++++++-------- 1 file changed, 148 insertions(+), 36 deletions(-) diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 99f5710e5d..cdd7d5273a 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -429,10 +429,18 @@ impl<'a> Reader<'a> { Ok(self.u32(off)? as i32 as f64 / 65536.0) } fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> { - Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?]) + let r = Reader { b: self.slice(off, 12)? }; + Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?]) } } +/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not +/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32): +/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976). +fn extent(base: usize, n: usize, size: usize) -> Result { + n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated) +} + fn sig(s: &[u8; 4]) -> u32 { u32::from_be_bytes(*s) } @@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { match r.slice(0, 4)? { b"curv" => { let n = r.u32(8)? as usize; + // Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976). + if n > 1 << 20 { + return Err(CmsError::Invalid("curve too long".into())); + } let len = 12 + n * 2; match n { 0 => Ok((Curve::Identity, len)), 1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)), _ => { - if n > 1 << 20 { - return Err(CmsError::Invalid("curve too long".into())); - } let s = r.slice(12, n * 2)?; let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(); Ok((Curve::Table(t), len)) @@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { return Err(CmsError::Invalid("bad lutAB dimensions".into())); } let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize); - let matrix = |off: usize| -> Result { - let mut m = vec![0.0; 9]; - for (k, v) in m.iter_mut().enumerate() { - *v = r.s15f16(off + k * 4)?; - } - let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?]; - Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) - }; - let clut = |off: usize, cin: usize, cout: usize| -> Result { - let mut grid = Vec::with_capacity(cin); - for k in 0..cin { - let g = r.u8(off + k)? as usize; - if g < 1 { - return Err(CmsError::Invalid("zero grid points".into())); - } - grid.push(g); - } - let prec = r.u8(off + 16)? as usize; - let nodes: u128 = grid.iter().map(|g| *g as u128).product(); - if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { - return Err(CmsError::Invalid("CLUT too large".into())); - } - let total = nodes as usize * cout; - let data: Vec = match prec { - 1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(), - 2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), - }; - Ok(Stage::Clut(Clut::new(grid, cout, data))) - }; + let matrix = |off: usize| lut_ab_matrix(&r, off); + let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout); let mut stages = Vec::new(); if ty == b"mAB " { // A curves → CLUT → M curves → matrix → B curves. @@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { } } +/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_matrix(r: &Reader, off: usize) -> Result { + // One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`. + let e = Reader { b: r.slice(off, 48)? }; + let mut m = vec![0.0; 9]; + for (k, v) in m.iter_mut().enumerate() { + *v = e.s15f16(k * 4)?; + } + let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?]; + Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) +} + +/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result { + // Grid points (16 bytes), precision and padding; the samples follow. + let head = Reader { b: r.slice(off, 20)? }; + let mut grid = Vec::with_capacity(cin); + for k in 0..cin { + let g = head.u8(k)? as usize; + if g < 1 { + return Err(CmsError::Invalid("zero grid points".into())); + } + grid.push(g); + } + let prec = head.u8(16)? as usize; + let nodes: u128 = grid.iter().map(|g| *g as u128).product(); + if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { + return Err(CmsError::Invalid("CLUT too large".into())); + } + let total = nodes as usize * cout; + let samples = extent(off, 20, 1)?; + let data: Vec = match prec { + 1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(), + 2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), + }; + Ok(Stage::Clut(Clut::new(grid, cout, data))) +} + /// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`. fn parse_text(d: &[u8]) -> String { let r = Reader { b: d }; @@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String { let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() }; let mut best: Option<(usize, usize)> = None; for k in 0..(n as usize).min(256) { - let o = 16 + k * size as usize; - let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break }; + // The record size comes from the file: `k * size` can overflow a 32-bit `usize`. + let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break }; + let rec = Reader { b: rec }; + let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break }; if best.is_none() || lang == u16::from_be_bytes(*b"en") { best = Some((off as usize, len as usize)); if lang == u16::from_be_bytes(*b"en") { @@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn be(v: u32) -> [u8; 4] { + v.to_be_bytes() + } + + /// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on + /// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow. + #[test] + fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() { + let bytes = [0u8; 64]; + let r = Reader { b: &bytes }; + for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] { + assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}"); + assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}"); + assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}"); + } + assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated)); + assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated)); + assert_eq!(extent(16, 3, 12), Ok(52)); + } + + #[test] + fn a_curve_count_is_checked_before_its_length_is_computed() { + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into()))); + // Controls: the identity and a two-point table still parse with their lengths. + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(0)); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12)); + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend([0, 0, 0xFF, 0xFF]); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16)); + } + + #[test] + fn lut_ab_elements_at_hostile_offsets_are_rejected() { + // A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT + // offsets in turn point at the end of a 32-bit file. + let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| { + let mut d = b"mAB \0\0\0\0".to_vec(); + d.extend([3, 3, 0, 0]); + for v in [b, matrix, m, clut, a] { + d.extend(be(v)); + } + for _ in 0..3 { + d.extend(b"curv\0\0\0\0"); + d.extend(be(0)); + } + d + }; + assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses"); + let top = u32::MAX - 8; + assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset"); + assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset"); + assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset"); + } + + #[test] + fn multilingual_records_with_a_huge_record_size_give_the_first_record() { + let record = |lang: &[u8; 2], len: u32, off: u32| { + let mut r = lang.to_vec(); + r.extend(b"US"); + r.extend(be(len)); + r.extend(be(off)); + r + }; + // Two records of the standard 12 bytes: the English one wins. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend(be(12)); + d.extend(record(b"de", 4, 40)); + d.extend(record(b"en", 4, 44)); + d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']); + assert_eq!(parse_text(&d), "EN"); + // A record size of u32::MAX: the second record lies past any file, so the first is used. + d[12..16].copy_from_slice(&be(u32::MAX)); + assert_eq!(parse_text(&d), "DE"); + // No records at all. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + d.extend(be(u32::MAX)); + assert_eq!(parse_text(&d), ""); + } +} From a37fa8171b65e543a12295601974786a21e3bf05 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:45:52 -0400 Subject: [PATCH 053/167] GPU compositor: pattern textures are freed on close, when idle and over a budget (#4971) (#5232) The compositor kept every pattern texture it ever uploaded (RGBA32F, 16 bytes per pixel) until the device was lost: `forget_doc` and `retain_docs` skipped them and no budget applied, so a long session that opened and closed documents with pattern fills or pattern effects only ever grew. Each cached pattern now records the document that last painted with it and the frame it last painted in: - closing a document (`forget_doc`, `retain_docs`) frees its patterns; - a pattern no frame has painted with for 240 frames is dropped; - over PATTERN_BUDGET (256 MiB) the least recently painted go first, never one the current frame paints with. Co-authored-by: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 83 +++++++++++++++++++++++++++++++++++--- crates/gpu/tests/parity.rs | 65 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 223d4ed983..45e07500ba 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -75,6 +75,11 @@ pub const FX_BUDGET: usize = 1536 << 20; pub const PAGE: u32 = 2048; /// GPU memory resident layer pages may hold before the least recently used are evicted. pub const RESIDENT_BUDGET: u64 = 4 << 30; +/// GPU memory pattern textures may hold before the least recently painted are dropped (those the +/// current frame paints with always stay). +pub const PATTERN_BUDGET: u64 = 256 << 20; +/// A pattern texture no frame has painted with for this many frames is dropped. +pub const PATTERN_IDLE_FRAMES: u64 = 240; /// Uploads (bytes) [`Compositor::render`] stages before submitting the work recorded so far, so /// a refresh of a huge document never holds all its uploads in staging memory at once. const FLUSH_BYTES: u64 = 512 << 20; @@ -183,6 +188,12 @@ struct CachedPattern { format: PixelFormat, default_bits: Vec, texture: Tex, + /// The document that last painted with it: closing that document frees the texture (#4971). + doc: DocId, + /// [`Compositor::frame`] when it last painted. + last_used: u64, + /// GPU bytes of the texture (RGBA32F). + bytes: u64, } struct Resident { @@ -423,6 +434,10 @@ pub struct Compositor { /// The colour mode of the document being encoded: patterns paint in it /// (`photocraft_compose::pattern::pattern_rgba`). pattern_mode: photocraft_color::ColorMode, + /// The document being encoded, which owns the patterns it paints with. + pattern_doc: DocId, + /// GPU memory pattern textures may hold ([`PATTERN_BUDGET`] by default). + pattern_budget: u64, /// Texture side limit the paging is planned for (the device's, or smaller in tests). max_dim: u32, /// The device's real texture side limit. @@ -670,6 +685,8 @@ impl Compositor { temps: Vec::new(), patterns: HashMap::new(), pattern_mode: photocraft_color::ColorMode::Rgb, + pattern_doc: DocId(0), + pattern_budget: PATTERN_BUDGET, max_dim: 0, device_max: device.limits().max_texture_dimension_2d, page: PAGE, @@ -791,6 +808,17 @@ impl Compositor { self.residents.values().map(Resident::bytes).sum() } + /// GPU memory held by cached pattern textures (bytes). + pub fn pattern_cache_bytes(&self) -> u64 { + self.patterns.values().map(|p| p.bytes).sum() + } + + /// Set the GPU memory pattern textures may hold (default [`PATTERN_BUDGET`]). It takes effect + /// at the end of the next refresh. + pub fn set_pattern_budget(&mut self, bytes: u64) { + self.pattern_budget = bytes; + } + /// Whether `doc` can be composited on the GPU. Documents and layers larger than the texture /// limit are fine: surfaces are stored in page-sized textures and effect maps of regions /// larger than the limit are built per page cell. @@ -865,14 +893,22 @@ impl Compositor { pub fn forget_doc(&mut self, doc: DocId) { self.residents.retain(|_, r| r.doc != doc); self.fx.retain(|_, e| e.doc != doc); + self.patterns.retain(|_, p| p.doc != doc); self.prefix.forget(doc); } /// [`Self::forget_doc`] for every document `keep` rejects: what the compositor holds for /// documents that were closed, however their canvas entry went (#4529). pub fn retain_docs(&mut self, keep: impl Fn(DocId) -> bool) { - let mut gone: Vec = - self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).filter(|d| !keep(*d)).collect(); + let mut gone: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .filter(|d| !keep(*d)) + .collect(); gone.sort_by_key(|d| d.0); gone.dedup(); for d in gone { @@ -880,9 +916,17 @@ impl Compositor { } } - /// Documents the compositor holds pages, effect maps or a lower-stack composite for. + /// Documents the compositor holds pages, effect maps, pattern textures or a lower-stack + /// composite for. pub fn held_docs(&self) -> Vec { - let mut docs: Vec = self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).collect(); + let mut docs: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .collect(); docs.sort_by_key(|d| d.0); docs.dedup(); docs @@ -994,6 +1038,7 @@ impl Compositor { let plan = plan(doc)?; self.check_fx(doc, &plan)?; self.pattern_mode = doc.pixel_format().mode; + self.pattern_doc = doc.id; let mut stats = Stats { passes: plan.passes.len(), slots: plan.slots, ..Default::default() }; if region.is_empty() { return Ok(stats); @@ -1093,9 +1138,31 @@ impl Compositor { self.fx.retain(|(id, _), e| e.doc != doc.id || e.last_used == frame || live.contains(id)); stats.evicted += self.evict().0; self.temps.retain(|(_, used)| frame.saturating_sub(*used) < 240); + self.evict_patterns(); Ok(stats) } + /// Drop the pattern textures no frame has painted with for [`PATTERN_IDLE_FRAMES`] frames, + /// then the least recently painted ones over the pattern budget. Never one this frame painted + /// with: it would only be uploaded again on the next frame. + fn evict_patterns(&mut self) { + let frame = self.frame; + self.patterns.retain(|_, p| frame.saturating_sub(p.last_used) < PATTERN_IDLE_FRAMES); + let mut total = self.pattern_cache_bytes(); + if total > self.pattern_budget { + let mut lru: Vec<(u64, String, u64)> = + self.patterns.iter().filter(|(_, p)| p.last_used != frame).map(|(k, p)| (p.last_used, k.clone(), p.bytes)).collect(); + lru.sort_unstable(); + for (_, key, bytes) in lru { + if total <= self.pattern_budget { + break; + } + self.patterns.remove(&key); + total = total.saturating_sub(bytes); + } + } + } + /// Keep resident pages within the resident budget and the effect cache within /// its budget, least recently used first. Never drops what the current cell uses, nor /// whole-region effect maps drawn this frame. Returns how many textures were dropped and @@ -1579,11 +1646,14 @@ impl Compositor { let format = pat.surface.format(); // Unstored tiles read the default pixel, interpreted in the source format. let default_bits: Vec = pat.surface.default_pixel().iter().map(|v| v.to_bits()).collect(); - if let Some(cached) = self.patterns.get(&key) + let (doc, last_used) = (self.pattern_doc, self.frame); + if let Some(cached) = self.patterns.get_mut(&key) && cached.fingerprint == fp && cached.format == format && cached.default_bits == default_bits { + cached.doc = doc; + cached.last_used = last_used; return cached.texture.view.clone(); } let (w, h) = (pat.width, pat.height); @@ -1597,7 +1667,8 @@ impl Compositor { wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); let v = t.view.clone(); - self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t }); + let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16; + self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes }); v } diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index c61aee9b67..44836c233f 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -2135,6 +2135,71 @@ fn pattern_cache_tracks_default_pixels() { } } +/// A document that paints one `side` x `side` pattern named `name`. +fn pattern_fill_doc(name: &str, side: u32) -> Document { + let mut d = Document::new(name, Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + let pat = Pattern::new(name, photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]), side, side); + d.layers.push(Layer::new( + "pattern", + LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); + d.patterns.push(pat); + d +} + +/// #4971: a pattern texture belongs to the document that last painted with it, and closing that +/// document frees it. Before, pattern textures were kept until the device was lost. +#[test] +fn closed_documents_release_their_pattern_textures() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let one = 4 * 4 * 16; + assert_eq!(g.comp.pattern_cache_bytes(), 0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA32F texture"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), 2 * one); + g.comp.forget_doc(a.id); + assert_eq!(g.comp.pattern_cache_bytes(), one, "closing a document frees its pattern"); + assert_eq!(g.comp.held_docs(), vec![b.id]); + g.comp.retain_docs(|_| false); + assert_eq!(g.comp.pattern_cache_bytes(), 0); + check(&mut g, &a, "the pattern is uploaded again after being forgotten"); +} + +/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and +/// by age. What the current frame paints with is never dropped. +#[test] +fn pattern_textures_are_evicted_over_budget_and_when_idle() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let mut plain = base_doc(8, 6); + plain.id = photocraft_doc::DocId(a.id.0 + 2); + let one = 4 * 4 * 16; + // Room for one pattern and a half: painting the second drops the first. + g.comp.set_pattern_budget(one + one / 2); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "the least recently painted pattern went"); + // A budget below a single pattern still keeps the one being painted. + g.comp.set_pattern_budget(0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b again"); + assert_eq!(g.comp.pattern_cache_bytes(), one); + // Idle: frames that paint no pattern age it out. + g.comp.set_pattern_budget(photocraft_gpu::PATTERN_BUDGET); + for _ in 0..photocraft_gpu::PATTERN_IDLE_FRAMES - 1 { + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + } + assert_eq!(g.comp.pattern_cache_bytes(), one, "still within the idle limit"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + assert_eq!(g.comp.pattern_cache_bytes(), 0, "dropped after {} idle frames", photocraft_gpu::PATTERN_IDLE_FRAMES); + check(&mut g, &b, "the pattern is uploaded again after being evicted"); +} + #[test] fn pattern_cache_tracks_source_format_and_cow_tiles() { let Some(mut g) = gpu() else { return }; From d9eb46b4851796f51b663c3735a30d2a7538e78c Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 18:45:59 +0200 Subject: [PATCH 054/167] Performance: the lower-stack cache follows an edit up the stack (P9 / P11, #5286) (#5288) Repeating a duplicate or paste of a 12 MP layer cost as much as the first time because the GPU compositor's lower-stack cache stayed at the checkpoint of the first edit after it was filled: an edit higher up only checked that the layers below that checkpoint were unchanged, so every refresh re-ran all passes above it (in the #209 layered document, 87 of 110 passes over the 12 MP damage rect; the duplicate itself takes 0.3 ms, its tiles are already shared). Each cached chunk now remembers its own checkpoint. A render captures at the highest checkpoint below the edit, also where it resumed from a lower cached one, so the next refresh of that edit (its undo, redo or repetition) resumes just below it. Chunks a refresh doesn't reach keep their lower prefix; a chunk moved by a partial capture holds only that rectangle afterwards. Validity is unchanged: a chunk survives only while the layers below its checkpoint are untouched. Release perf_scenarios, layered group, Windows 11, RTX 5070 Ti, 7 interleaved runs: P9 p50 22-29 -> 12-17 ms, P11 p50 37-41 -> 11-18 ms; P8 and P10 fall too; P1-P7, P12 unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/gpu/src/lib.rs | 20 ++++++--- crates/gpu/src/prefix.rs | 83 +++++++++++++++++++++++++----------- crates/gpu/tests/parity.rs | 87 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 32 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 45e07500ba..de6ca3e56e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -24,7 +24,9 @@ //! staged uploads and evicted pages stay bounded. //! - **Stack reuse.** A complete root stack unit can checkpoint its backdrop into bounded chunk //! textures. Exact COW snapshots invalidate the lower prefix; edits above it restore those -//! pixels and execute only the remaining passes. Groups and clipping units stay indivisible. +//! pixels and execute only the remaining passes, capturing on the way the highest checkpoint +//! below the edit, so repeating it (a duplicate or paste and its undo) skips that much more. +//! Groups and clipping units stay indivisible. //! //! Vector masks (rasterised once per mask state into a combined mask texture), layers clipped to //! pass-through groups, stroked shapes with clipped layers (fill and stroke split once per shape @@ -1302,7 +1304,10 @@ impl Compositor { if chunks.is_empty() { return; } - let first_pass = checkpoint.filter(|_| chunks.iter().all(|c| self.prefix.contains(*c))).map_or(0, |c| c.pass_end); + // Chunks may hold different checkpoints: bind from the lowest one any of them resumes at. + let first_pass = checkpoint + .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) + .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); // Chunk pool. @@ -1372,7 +1377,7 @@ impl Compositor { for (ci, c) in chunks.iter().enumerate() { let mut resume = 0; - if let Some(cp) = checkpoint + if let Some(cp) = checkpoint.and_then(|_| self.prefix.restore_point(*c)) && let Some((dst, _)) = self.pool.get(cp.slot as usize) && self.prefix.copy_out(encoder, *c, dst) { @@ -1397,11 +1402,12 @@ impl Compositor { return; } } - if resume == 0 + // Also after resuming from a lower checkpoint: the chunk moves up to this one. + if resume < i && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let scissor = match p.clip { Some(clip) => { @@ -1467,11 +1473,11 @@ impl Compositor { pass.set_bind_group(1, bg1[i].as_ref(), &[]); pass.draw(0..3, 0..1); } - if resume == 0 + if resume < plan.passes.len() && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == plan.passes.len()) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let (t, v) = &self.pool[plan.root as usize]; sink(encoder, ChunkOut { rect: *c, texture: t, view: v }); diff --git a/crates/gpu/src/prefix.rs b/crates/gpu/src/prefix.rs index 0312a25fa2..34c70916e0 100644 --- a/crates/gpu/src/prefix.rs +++ b/crates/gpu/src/prefix.rs @@ -6,6 +6,13 @@ //! every frame, and caching those would fill the budget with entries that never hit again. //! A partial rectangle is served as a sub-rect copy of the whole chunk it falls into, and a //! capture merges into the chunk it overlaps, so every hit lands on the same stable entry. +//! +//! Each chunk remembers the checkpoint it holds. A render captures at the highest checkpoint +//! below its edit, also where it resumed from a lower cached one, so the cache follows the edit +//! up the stack chunk by chunk: repeating an edit high in the stack (a duplicate or paste and its +//! undo) then skips everything below it, and chunks a refresh doesn't reach keep serving their +//! own, lower prefix meanwhile (#5286). A chunk moved by a partial capture holds only that +//! rectangle afterwards: the rest of it misses once, like any fresh entry. use std::collections::HashMap; use photocraft_doc::{Document, Layer}; @@ -21,11 +28,17 @@ pub(crate) const MAX_BYTES: u64 = 512 << 20; pub(crate) struct Chunk { tex: Tex, covered: Vec, + /// The checkpoint the covered pixels hold. + checkpoint: Checkpoint, + bytes: u64, } pub(crate) struct Prefix { context: Option, + /// The root layers of the last prepared render, all of them: a chunk stays valid while every + /// render since its capture left the layers below its checkpoint as they were. layers: Vec, + /// Where renders capture: the highest useful checkpoint below the latest edit. checkpoint: Option, chunks: HashMap, bytes: u64, @@ -90,6 +103,7 @@ impl Prefix { self.context.as_ref().map(|d| d.id) } + /// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at. pub(crate) fn prepare(&mut self, doc: &Document, plan: &Plan<'_>) -> Option { if self.budget == 0 { return None; @@ -97,39 +111,51 @@ impl Prefix { // Keeping every non-layer field makes new document inputs invalidate automatically. // Tile data is Arc-shared; shared Tile equality does not scan its pixel bytes. let mut context = doc.clone(); - let mut layers = std::mem::take(&mut context.layers); + let layers = std::mem::take(&mut context.layers); let same_context = self.context.as_ref() == Some(&context); - let changed = self.layers.iter().zip(&layers).position(|(a, b)| a != b).or_else(|| (layers.len() < self.layers.len()).then_some(layers.len())); - let old = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); - let checkpoint = old.or_else(|| { - if same_context && let Some(changed) = changed { - plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied() - } else { + // The lowest root layer this render changes since the last one (a layer added or removed + // at the top changes from there on). + let changed = self + .layers + .iter() + .zip(&layers) + .position(|(a, b)| a != b) + .or_else(|| (layers.len() != self.layers.len()).then_some(layers.len().min(self.layers.len()))); + if same_context { + let mut freed = 0u64; + self.chunks.retain(|_, c| { + let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && plan.checkpoints.contains(&c.checkpoint); + if !keep { + freed = freed.saturating_add(c.bytes); + } + keep + }); + self.bytes = self.bytes.saturating_sub(freed); + } else { + self.clear(); + } + let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); + let checkpoint = kept.or_else(|| match changed { + // Just below the edit; nothing when it is at the bottom. + Some(changed) if same_context => plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(), + _ => { let mut useful = plan.checkpoints.iter().rev().filter(|c| c.pass_end > 1); let last = useful.next().copied(); useful.next().copied().or(last) } }); - if old.is_none() { - self.clear(); - } - let Some(checkpoint) = checkpoint else { - // Remember bottom-layer edits even when no useful prefix survives: otherwise - // every other edit would populate and immediately discard the default checkpoint. - self.context = Some(context); - self.layers = layers; - return None; - }; - layers.truncate(checkpoint.layer_end); + // Remember bottom-layer edits even when no useful prefix survives: otherwise every other + // edit would populate and immediately discard the default checkpoint. self.context = Some(context); self.layers = layers; - self.checkpoint = Some(checkpoint); - Some(checkpoint) + self.checkpoint = checkpoint; + checkpoint } - /// Whether every pixel of `rect` is already cached in its whole grid chunk. - pub(crate) fn contains(&self, rect: Rect) -> bool { - self.covered_by(rect).is_some() + /// The checkpoint the cached pixels of `rect` hold, when every one of them is cached in its + /// whole grid chunk. + pub(crate) fn restore_point(&self, rect: Rect) -> Option { + self.covered_by(rect).map(|(chunk, _)| chunk.checkpoint) } /// The whole chunk and the source offset that serve `rect`, when it is fully covered. @@ -160,7 +186,8 @@ impl Prefix { /// Merges the rendered `rect` (document coordinates) into its whole grid chunk, admitting /// the chunk while space remains. Replacing entries on every miss would make a full /// refresh larger than the cache evict its own next hits. - pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture) { + /// A chunk holding another (lower) checkpoint starts over with this capture. + pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture, checkpoint: Checkpoint) { if rect.is_empty() { return; } @@ -174,9 +201,15 @@ impl Prefix { let tex = Tex::new(device, "pc_compose_prefix", cell.width(), cell.height(), src.format(), wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST); self.bytes = self.bytes.saturating_add(cell_bytes); - self.chunks.insert(cell, Chunk { tex, covered: Vec::new() }); + self.chunks.insert(cell, Chunk { tex, covered: Vec::new(), checkpoint, bytes: cell_bytes }); } let Some(chunk) = self.chunks.get_mut(&cell) else { return }; + if chunk.checkpoint != checkpoint { + // The render restored this chunk's old pixels earlier in the same encoder, so + // overwriting them in place is ordered after that copy. + chunk.covered.clear(); + chunk.checkpoint = checkpoint; + } // A whole-chunk render replaces the coverage; a partial rectangle lands at its offset // inside the cell, beside whatever earlier strokes put there. if rect == cell { diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 44836c233f..de2a972008 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -259,6 +259,93 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() { } } +/// Duplicating a layer high in the stack, undoing and redoing it (#5286): the render resumes +/// from the lower cached prefix and moves the cache up to just below the copy, so the undo and +/// the redo skip more passes. Every render matches an uncached compositor bit for bit, at every +/// depth. +#[test] +fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::new(&g.device); + uncached.set_composite_cache_budget(0); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("follow", Size::new(300, 70), ColorMode::Rgb, depth); + for seed in 1..=6 { + doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2)); + } + let mut exact = |g: &mut Gpu, doc: &Document, what: &str| -> photocraft_gpu::Stats { + let stats = diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); + let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + assert_eq!(cached, cold, "{depth:?} {what}: reuse preserves the GPU accumulator exactly"); + stats + }; + exact(&mut g, &doc, "populate"); + // An edit low in the stack puts the cache there. + doc.layers[1].opacity = 0.6; + assert_eq!(exact(&mut g, &doc, "low edit").prefix_hits, 0); + let low = doc.clone(); + let copy = doc.layers[4].duplicate(); + doc.layers.insert(5, copy); + let first = exact(&mut g, &doc, "duplicate"); + assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix"); + let undo = exact(&mut g, &low, "undo"); + assert!(undo.prefix_hits > 0, "{depth:?}"); + assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}"); + let redo = exact(&mut g, &doc, "redo"); + assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?}"); + // An edit below the moved cache still invalidates it. + doc.layers[2].opacity = 0.4; + assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?}"); + } +} + +/// A small refresh above the cache moves only the chunk it renders, which then holds just that +/// rectangle; the other chunks keep serving their lower checkpoint, and a full render afterwards +/// resumes each chunk from its own (#5286). Every render matches an uncached compositor exactly. +#[test] +fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + uncached.set_texture_limit(PAGED_LIMIT); + uncached.set_composite_cache_budget(0); + let mut doc = base_doc(600, 300); + for seed in 2..=5 { + doc.layers.push(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2)); + } + let mut render = |g: &mut Gpu, doc: &Document, rect: Rect, what: &str| -> photocraft_gpu::Stats { + let (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, doc, rect).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, rect).unwrap(); + assert_eq!(cached, cold, "{what}: reuse preserves the GPU accumulator exactly"); + let cpu = photocraft_compose::render(doc, rect); + assert!(worst_diff(&cpu.px, &cached).0 <= TOL, "{what}: GPU and CPU differ"); + stats + }; + render(&mut g, &doc, doc.bounds(), "populate"); + doc.layers[1].opacity = 0.5; + let low = render(&mut g, &doc, doc.bounds(), "low edit"); + assert_eq!(low.prefix_hits, 0); + // A copy near the top, refreshed in one chunk only. + let copy = doc.layers[3].duplicate(); + doc.layers.insert(4, copy); + let small = render(&mut g, &doc, Rect::new(10, 10, 60, 60), "partial duplicate refresh"); + assert_eq!(small.prefix_hits, 1, "{small:?}"); + let full = render(&mut g, &doc, doc.bounds(), "full refresh after the partial one"); + assert_eq!(full.prefix_hits, full.chunks - 1, "only the partly moved chunk misses: {full:?}"); + let again = render(&mut g, &doc, doc.bounds(), "full refresh, all moved"); + assert_eq!(again.prefix_hits, again.chunks); + assert!(again.prefix_passes_skipped > full.prefix_passes_skipped, "{full:?} {again:?}"); + // Mixed checkpoints: a bottom-half refresh moves the chunks it covers whole further up + // (an edit at the top), the top half keeps the previous checkpoint and still hits. + doc.layers.last_mut().unwrap().opacity = 0.7; + let bottom = render(&mut g, &doc, Rect::new(0, 256, 600, 300), "bottom strip"); + assert_eq!(bottom.prefix_hits, bottom.chunks); + let moved = render(&mut g, &doc, Rect::new(0, 0, 512, 256), "whole top chunks"); + assert_eq!(moved.prefix_hits, moved.chunks); + let mixed = render(&mut g, &doc, doc.bounds(), "full refresh, mixed checkpoints"); + assert_eq!(mixed.prefix_hits, mixed.chunks, "{mixed:?}"); +} + #[test] fn root_checkpoints_keep_clipping_groups_and_adjustments_whole() { let Some(mut g) = gpu() else { return }; From 8d2806f66c4f7b86be3a7b8ec57519f5c8f41a99 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 00:46:04 +0800 Subject: [PATCH 055/167] Expose shared Smart Object instances in inspection and replacement results (#4832) * Smart Objects: inspect and Replace Contents show shared instances (#4075) Placed layers that share a source have imported as one set of contents since #2222, and Replace Contents changes every instance, but nothing reported it: `linkGroup` is Link Layers, and `smartSource` can't tell copies of one contents from independent copies of the same file. document.inspect now gives each smart object `smartContents`: the contents `id` its copies share and how many `instances` there are. Replace Contents, Relink to File and Convert to Embedded/Linked list the `instances` they changed. Co-Authored-By: Claude Opus 5.5 * Test rendered caches after shared Smart Object replacement --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/inspect.rs | 35 ++++++-- crates/engine/src/smart_cmds.rs | 11 ++- crates/engine/tests/smart_duplicates.rs | 103 +++++++++++++++++++++++- docs/development.md | 4 +- 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 22c3d3a96f..6068503aec 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -1,6 +1,8 @@ //! JSON views of engine state for automation, tests and debugging. -use photocraft_doc::{Layer, LayerContent, SmartSource}; +use std::collections::HashMap; + +use photocraft_doc::{Layer, LayerContent, SmartContentsId, SmartSource}; use serde_json::{Value, json}; use crate::{DocState, Session}; @@ -25,6 +27,7 @@ pub fn session(s: &Session) -> Value { pub fn document(d: &DocState) -> Value { let doc = &d.doc; let selected = d.selected_layers(); + let instances = smart_instances(&doc.layers); json!({ "name": doc.name, "path": &d.path, @@ -38,7 +41,7 @@ pub fn document(d: &DocState) -> Value { "isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::>(), "hasSelection": doc.selection.is_some(), "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), - "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::>(), + "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata))).collect::>(), "history": d.history.entries(), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), @@ -61,14 +64,27 @@ pub fn document(d: &DocState) -> Value { }) } -/// Layer tree top-to-bottom (display order). +/// Layer tree top-to-bottom (display order). Smart object instances are counted within `l`. pub fn layer(l: &Layer) -> Value { - layer_sel(l, &[], None) + layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None) } -/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection); with the -/// document's `meta`, a smart object whose file is embedded in the PSD says so. -fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&photocraft_doc::Metadata>) -> Value { +/// How many smart objects in `layers`, groups included, share each contents identity. +fn smart_instances(layers: &[Layer]) -> HashMap { + let mut counts = HashMap::new(); + let mut pending: Vec<&Layer> = layers.iter().collect(); + while let Some(l) = pending.pop() { + match &l.content { + LayerContent::Smart(sm) => *counts.entry(sm.contents_id).or_default() += 1, + LayerContent::Group(g) => pending.extend(&g.children), + _ => {} + } + } + counts +} + +/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection). +fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMap, meta: Option<&photocraft_doc::Metadata>) -> Value { let bounds = l.surface().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] @@ -92,7 +108,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot }); match &l.content { LayerContent::Group(g) => { - v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, meta)).collect()); + v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, instances, meta)).collect()); v["expanded"] = json!(g.expanded); if let Some(a) = &g.artboard { v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset}); @@ -122,6 +138,9 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot None => json!({"kind": "linked", "path": path}), }, }; + // Copies of the same contents change together (Edit and Replace Contents), and + // `smartSource` can't tell them from independent copies of one file (#4075). + v["smartContents"] = json!({"id": sm.contents_id.0, "instances": instances.get(&sm.contents_id).copied().unwrap_or(1)}); } LayerContent::Text(t) => { v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt}); diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 295b845df0..649a154419 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -827,21 +827,24 @@ fn set_source(s: &mut Session, p: &Value, label: &str, keep_psd: bool, make: imp // Keep the shared-instance transaction out of the per-command source-builder instantiations. fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, new: SmartSource) -> Result { - let (parent, contents_id) = s.edit(label, |doc, _| { + let (parent, contents_id, changed) = s.edit(label, |doc, _| { let contents_id = smart(doc, id)?.contents_id; - for instance in instances(doc, contents_id) { + let changed = instances(doc, contents_id); + for &instance in &changed { smart_mut(doc, instance)?.source = new.clone(); if !keep_psd { detach_psd(doc.layer_mut(instance).ok_or(EngineError::NoLayer(instance))?); } refresh_or_fail(doc, instance)?; } - Ok((doc.id, contents_id)) + Ok((doc.id, contents_id, changed)) })?; // An old editor must not overwrite newly replaced/relinked contents on close. Keep its // document open, but the next Edit Contents opens the replacement source. s.smart_links.retain(|l| l.parent != parent || l.contents_id != contents_id); - Ok(json!({"layer": id.0})) + // Every instance changed, not only `layer`: list them, so a caller doesn't repeat the + // command once per copy (#4075). + Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>()})) } fn replace_contents(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/tests/smart_duplicates.rs b/crates/engine/tests/smart_duplicates.rs index 12f5ec5486..554fd23f0f 100644 --- a/crates/engine/tests/smart_duplicates.rs +++ b/crates/engine/tests/smart_duplicates.rs @@ -4,7 +4,7 @@ use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject}; use photocraft_engine::Session; use photocraft_engine::smart_cmds::{decode_source, source_bytes}; use photocraft_geom::Rect; -use serde_json::json; +use serde_json::{Value, json}; fn session(depth: u64) -> (Session, u64, u64) { let mut s = Session::new(); @@ -195,3 +195,104 @@ fn replacing_shared_contents_does_not_reuse_or_commit_a_stale_editor() { s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap(); assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [0.0, 1.0, 0.0, 1.0]); } + +/// A `.pcraft` file whose contents are one solid colour, for Replace Contents. +fn contents_file(rgba: [f32; 4], tag: &str) -> std::path::PathBuf { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, rgba); + let path = std::env::temp_dir().join(format!("photocraft-{tag}-{}.pcraft", std::process::id())); + std::fs::write(&path, photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()).unwrap(); + path +} + +/// The node named `name` in `document.inspect`'s layer tree, searched through groups. +fn inspected<'a>(layers: &'a Value, name: &str) -> Option<&'a Value> { + layers.as_array()?.iter().find_map(|l| if l["name"] == name { Some(l) } else { inspected(&l["children"], name) }) +} + +/// Placed layers sharing a source (a PSD's copies of one smart object) share their contents, so +/// one Replace Contents updates every copy. Nothing showed it: `linkGroup` is Link Layers, which +/// stays unset (#4075). Inspect gives each smart object its contents identity and instance count, +/// and Replace Contents lists every layer it changed. +#[test] +fn inspect_and_replace_contents_report_shared_instances() { + let (mut s, original, duplicate) = session(8); + let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap(); + s.edit("name and group", |doc, _| { + for (id, name) in [(original, "original"), (duplicate, "duplicate"), (independent, "independent")] { + doc.layer_mut(LayerId(id)).unwrap().name = name.into(); + } + // An instance inside a group counts too. + let copy = doc.remove(LayerId(duplicate)).unwrap(); + doc.layers.push(Layer::group("Copies", vec![copy])); + Ok(()) + }) + .unwrap(); + let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap(); + let mut s = Session::new(); + s.add_document(photocraft_io::import("copies.psd", &out.bytes).unwrap().document, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let [o, d, i] = ["original", "duplicate", "independent"].map(|name| inspected(&tree["layers"], name).unwrap().clone()); + assert_eq!(o["smartContents"]["id"], d["smartContents"]["id"]); + assert_ne!(o["smartContents"]["id"], i["smartContents"]["id"]); + assert_eq!([&o, &d, &i].map(|l| l["smartContents"]["instances"].as_u64()), [Some(2), Some(2), Some(1)]); + assert!([&o, &d, &i].iter().all(|l| l["linkGroup"].is_null()), "shared contents don't link the layers"); + + let [o, d, i] = [&o, &d, &i].map(|l| l["id"].as_u64().unwrap()); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "replace-instances"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": d, "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + assert_eq!(changed, if o < d { [o, d] } else { [d, o] }); + assert_eq!([o, d, i].map(|id| contents_pixel(&s, id)), [[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]); + for (id, expected) in [(o, [0.0, 1.0, 0.0, 1.0]), (d, [0.0, 1.0, 0.0, 1.0]), (i, [0.0, 0.0, 1.0, 1.0])] { + assert_eq!(smart(&s, id).cache.as_ref().unwrap().rgba(4, 3), expected, "instance {id} must display its current contents"); + } +} + +/// The same with the placed layer Photoshop wrote, twice: Photoshop's copies of a smart object +/// each carry a `SoLd` naming the one embedded file (`Idnt`), so they import as one contents. +#[cfg(feature = "corpus")] +#[test] +fn photoshop_placed_copies_import_as_shared_instances() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-none-plain.psd"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display())); + let mut file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let placed = file.layers().iter().find(|r| r.block(b"SoLd").is_some()).expect("a placed layer").clone(); + file.layers_mut().push(placed); + let mut s = Session::new(); + s.add_document(photocraft_io::psd_to_document(&file).0, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let copies: Vec<&Value> = tree["layers"].as_array().unwrap().iter().filter(|l| l["kind"] == "Smart Object").collect(); + assert_eq!(copies.len(), 2); + assert_eq!(copies[0]["smartContents"], copies[1]["smartContents"]); + assert_eq!(copies[0]["smartContents"]["instances"], 2); + for copy in &copies { + assert_eq!(copy["smartSource"]["kind"], "embedded"); + assert!(copy["smartSource"]["uuid"].as_str().is_some()); + } + let mut ids: Vec = copies.iter().map(|l| l["id"].as_u64().unwrap()).collect(); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "photoshop-copies"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": ids[0], "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + ids.sort_unstable(); + assert_eq!(changed, ids); + for id in ids { + assert_eq!(contents_pixel(&s, id), [0.0, 1.0, 0.0, 1.0]); + let cache = smart(&s, id).cache.as_ref().unwrap(); + let bounds = cache.content_bounds(); + assert!(!bounds.is_empty(), "instance {id} must have rendered pixels"); + assert_eq!( + cache.rgba(bounds.x0 + bounds.width() as i32 / 2, bounds.y0 + bounds.height() as i32 / 2), + [0.0, 1.0, 0.0, 1.0], + "instance {id} must refresh its imported cache" + ); + } +} diff --git a/docs/development.md b/docs/development.md index f685f653cc..8bfc53df81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -243,7 +243,9 @@ claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp \ `doc_inspect` (and the engine command `document.inspect`) reports the layer tree with kinds, bounds, masks, selection, effects (`effects.items[].kind`), smart filters (`smartFilters[]`), smart object sources (`smartSource`: `embedded` with its file name, or `linked` with a file path or, for a -PSD placed layer, the `Idnt` uuid its duplicates share), type +PSD placed layer, the `Idnt` uuid its duplicates share), shared contents (`smartContents`: an `id` +every copy of the same contents has, and the number of `instances`; Replace Contents changes them +all and lists them in its result), type text, adjustment settings, channels and history, so agents can verify what they did without a screenshot. `crates/automation/tests/agent_tasks.rs` is the reference: ten realistic edit tasks (title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align, From 45f8921ae84e0583b69460d4c62b7c1f530a05fd Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Wed, 14 Oct 2026 18:46:11 +0200 Subject: [PATCH 056/167] Free Transform: solid pixel stretch (edge-repeat sampling, selected-pixel bounds) (#3867) Stretching a 1 px row or column faded out along the stretch: bicubic and bilinear sampling read transparent pixels past the source edge, and with a selection the edge was the whole layer's bounds (lifted pixels kept their colour at zero alpha). Samples past the source rect now repeat the edge and the outline is antialiased by coverage, and the lifted pixels' bounds are the selected pixels'. Fixes #3773 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 40 +++++++++- crates/engine/src/transform_cmds.rs | 110 ++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 7d1dd0fe6e..3b157b56a9 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -392,6 +392,15 @@ fn warp( px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64 } }; + // Samples past the source rect repeat its edge, so a stretched edge row or column keeps its + // colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage + // instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels. + let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c); + let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0); + let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0)); + let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 }; + let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs()); + let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs()); let w = t.width() as usize; let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; let bytes = len.checked_mul(std::mem::size_of::()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; @@ -424,7 +433,7 @@ fn warp( for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix + dx, iy + dy, c) * wx * wy; + *s += clamped(ix + dx, iy + dy, c) * wx * wy; } } } @@ -436,12 +445,21 @@ fn warp( for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; + *s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; } } } } } + if interp != Interp::Nearest { + // How much of this pixel the warped source rect covers, per axis. + let (uc, vc) = (u + 0.5, v + 0.5); + let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0); + let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0); + for s in acc.iter_mut().take(n) { + *s *= cu * cv; + } + } let al = acc[a].clamp(0.0, 1.0); if al <= 0.0 { continue; @@ -539,6 +557,24 @@ mod tests { Surface::new(PixelFormat::RGBA8) } + /// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in + /// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased. + #[test] + fn stretched_single_column_stays_opaque() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]); + for interp in [Interp::Bicubic, Interp::Bilinear] { + let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap(); + let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap(); + assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}"); + for x in [0, 1, 25, 50, 75, 99] { + for y in 0..4 { + assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3)); + } + } + } + } + /// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left /// half is grey 64 and right half grey 192: the row across the edge, columns 26..36. #[test] diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index bcf97126cc..dfc3375d00 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -282,7 +282,12 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R } _ => { if let Some(surf) = l.surface() { - check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?; + // With a selection only the lifted pixels are warped (`split_selected`). + let src = match sel { + Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()), + None => surf.content_bounds(), + }; + check_budget(src, h, &format!("layer \"{}\"", l.name))?; } } } @@ -383,17 +388,20 @@ fn apply_transform( } _ => { if let Some(surf) = l.surface_mut() { - let src = surf.content_bounds(); *surf = match doc_sel { - // Only the selected pixels move: lift them, clear them, warp and paste back. + // Only the selected pixels move: lift them, clear them, warp and paste back. The + // lifted pixels' own bounds are the edge a stretch repeats (#3773). Some(sel) => { let (lifted, mut rest) = split_selected(surf, sel)?; - let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?; + let moved = warp_surface_with(&lifted, lifted.content_bounds(), h, interp, ctl)?.ok_or(EngineError::Cancelled)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?, + None => { + let src = surf.content_bounds(); + warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)? + } }; } } @@ -445,6 +453,11 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface let k = sel.sample_channel(x, y, 0); l[n - 1] *= k; r[n - 1] *= 1.0 - k; + // Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the + // selected pixels' (what a stretch repeats at the edge, #3773). + if l[n - 1] <= 0.0 { + l.fill(0.0); + } if r[n - 1] <= 0.0 { r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c); } @@ -1249,6 +1262,93 @@ mod tests { assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform"); } + /// A 1 x 8 px column on a 256 x 256 canvas (#3773). + fn column_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).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(100, 100, 101, 108), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + fn opaque_green(s: &Session, x: i32, y: i32) -> bool { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (surf.sample_channel(x, y, 1) - 1.0).abs() < 1e-3 && (surf.sample_channel(x, y, 3) - 1.0).abs() < 1e-3 + } + + /// The "pixel stretch" (#3773): scales far past 1000% are taken, and the stretched column stays + /// solid along its whole length. + #[test] + fn a_one_pixel_column_stretches_5000_percent() { + let mut s = column_session(); + s.execute("edit.transform", json!({"quad": [[100, 100], [150, 100], [150, 108], [100, 108]]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(100, 100, 150, 108)); + for x in [100, 101, 125, 149] { + for y in 100..108 { + assert!(opaque_green(&s, x, y), "({x}, {y})"); + } + } + } + + /// The technique as done in Photoshop: select a 1 px column of an image and stretch it across. + #[test] + fn a_selected_column_stretches_solid_over_the_image() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 256, 256), &[1.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(10, 0, 11, 256), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 256})).unwrap(); + s.execute("edit.transform", json!({"quad": [[10, 0], [250, 0], [250, 256], [10, 256]]})).unwrap(); + for x in [10, 11, 100, 200, 249] { + assert!(opaque_green(&s, x, 128), "x = {x}"); + } + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(surf.sample_channel(250, 128, 0) > 0.999 && surf.sample_channel(9, 128, 0) > 0.999, "the image either side is untouched"); + } + + /// Free Transform keeps pixels moved off the canvas, as Photoshop does: an 8 x 8 layer moved + /// three canvas widths right and back comes back whole. + #[test] + fn a_layer_moved_far_off_the_canvas_and_back_keeps_its_pixels() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..8 { + for x in 0..8 { + surf.fill_rect(Rect::new(20 + x, 20 + y, 21 + x, 21 + y), &[x as f32 / 8.0, y as f32 / 8.0, 0.5, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + let pixels = |s: &Session| { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (0..8 * 8 * 4).map(|i| surf.sample_channel(20 + i / 4 % 8, 20 + i / 32, (i % 4) as usize)).collect::>() + }; + let before = pixels(&s); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(212, 20, 220, 28)); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(20, 20, 28, 28)); + assert_eq!(pixels(&s), before); + } + #[test] fn relative_params_scale_and_rotate_about_the_frame_centre() { let mut s = Session::new(); From 7b08b56531b2af71c593ce6e97b7e5ba9599381a Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 18:46:18 +0200 Subject: [PATCH 057/167] feat(engine): layer.list, a read-only listing of the layer tree (#5059) (#5148) The layers were only in document.inspect, together with history, channels and much more, so agents looked for a layer.* command that didn't exist. layer.list returns the layers top-down with id, name, kind, visibility, bounds, selected and active; groups list their children, or with "flat": true every layer is one row with its depth and parent group. It takes the same optional "document" index as document.inspect, is not journaled and bad params are errors. --- crates/engine/src/commands.rs | 1 + crates/engine/src/layer_list_cmds.rs | 163 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + docs/control-protocol.md | 1 + 4 files changed, 166 insertions(+) create mode 100644 crates/engine/src/layer_list_cmds.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 48ce4a9332..923f049643 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1283,6 +1283,7 @@ fn build() -> Vec { }); } v.extend(crate::layer_style::specs()); + v.extend(crate::layer_list_cmds::specs()); v.extend(crate::filters::specs()); v.extend(crate::filters_ext::specs()); v.extend(crate::color_to_alpha_cmds::specs()); diff --git a/crates/engine/src/layer_list_cmds.rs b/crates/engine/src/layer_list_cmds.rs new file mode 100644 index 0000000000..6ced6ae0d9 --- /dev/null +++ b/crates/engine/src/layer_list_cmds.rs @@ -0,0 +1,163 @@ +//! `layer.list`: the layer tree alone, for agents and scripts (#5059). `document.inspect` also has +//! it, buried among history, channels and much more; an agent looking for the layers' ids looks +//! for a `layer.*` command first. + +use photocraft_doc::{Layer, LayerContent, LayerId}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{DocState, EngineError, Result, Session}; + +const ID: &str = "layer.list"; + +/// Groups nest at most this deep in the listing; deeper layers are left out (documents can be +/// deep, and the tree is built recursively). +const MAX_DEPTH: usize = 256; + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +/// One layer's row: what an agent needs to pick a layer to work on. +fn row(l: &Layer, selected: &[LayerId], active: Option) -> Value { + let bounds = crate::layer_multi_cmds::layer_bounds(l).map(|r| [r.x0, r.y0, r.width() as i32, r.height() as i32]); + json!({ + "id": l.id.0, + "name": l.name, + "kind": l.content.kind_name(), + "visible": l.visible, + "bounds": bounds, + "selected": selected.contains(&l.id), + "active": active == Some(l.id), + }) +} + +/// The layers top-down (the Layers panel's order), groups with their `children`. +fn tree(layers: &[Layer], d: &DocState, selected: &[LayerId], depth: usize) -> Vec { + if depth >= MAX_DEPTH { + return Vec::new(); + } + layers + .iter() + .rev() + .map(|l| { + let mut v = row(l, selected, d.active_layer); + if let LayerContent::Group(g) = &l.content { + v["children"] = Value::Array(tree(&g.children, d, selected, depth + 1)); + } + v + }) + .collect() +} + +/// The layers top-down as one list, each with its `depth` (0 = top level) and `parent` group id. +fn flat(layers: &[Layer], d: &DocState, selected: &[LayerId], parent: Option, depth: usize, out: &mut Vec) { + if depth >= MAX_DEPTH { + return; + } + for l in layers.iter().rev() { + let mut v = row(l, selected, d.active_layer); + v["depth"] = json!(depth); + v["parent"] = json!(parent.map(|p| p.0)); + out.push(v); + if let LayerContent::Group(g) = &l.content { + flat(&g.children, d, selected, Some(l.id), depth + 1, out); + } + } +} + +fn run(s: &mut Session, p: &Value) -> Result { + let bad = |msg: &str| EngineError::BadParams { cmd: ID.into(), msg: msg.into() }; + let d = match p.get("document") { + None | Some(Value::Null) => s.active().ok_or(EngineError::NoDocument)?, + Some(v) => { + let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("`document` must be an index"))?; + s.documents().get(i).ok_or_else(|| EngineError::Other(format!("no document at index {i}")))? + } + }; + let as_flat = match p.get("flat") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("`flat` must be true or false"))?, + }; + let selected = d.selected_layers(); + let layers = if as_flat { + let mut out = Vec::new(); + flat(&d.doc.layers, d, &selected, None, 0, &mut out); + out + } else { + tree(&d.doc.layers, d, &selected, 0) + }; + Ok(json!({"layers": layers, "activeLayer": d.active_layer.map(|l| l.0)})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: ID, + label: "List Layers", + menu: &[], + shortcut: None, + params: r##"{"document":index?,"flat":bool=false} → {"layers":[{"id","name","kind","visible","bounds":[x,y,w,h]|null,"selected","active","children"?}],"activeLayer":id?} (top-down; groups list their "children", or with "flat" every layer is one row with its "depth" and "parent" group id)"##, + enabled: has_doc, + run, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s + } + + #[test] + fn lists_ids_names_kinds_and_nesting_top_down() { + let mut s = session_with_doc(); + let bg = s.active().unwrap().active_layer.unwrap().0; + let inner = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().doc.layers.last().unwrap().id.0; + s.execute("layer.select", json!({"layer": inner})).unwrap(); + s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap(); + let r = s.execute(ID, json!({})).unwrap(); + let layers = r["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 2, "{r}"); + assert_eq!((layers[0]["id"].as_u64(), layers[0]["kind"].as_str()), (Some(group), Some("Group")), "{r}"); + assert_eq!(layers[0]["children"][0]["id"], inner, "{r}"); + assert_eq!(layers[0]["children"][0]["active"], true, "{r}"); + assert!(layers[0]["children"][0]["bounds"].is_array(), "{r}"); + assert_eq!(layers[0]["bounds"], layers[0]["children"][0]["bounds"], "a group spans its children"); + assert_eq!((layers[1]["id"].as_u64(), layers[1]["name"].as_str(), layers[1]["visible"].as_bool()), (Some(bg), Some("Background"), Some(false))); + assert!(layers[1].get("children").is_none()); + assert_eq!(r["activeLayer"], inner); + // Flat: one row per layer, with its depth and parent group. + let r = s.execute(ID, json!({"flat": true})).unwrap(); + let rows: Vec<(u64, u64, Option)> = + r["layers"].as_array().unwrap().iter().map(|l| (l["id"].as_u64().unwrap(), l["depth"].as_u64().unwrap(), l["parent"].as_u64())).collect(); + assert_eq!(rows, [(group, 0, None), (inner, 1, Some(group)), (bg, 0, None)]); + } + + #[test] + fn is_a_query_and_leaves_the_document_alone() { + let mut s = session_with_doc(); + let revision = s.active().unwrap().revision; + let journal = s.journal.len(); + s.execute(ID, json!({})).unwrap(); + assert_eq!((s.active().unwrap().revision, s.journal.len()), (revision, journal)); + } + + #[test] + fn bad_params_are_errors_not_panics() { + let mut s = Session::new(); + assert!(s.execute(ID, json!({})).is_err(), "no document"); + let mut s = session_with_doc(); + for p in [json!({"flat": "yes"}), json!({"document": -1}), json!({"document": 99}), json!({"document": u64::MAX}), json!({"document": "a"})] { + assert!(s.execute(ID, p.clone()).is_err(), "{p}"); + } + assert!(s.execute(ID, json!({"document": 0, "flat": false})).is_ok()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff3cde6e4c..f3e683be4f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,6 +62,7 @@ pub mod jobs; pub mod kys; pub mod layer_copy_cmds; pub mod layer_label_cmds; +pub mod layer_list_cmds; pub mod layer_mask_props_cmds; pub mod layer_menu_cmds; pub mod layer_multi_cmds; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4fa1c60c6e..1cf47c17a6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -111,6 +111,7 @@ store expose and persist the same setting; scripts keep using canonical command | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | +| `layer.list` | `{"document":index?,"flat":bool?}`: just the layers, top-down: `id`, `name`, `kind`, `visible`, `bounds` (`[x,y,w,h]` or null), `selected`, `active`; groups list their `children`, or with `flat` every layer is one row with its `depth` and `parent` group id | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | From 3b45e8baee7117e53847523b5bc3ae2b337966d4 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:46:25 +0200 Subject: [PATCH 058/167] PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202) (#5206) * PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202) The flattened Bitmap/Indexed/Duotone path retried every composite_rgba8 error, including LimitExceeded, by allocating the refused RGBA buffer (OOM abort on e.g. a 50000x50000 1-bit PSB). LimitExceeded now propagates; the Multichannel fallback checks its output against the cap, reserves fallibly, borrows the merged image, uses get for planes and converts in TILE_SIZE bands. A failed composite now warns. Checked usize arithmetic for alpha channel names, merged plane sizes and the gradient map name length (overflowed on 32-bit targets). Also: EXR part import drops a cache that never hit but cloned each image; raw KRA tiles are borrowed; flat import detects the format once. Co-Authored-By: Claude Opus 5.5 * PSD import: flat Background opacity check reads a band at a time (#5200) flat_background read the whole surface into floats to see whether it is opaque, undoing the banded conversion. Read TILE_SIZE row bands, stop at the first transparent pixel, and drop the RGBA8 buffer before. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/adjust_map.rs | 3 +- crates/io/src/exr_parts.rs | 13 +-- crates/io/src/flat.rs | 21 ++-- crates/io/src/kra/tiles.rs | 6 +- crates/io/src/psd_import.rs | 193 +++++++++++++++++++++++++++++------- 5 files changed, 178 insertions(+), 58 deletions(-) diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 6e82478389..d4bc312690 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -280,7 +280,8 @@ fn parse_gradient_map(d: &[u8]) -> Option { 3 => (Some(d.get(4..8)?), 8), _ => return None, }; - at += 4 + be32(d, at)? as usize * 2; + // The gradient's name (UTF-16 units); checked, as `len * 2` overflows a 32-bit `usize`. + at = (be32(d, at)? as usize).checked_mul(2)?.checked_add(at + 4)?; let n = usize::from(be16(d, at)?); at += 2; let mut stops = Vec::with_capacity(n); diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 5474d00310..7e25feb984 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -225,8 +225,8 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result> = vec![None; info.len()]; + // Each chosen part (deduplicated above) decodes once; groups come from their part's named + // channels. let mut layers: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); let mut gray_only = true; @@ -234,14 +234,7 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result img.clone(), - None => { - let img = codecs::decode_exr_part(bytes, part.index, &opts)?; - part_pixels[part.index] = Some(img.clone()); - img - } - }; + let img = codecs::decode_exr_part(bytes, part.index, &opts)?; if img.sample_type() == SampleType::F16 { f16_seen = true; } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 5512a55c01..54fe96090d 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -16,13 +16,15 @@ const BAND_BYTES: usize = 32 << 20; /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - if codecs::detect(bytes) == Some(Format::Tiff) { + // Detected once; every step below branches on it. + let format = codecs::detect(bytes); + if format == Some(Format::Tiff) { return import_tiff_page(name, bytes, None); } - let img = codecs::decode(bytes)?; + let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; // A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop // (#3802, #4804). - let palette = match codecs::detect(bytes) { + let palette = match format { Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)), Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)), _ => None, @@ -34,15 +36,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { r.document.mode = ColorMode::Indexed; r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } - r.document.file_encoding = file_encoding(bytes); - if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { + r.document.file_encoding = file_encoding_as(format, bytes); + if format == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { tga_alpha_to_channel(&mut r.document)?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear // gray so they display and convert correctly. let d = &mut r.document; - if d.icc_profile.is_none() && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) { + if d.icc_profile.is_none() && matches!(format, Some(Format::OpenExr | Format::Hdr)) { d.icc_profile = match d.mode { ColorMode::Rgb => Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()), ColorMode::Grayscale => Some(photocraft_cms::builtin::linear_gray().to_bytes()), @@ -68,8 +70,13 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document /// (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 { + file_encoding_as(codecs::detect(bytes), bytes) +} + +/// [`file_encoding`] for `bytes` already detected as `format`. +fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::FileEncoding { let mut e = photocraft_doc::FileEncoding::default(); - match codecs::detect(bytes) { + match format { 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), diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index b369341b99..8af6638aa8 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -231,9 +231,9 @@ impl<'a> TileFile<'a> { 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(), + let planar: std::borrow::Cow<'_, [u8]> = match flag { + 1 => lzf_decompress(payload, expected)?.into(), + 0 if payload.len() == expected => payload.into(), 0 => return Err(invalid("raw tile has the wrong size")), f => return Err(invalid(format!("unknown tile compression flag {f}"))), }; diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 211bba4da9..94c4ea9932 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -595,14 +595,83 @@ fn unicode_names(data: &[u8]) -> Vec { while at + 4 <= data.len() { let n = u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as usize; at += 4; - let Some(b) = data.get(at..at + n * 2) else { break }; + // Checked: `n * 2` overflows a 32-bit `usize` for a hostile length. + let Some(end) = n.checked_mul(2).and_then(|len| at.checked_add(len)) else { break }; + let Some(b) = data.get(at..end) else { break }; let units: Vec = b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); out.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); - at += n * 2; + at = end; } out } +/// Plane `i` of a decoded merged image whose planes are `plane` bytes each, or `None` when it is +/// short or the offsets overflow. +fn merged_plane(all: &[u8], i: usize, plane: usize) -> Option<&[u8]> { + all.get(i.checked_mul(plane)?..i.checked_add(1)?.checked_mul(plane)?) +} + +/// The merged image of a file the layered path does not open (Bitmap, Indexed, Duotone; 1-bit +/// files) as RGBA8. What `composite_rgba8` can't read (Multichannel) shows its first channels as +/// RGB. An output over `cap` bytes is refused, never allocated (#5200). +fn flat_composite( + file: &PsdFile, + merged: Option<&Result, photocraft_psd::PsdError>>, + cap: u64, +) -> Result { + use photocraft_psd::PsdError; + match file.composite_rgba8() { + Ok(img) => return Ok(img), + // Over the output budget: the fallback below would make the same allocation. + Err(e @ PsdError::LimitExceeded(_)) => return Err(e), + Err(_) => {} + } + let all = match merged { + Some(Ok(all)) => all, + Some(Err(e)) => return Err(e.clone()), + None => return Err(PsdError::Invalid("no merged image".into())), + }; + let h = &file.header; + let (w, hh) = (h.width as usize, h.height as usize); + let len = w + .checked_mul(hh) + .and_then(|n| n.checked_mul(4)) + .filter(|&len| len as u64 <= cap) + .ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?; + let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?; + let mut data = Vec::new(); + data.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for RGBA output"))?; + data.resize(len, 255u8); + for c in 0..3.min(usize::from(h.channels)) { + let bytes = merged_plane(all, c, plane).ok_or_else(|| PsdError::Invalid("channel size mismatch".into()))?; + let p = photocraft_psd::pixels::plane_to_u8(bytes, h.depth, w, hh)?; + for (px, v) in data.as_chunks_mut::<4>().0.iter_mut().zip(&p) { + px[c] = *v; + } + } + Ok(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) +} + +/// Writes RGBA8 `data` covering `canvas` into `s`, a band of tile rows at a time (no full-size +/// float copy of the image). +fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) { + let fmt = s.format(); + let row = (canvas.width() as usize).saturating_mul(4); + let mut vals = Vec::new(); + let mut y = canvas.y0; + while y < canvas.y1 { + let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); + let start = ((y - canvas.y0) as usize).saturating_mul(row); + let Some(rows) = data.get(start..start.saturating_add((band.height() as usize).saturating_mul(row))) else { break }; + vals.clear(); + for p in rows.as_chunks::<4>().0 { + vals.extend(photocraft_raster::from_rgba(&fmt, p.map(|x| f32::from(x) / 255.0))); + } + s.write_region(band, &vals); + y = band.y1; + } +} + fn parse_guides(data: &[u8], doc: &mut Document) { let rd = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let Some(count) = rd(12) else { return }; @@ -623,10 +692,21 @@ fn parse_guides(data: &[u8], doc: &mut Document) { /// Background (Photoshop 27.11 opens psd-tools' flat Bitmap and Indexed files that way), so it is /// written back as one. fn flat_background(s: Surface) -> Layer { + // A band of tile rows at a time (no full-size float copy), stopping at the first + // transparent pixel. let opaque = { let r = s.content_bounds(); let n = s.channels(); - !r.is_empty() && s.read_region(r).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)) + let mut vals = Vec::new(); + let mut y = r.y0; + let mut opaque = !r.is_empty(); + while opaque && y < r.y1 { + let band = Rect::new(r.x0, y, r.x1, y.saturating_add(TILE_SIZE).min(r.y1)); + s.read_region_into(band, &mut vals); + opaque = vals.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); + y = band.y1; + } + opaque }; let mut l = Layer::new("Background", LayerContent::Raster(s)); l.locks.transparency = opaque; @@ -771,7 +851,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } let (w, hh) = (h.width as usize, h.height as usize); - let n = w * hh; + // Pixels per plane and bytes per merged plane, checked: a 1-bit plane can decode within the + // budget while `w * hh` does not fit a 32-bit `usize` (wasm32). + let sizes = w.checked_mul(hh).zip(h.row_bytes().checked_mul(hh)); let canvas = Rect::new(0, 0, h.width as i32, h.height as i32); // The merged composite is decoded only when something consumes it: a flattened or // multichannel file (it *is* the image), or extra alpha/spot channels behind the colour @@ -821,12 +903,13 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } if doc.layers.is_empty() && let Some(Ok(all)) = &merged + // Always `Some` here: a decoded 8/16/32-bit plane holds at least `w * hh` bytes. + && let Some((n, plane)) = sizes { // Flattened file: the merged image becomes the background layer. - let plane = h.row_bytes() * hh; let alpha_idx = if file.merged_has_alpha() { Some(cc) } else { None }; - let mut planes: Vec> = (0..cc).map(|c| all.get(c * plane..(c + 1) * plane)).collect(); - planes.push(alpha_idx.and_then(|a| all.get(a * plane..(a + 1) * plane))); + let mut planes: Vec> = (0..cc).map(|c| merged_plane(all, c, plane)).collect(); + planes.push(alpha_idx.and_then(|a| merged_plane(all, a, plane))); let mut fill = vec![zero_sample(depth); cc]; fill.push(max_sample(depth)); let mut inv = vec![cx.cmyk; cc]; @@ -882,47 +965,32 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); } - let rgba = file.composite_rgba8().or_else(|_| { - // Multichannel: show the first channels as RGB. - let all = merged.clone().ok_or_else(|| photocraft_psd::PsdError::Invalid("no merged image".into()))??; - let plane = h.row_bytes() * hh; - let mut data = vec![255u8; n * 4]; - for c in 0..3.min(usize::from(h.channels)) { - let p = photocraft_psd::pixels::plane_to_u8(&all[c * plane..(c + 1) * plane], h.depth, w, hh)?; - for i in 0..n { - data[i * 4 + c] = p[i]; - } + match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { + Ok(img) => { + let mut s = Surface::new(fmt); + write_rgba8(&mut s, canvas, &img.data); + drop(img); + s.prune(); + doc.layers.push(flat_background(s)); } - Ok::<_, photocraft_psd::PsdError>(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) - }); - if let Ok(img) = rgba { - let mut s = Surface::new(fmt); - let vals: Vec = img - .data - .as_chunks::<4>() - .0 - .iter() - .flat_map(|p| { - let v = photocraft_raster::from_rgba(&fmt, [p[0], p[1], p[2], p[3]].map(|x| f32::from(x) / 255.0)); - v.into_iter() - }) - .collect(); - s.write_region(canvas, &vals); - s.prune(); - doc.layers.push(flat_background(s)); + // A merged image that failed to decode was reported above. + Err(_) if matches!(merged, Some(Err(_))) => {} + Err(e) => cx.warn(format!("the image could not be read: {e}")), } } // Extra (alpha / spot) channels of the merged image. - if layered && let Some(Ok(all)) = &merged { + if layered + && let Some(Ok(all)) = &merged + && let Some((n, plane)) = sizes + { 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 Some(p) = merged_plane(all, idx, plane) else { break }; let mut s = Surface::new(cx.mask_fmt); s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); s.prune(); @@ -962,3 +1030,54 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) ctl.progress(1.0); Some((doc, cx.warnings)) } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_psd::{Compression, PsdError, Version, testgen}; + + /// #5200: the Multichannel fallback checks its RGBA output against the budget before + /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). + #[test] + fn flat_fallback_refuses_an_output_over_the_cap() { + let file = testgen::merged_only(Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 2, 2); + assert!(file.composite_rgba8().is_err(), "Multichannel goes through the fallback"); + let merged = file.decode_merged(); + assert!(matches!(flat_composite(&file, Some(&merged), 15), Err(PsdError::LimitExceeded(_)))); + let img = flat_composite(&file, Some(&merged), 16).unwrap(); + assert_eq!(img.data.len(), 16); + } + + /// #5200: a composite over the budget is not retried by the fallback (which allocated the + /// refused buffer: 14.4 GB for this 60000x60000 Bitmap header). + #[test] + fn flat_fallback_does_not_retry_an_over_budget_composite() { + let mut file = testgen::small(Version::Psb, Compression::Raw); + file.header.color_mode = PsdMode::Bitmap; + file.header.depth = 1; + file.header.channels = 1; + file.header.width = 60_000; + file.header.height = 60_000; + let merged = Ok(vec![0u8; 16]); + let cap = photocraft_psd::compression::MAX_DECODED_BYTES; + assert!(matches!(flat_composite(&file, Some(&merged), cap), Err(PsdError::LimitExceeded(_)))); + } + + /// #5202: a hostile name length ends the list instead of overflowing. + #[test] + fn unicode_names_stop_at_a_hostile_length() { + let mut data = 1u32.to_be_bytes().to_vec(); + data.extend([0, b'A']); + data.extend(u32::MAX.to_be_bytes()); + data.extend([0, b'B']); + assert_eq!(unicode_names(&data), ["A"]); + } + + #[test] + fn merged_plane_checks_offsets() { + let all = [1u8, 2, 3, 4]; + assert_eq!(merged_plane(&all, 1, 2), Some(&[3u8, 4][..])); + assert_eq!(merged_plane(&all, 2, 2), None); + assert_eq!(merged_plane(&all, usize::MAX, 2), None); + } +} From 0eb008ce5ddf2d40e52262f045fe815a6a15e60a Mon Sep 17 00:00:00 2001 From: Wil <77077912+GamerWil26@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:46:31 +0800 Subject: [PATCH 059/167] Actions: record layer selections by name and display the target (#2441) * Record action layer selections by name and show their targets * Use document pixel format in grouped-layer regression * Document the named layer action UI with a synthetic capture --- crates/engine/src/actions_cmds.rs | 103 ++++++++++++++++++++++++++ crates/engine/src/commands.rs | 2 +- crates/engine/src/jobs.rs | 2 + crates/engine/src/layer_multi_cmds.rs | 44 ++++++++++- crates/ui-egui/src/actions.rs | 28 +++++++ docs/control-protocol.md | 13 +++- 6 files changed, 189 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 3b3ffd1f01..7ae557c48a 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -957,6 +957,109 @@ mod tests { } } + #[test] + fn recorded_layer_selection_replays_by_name_on_another_document() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + let original = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + s.execute("actions.record", json!({"name": "Duplicate photo"})).unwrap(); + s.execute("layer.select", json!({"layer": original.0, "mode": "replace"})).unwrap(); + // The target name must be captured now, not when recording stops. + s.execute("layer.setProps", json!({"name": "Retouched"})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo", "mode":"replace"})); + // Persisted actions must work in an entirely different session too. + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + replay.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + replay.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + let target = replay.active().unwrap().active_layer.unwrap(); + assert_ne!(original, target); + replay.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(result["ran"], 3); + assert_eq!(replay.active().unwrap().doc.layer(target).unwrap().name, "Retouched"); + let active = replay.active().unwrap().active_layer.unwrap(); + assert_eq!(replay.active().unwrap().doc.layer(active).unwrap().name, "Retouched copy"); + } + } + + #[test] + fn named_selection_preserves_modes_and_duplicate_occurrences() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.setProps", json!({"name":"Photo"})).unwrap(); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.group", json!({"name":"Group"})).unwrap(); + // A duplicate nested in a group must be found even when that group is collapsed. + let second = s + .edit("child", |doc, active| { + let child = photocraft_doc::Layer::raster("Photo", doc.pixel_format()); + let id = child.id; + if let photocraft_doc::LayerContent::Group(group) = &mut doc.layer_mut(active.unwrap()).unwrap().content { + group.children.push(child); + group.expanded = false; + } + Ok(id) + }) + .unwrap(); + s.execute("actions.record", json!({"name":"Select duplicates"})).unwrap(); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + s.execute("layer.select", json!({"layer":second.0,"mode":"add"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo","occurrence":0})); + assert_eq!(s.actions.list[0].steps[1].1, json!({"name":"Photo","occurrence":1,"mode":"add"})); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + let r = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(s.active().unwrap().selected_layers(), vec![first, second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"toggle"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"range"})).unwrap(); + assert!(s.active().unwrap().selected_layers().contains(&second)); + // Main's imported .atn steps use a string-valued `layer` and choose the topmost match. + s.execute("layer.select", json!({"layer":"Photo"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"add"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + // Removing an unselected layer does not add it; removing the last target keeps it. + s.execute("layer.select", json!({"layer":"Photo","mode":"remove"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + for p in [ + json!({"name":"absent"}), + json!({"name":"Photo"}), + json!({"name":1}), + json!({"name":"Photo","occurrence":-1}), + json!({"name":"Photo","occurrence":1.5}), + json!({"name":"Photo","occurrence":99}), + json!({"name":"Photo","layer":first.0}), + json!({"layer":first.0,"occurrence":0}), + ] { + let before = s.active().unwrap().selected_layers(); + assert!(s.execute("layer.select", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().selected_layers(), before); + } + s.actions.list.push(Action { + name: "Missing".into(), + steps: vec![("layer.select".into(), json!({"name":"absent"})), ("layer.duplicate".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let before = s.active().unwrap().doc.walk().len(); + let result = s.execute("actions.play", json!({"action":"Missing"})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result["failed"]["error"].as_str().unwrap().contains("absent")); + assert_eq!(s.active().unwrap().doc.walk().len(), before); + } + #[test] fn play_stops_at_the_failing_step_and_reports_it() { let mut s = Session::new(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 923f049643..3969af0576 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -690,7 +690,7 @@ fn build() -> Vec { "Select Layer", [], None, - r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, + r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, has_doc, crate::layer_multi_cmds::select ), diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index dc43f4d794..2ad21c4367 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -724,6 +724,8 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { + let params = + if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; self.journal.push((id.to_string(), params)); } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2accc4ea86..9a79a72050 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -100,6 +100,48 @@ pub(crate) fn reselect(s: &mut Session, ids: Vec, active: Option Value { + let Some(id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; + let Some(st) = s.active() else { return p }; + let Some(layer) = st.doc.layer(id) else { return p }; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).map(|(_, _, l)| l.id).collect(); + let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p }; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + fields.insert("name".into(), json!(layer.name)); + if matches.len() > 1 { + fields.insert("occurrence".into(), json!(occurrence)); + } + } + p +} + +fn selection_target(s: &Session, p: &Value) -> Result { + let Some(name) = p.get("name") else { + if p.get("occurrence").is_some() { + return Err(bad("layer.select", "`occurrence` requires `name`")); + } + return crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "needs `layer` or `name`")); + }; + if p.get("layer").is_some() { + return Err(bad("layer.select", "use either `layer` or `name`, not both")); + } + let name = name.as_str().ok_or_else(|| bad("layer.select", "`name` must be a string"))?; + let occurrence = p + .get("occurrence") + .map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("layer.select", "`occurrence` must be a non-negative integer"))) + .transpose()?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).collect(); + if matches.len() > 1 && occurrence.is_none() { + return Err(bad("layer.select", format!("multiple layers named `{name}`; specify `occurrence`"))); + } + let index = occurrence.unwrap_or(0); + matches.get(index).copied().ok_or_else(|| bad("layer.select", format!("no layer named `{name}` at occurrence {index}"))) +} + /// Layer rows follow the Layers panel's visual order, not Document::walk's storage order. /// Closed groups hide their children unless both endpoints were selected programmatically. fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec) { @@ -115,7 +157,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`. pub fn select(s: &mut Session, p: &Value) -> Result { - let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?; + let id = selection_target(s, p)?; 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() { diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index e0f3713f2e..17d324c606 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -250,6 +250,14 @@ 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 == "layer.select" { + let target = params + .get("name") + .and_then(Value::as_str) + .or_else(|| params.get("layer").and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| params.get("layer").and_then(Value::as_u64).map(|id| format!("ID {id}"))); + target.map_or_else(|| label_of(id), |target| 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("?"); @@ -911,6 +919,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn selected_layer_name_is_visible_during_recording_and_after_stop() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4})).unwrap(); + app.run("layer.setProps", json!({"name":"Background"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("actions.record", json!({"name":"Select photo"})).unwrap(); + app.ui.actions.expanded = vec![true]; + app.run("layer.select", json!({"layer":layer.0})).unwrap(); + let mut h = Harness::builder().with_size(vec2(400.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + assert_eq!(step_label("layer.select", &json!({"layer":26})), "Select Layer (ID 26)"); + assert_eq!(step_label("layer.select", &json!({"layer":"Background"})), "Select Layer (Background)"); + } + #[test] fn actions_list_grows_with_panel_and_keeps_footer_visible() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1cf47c17a6..9b49cd9b36 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -100,7 +100,7 @@ store expose and persist the same setting; scripts keep using canonical command |---|---| | `file.new` | `{"width":1920,"height":1080,"mode":"rgb","depth":8,"background":"white"}` | | `layer.new.layer` | `{"name":"Ink"}` | -| `layer.select` | `{"layer":id,"mode":"replace\|toggle\|range\|add"}`: ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | +| `layer.select` | `{"layer":id\|str,"mode":"replace\|toggle\|range\|add\|remove"}` or `{"name":"Background","occurrence":0?}` (occurrence is zero-based document walk order; required for duplicate names): ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | | `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel | | `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | | `layer.fill.solidColor.set` | `{"layer":id?,"document":id?,"color":"#rrggbb"}`: edit a Solid Color Fill in one undo step; RGB without alpha preserves the fill's alpha. Float RGB arrays and a stored `Color` are also accepted. `document` rejects stale dialog targets. | @@ -547,6 +547,17 @@ visibility reset when the dialog opens. HDR scopes are not implemented. See ### Editing and composing actions +Newly recorded Select Layer steps store the layer name instead of a document-specific ID, +and the Actions panel shows `Select Layer (Background)`. Duplicate names include a zero-based +`occurrence` in document walk order (including layers inside groups); keep that order consistent +between source and target documents. A missing name or occurrence stops playback; a name without +an occurrence is rejected when ambiguous. Names are captured at selection time, so a later +rename in the action does not change the earlier target. Numeric `layer` IDs remain supported +for direct commands and older recordings, which the panel labels `Select Layer (ID 26)`. +String-valued `layer` targets from imported Photoshop actions keep main's topmost-name matching +and show the same named label; `remove` selection mode is supported for either target form. +Re-record old Select Layer steps: their original names cannot be recovered from an ID alone. + Creating an action scrolls its row into view; newly recorded steps are revealed immediately. Drag a step above or below another step in the same action to reorder it. Drag an action header to reorder the action list. The insertion line marks the drop position; dragging near the rows' From 8fb32981611a1bdb34ff68ca7e52169f5975b732 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 18:46:38 +0200 Subject: [PATCH 060/167] Color Balance: fold the tones into one Levels per channel (#3423) (#5339) Photoshop doesn't apply the shadows, midtones and highlights curves one after another: per channel it builds a single Levels (black point from shadows, white point from highlights, one midtone gamma 2^((midtones + (shadows + highlights) / 2) / 100)), and a gamma > 1 starts with slope 2^gamma at black instead of a pure power curve. Applying the tones in sequence let a lowered shadows and a raised highlights slider compound their gammas (up to 85/255 off on mixed settings), and the pure power curve over-brightened the shadows of the issue's red +100 / magenta -100 repro by up to 16/255. Measured against Photoshop on generated 16-bit ramps and pure colours: worst error 0.8/255 (was 85/255), the issue's repro 0.02/255 (was 16.5). Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/adjust.rs | 170 +++++++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 48 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 726b375e36..3c40d7c595 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -594,61 +594,63 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f } } -/// How far one Color Balance slider at ±100 moves the black or white point (Photoshop 27.11: -/// shadows −100 maps 99.6 of 255 to black, highlights +100 maps 155.5 to white). -const BALANCE_LEVELS: f32 = 0.39; - /// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the /// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue, /// −100..100). /// -/// Reverse-engineered from Photoshop 27.11 (a gray ramp and pure colours through each slider at -/// ±25, ±50 and ±100, with Preserve Luminosity on and off): every slider is a levels-and-gamma -/// curve per channel, exact within 1 level for a single slider. With `a` = slider / 100: -/// - Preserve Luminosity off: a slider only moves its own channel. Midtones is gamma 2^−a. -/// Shadows is gamma 2^(−a/2) after a black point at 0.39·|a| when lowered; highlights is -/// gamma 2^(−a/2) after a white point at 1 − 0.39·a when raised. -/// - Preserve Luminosity on: midtones uses gamma 2^((max(a) + min(a))/2 − a_ch), -/// preserving the one-slider behaviour without summing offsets when multiple sliders move. -/// Shadows and highlights go by the differences between a tone's three sliders: -/// each channel gets a plain black point at 0.39·(max − its own) for shadows and a plain white -/// point at 1 − 0.39·(its own − min) for highlights (so one raised shadows slider darkens the -/// other two channels, and two raised together darken only the third). +/// Reverse-engineered from Photoshop (a 16-bit gray ramp through every tone at ±10..±100, pairs of +/// tones, and random settings of all nine sliders, Preserve Luminosity on and off): the three +/// tones fold into one Levels per channel (input black point, input white point, midtone gamma), +/// within 0.8 of 255 everywhere. Folding matters: a lowered shadows and a raised highlights +/// slider cancel each other's gamma, which applying the tones one after another doesn't (#3423). +/// - Preserve Luminosity off: a slider only moves its own channel. Lowered shadows set the black +/// point to |shadows| levels, raised highlights set the white point to 255 − highlights levels, +/// and the gamma is 2^(m / 100) with m = midtones + (shadows + highlights) / 2. +/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white +/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones. /// -/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one -/// channel: within 1 level). +/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter +/// near black without it). When the black point reaches the white point, input +/// at or above the white point is white and the rest black. pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 { - let [shadows, midtones, highlights] = tones; - let black = |v: f32, b: f32| ((v - b) / (1.0 - b)).max(0.0); - let white = |v: f32, w: f32| (v / w).min(1.0); - let mut v = v.clamp(0.0, 1.0); - for (tone, sliders) in [(2, highlights), (0, shadows), (1, midtones)] { - let a: [f32; 3] = sliders.map(|s| (s / 100.0).clamp(-1.0, 1.0)); - if preserve_luminosity { - let own = a.get(ch).copied().unwrap_or(0.0); - let (lo, hi) = a.iter().fold((f32::MAX, f32::MIN), |m, x| (m.0.min(*x), m.1.max(*x))); - v = match tone { - 0 => black(v, BALANCE_LEVELS * (hi - own)), - 1 => v.powf(2f32.powf((hi + lo) / 2.0 - own)), - _ => white(v, 1.0 - BALANCE_LEVELS * (own - lo)), - }; - continue; - } - for (src, slider) in sliders.iter().enumerate() { - let a = (slider / 100.0).clamp(-1.0, 1.0); - if a == 0.0 || src != ch { - continue; - } - v = match tone { - 1 => v.powf(2f32.powf(-a)), - 0 if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)), - 0 => v.powf(2f32.powf(-a / 2.0)), - 2 if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)), - _ => v.powf(2f32.powf(-a / 2.0)), - }; - } + let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 })); + let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0); + let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]); + let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]); + let (black, white, m) = if preserve_luminosity { + (max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0) + } else { + ((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0) + }; + let (b, w) = (black / 255.0, 1.0 - white / 255.0); + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if v >= w { + return 1.0; } - v + if v <= b { + return 0.0; + } + balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0)) +} + +/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except +/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the +/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop, +/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255). +fn balance_gamma(u: f32, gamma: f32) -> f32 { + let e = 1.0 / gamma.max(0.01); + let power = u.powf(e); + if gamma <= 1.0 { + return power; + } + let s0 = 2f32.powf(gamma); + let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0); + if u0.is_nan() || u0 <= 0.0 || u >= u0 { + return power; + } + let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0); + let (t2, t3) = (t * t, t * t * t); + ((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0) } /// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans, @@ -1257,6 +1259,78 @@ mod tone_tests { } } + /// Photoshop, measured (8-bit gray ramp, every channel): whole settings, where the tones fold + /// into one Levels per channel (#3423). The issue's midtones red +100 / magenta −100 (the + /// shadow toe), its sequoia14 layer with Preserve Luminosity off and on, all nine sliders with + /// Preserve Luminosity on, a black point past the white point, and lowered shadows with raised + /// highlights (their gammas cancel: a straight line between the two points). + #[test] + fn color_balance_whole_settings_match_photoshop() { + const X: [u8; 12] = [0, 4, 8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + type Case = ([[f32; 3]; 3], bool, [[u8; 12]; 3]); + let cases: [Case; 6] = [ + ( + [[0.0; 3], [100.0, -100.0, 0.0], [0.0; 3]], + false, + [[0, 16, 30, 55, 90, 128, 156, 181, 202, 221, 239, 255], [0, 0, 0, 1, 4, 16, 36, 64, 100, 145, 197, 255], X], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + false, + [ + [0, 0, 2, 9, 25, 56, 89, 122, 156, 188, 222, 255], + [0, 5, 9, 18, 35, 67, 100, 131, 163, 194, 225, 255], + [0, 3, 7, 14, 29, 60, 91, 123, 156, 189, 222, 255], + ], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + true, + [ + [0, 0, 0, 0, 5, 42, 80, 117, 154, 189, 226, 255], + [0, 4, 8, 16, 33, 66, 98, 130, 162, 195, 226, 255], + [0, 0, 0, 0, 15, 48, 82, 116, 151, 186, 221, 255], + ], + ), + ( + [[40.0, -30.0, 20.0], [-50.0, 25.0, 60.0], [30.0, -70.0, 10.0]], + true, + [ + [0, 1, 3, 9, 25, 69, 126, 193, 255, 255, 255, 255], + [0, 0, 0, 0, 0, 0, 46, 93, 136, 177, 217, 255], + [0, 0, 0, 0, 44, 107, 156, 199, 238, 255, 255, 255], + ], + ), + ( + [[100.0, -100.0, 0.0], [0.0; 3], [-100.0, 100.0, 0.0]], + true, + [X, [0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 0, 0, 0, 130, 255, 255, 255, 255]], + ), + ( + [[-76.0, -2.0, 60.0], [0.0; 3], [99.0, 0.0, -30.0]], + false, + [ + [0, 0, 0, 0, 0, 0, 71, 171, 255, 255, 255, 255], + [0, 2, 6, 14, 29, 61, 94, 126, 158, 191, 224, 255], + [0, 6, 11, 21, 39, 73, 106, 137, 168, 197, 227, 255], + ], + ), + ]; + for (tones, preserve, want) in cases { + for (ch, row) in want.iter().enumerate() { + for (x, w) in X.iter().zip(row) { + let got = (color_balance_channel(tones, preserve, ch, f32::from(*x) / 255.0) * 255.0).round() as i32; + assert!((got - i32::from(*w)).abs() <= 1, "{tones:?} pl {preserve} ch {ch} at {x}: {got} vs Photoshop {w}"); + } + } + } + // Hostile input stays in range. + for v in [f32::NAN, -1.0, 2.0, f32::INFINITY] { + let out = color_balance_channel([[f32::NAN, 1e9, -1e9]; 3], true, 7, v); + assert!((0.0..=1.0).contains(&out), "{v}: {out}"); + } + } + #[test] fn color_balance_midtones_with_multiple_sliders_preserve_luminosity() { // A shared offset depends on the two extremes, not the sum of moved sliders. From 88b2a21b335fd01802031be056bef8f220fb7aeb Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:46:44 +0200 Subject: [PATCH 061/167] Linked smart objects: read embedded WebP/GIF/BMP/EXR sizes from their headers (#5216) source_size_probe decoded every embedded file other than PSD/TIFF/PNG/JPEG in full just to learn its size. photocraft_codecs::probe_size reads the size from the same decoders' headers (WebP chunks, the image crate's GIF/BMP/TGA/ICO/QOI/HDR headers, flat EXR part headers), with the same limit checks and the EXIF quarter-turn swap, so it agrees with decode wherever decode succeeds. Deep EXR, AVIF and anything else still decode. Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/exr.rs | 15 +++++++++++++++ crates/codecs/src/codecs/via_image.rs | 22 ++++++++++++++++++++-- crates/codecs/src/codecs/webp.rs | 17 ++++++++++++++++- crates/codecs/src/lib.rs | 21 +++++++++++++++++++++ crates/codecs/tests/orientation.rs | 22 ++++++++++++++++++++++ crates/io/src/linked.rs | 17 +++++++++-------- 6 files changed, 103 insertions(+), 11 deletions(-) diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 2506dd062b..a55810b4b8 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -187,6 +187,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their +/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> { + let meta = read_meta(bytes, limits)?; + if meta.requirements.has_deep_data { + return Err(CodecError::unsupported(F, "no header-only size probe for deep data")); + } + let pick = pick_part(&meta); + let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?; + if info.color_rank() == 0 { + return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); + } + Ok((info.width, info.height)) +} + /// Decode one part of a multi-part file by its index into [`info`]'s list. A file with /// deep parts is not mixed: the deep decoder composites those instead. pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result { diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index c91be5baf7..7e7eb76b2f 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -37,7 +37,8 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result>, CodecErr Ok(decoder.get_palette().map(|palette| palette.to_vec())) } -pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { +/// The `image` decoder for `f` with our limits, its header read and its size checked. +fn open<'a>(f: Format, bytes: &'a [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); let mut il = image::Limits::default(); @@ -45,9 +46,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result { + let mut dec = open(f, bytes, limits)?; let icc = dec.icc_profile().ok().flatten(); let exif = dec.exif_metadata().ok().flatten(); let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?; @@ -60,6 +66,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result<(u32, u32, Option>), CodecError> { + if f == Format::Avif { + return Err(CodecError::unsupported(f, "no header-only size probe")); + } + let mut dec = open(f, bytes, limits)?; + let (w, h) = dec.dimensions(); + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + /// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any. /// The total is unknown when the blocks end before the trailer or break off. fn gif_more_frames(b: &[u8]) -> Option { diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index 36473041dd..b7b6e2a668 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } -pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { +type Decoder<'a> = image_webp::WebPDecoder>; + +/// The decoder with its chunks read, the stored size and the output layout, the size checked. +fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> { let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?; dec.set_memory_limit(limits.alloc_usize()); let (w, h) = dec.dimensions(); @@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // 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)?; + Ok((dec, w, h, layout)) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let (mut dec, w, h, layout) = open(bytes, limits)?; let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; let mut buf = alloc_zeroed::(size)?; dec.read_image(&mut buf).map_err(err)?; @@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the +/// same header reads and size checks, without decoding the bitstream. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option>), CodecError> { + let (mut dec, w, h, _) = open(bytes, limits)?; + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); if !opts.webp_lossless { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 438e1857e4..3831cf654f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -102,6 +102,27 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res finish_decode(img, o, opts) } +/// The upright pixel size [`decode`] would return, read from the file's header without decoding +/// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same +/// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an +/// EXIF orientation of 5–8, so wherever [`decode`] succeeds the two agree. Other formats (and +/// deep EXR or AVIF) return [`CodecError::Unsupported`]: decode those to learn their size. +pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let format = detect(bytes).ok_or(CodecError::UnknownFormat)?; + if !caps(format).read { + return Err(CodecError::unsupported(format, "decoding is not available for this format")); + } + let l = &Limits::default(); + let (w, h, exif) = match format { + Format::WebP => webp::probe(bytes, l)?, + Format::OpenExr => exr::probe(bytes, l).map(|(w, h)| (w, h, None))?, + Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::probe(format, bytes, l)?, + _ => return Err(CodecError::unsupported(format, "no header-only size probe")), + }; + let o = exif.as_deref().map_or(1, exif_orientation); + Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) +} + /// 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) diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/orientation.rs index 9808330790..c1f176b87c 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/orientation.rs @@ -236,6 +236,7 @@ fn webp_opens_upright() { // WebP has no gray mode: it comes back as RGB. let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8); assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}"); + assert_eq!(probe_size(&out).unwrap(), (w as u32, h as u32), "the header probe turns upright too, o={o}"); } } @@ -554,3 +555,24 @@ fn long_orientation_reads_and_rewrites_in_both_byte_orders() { assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]); } } + +/// `probe_size` reads the size `decode` returns from the header alone, for every format it +/// covers, and a file cut short inside its header is refused rather than given a size. +#[test] +fn probe_size_matches_decode() { + let img = synth(7, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.0); + let probed = [Format::WebP, Format::Gif, Format::Bmp, Format::Tga, Format::Qoi, Format::Hdr, Format::Ico, Format::OpenExr]; + assert!(probed.iter().all(|f| rw_formats().contains(f))); + for format in rw_formats() { + let bytes = encode(&img, format, &EncodeOptions::default()).unwrap(); + let decoded = decode(&bytes).unwrap().dimensions(); + match probe_size(&bytes) { + Ok(size) => assert_eq!(size, decoded, "{format:?}"), + Err(_) => assert!(!probed.contains(&format), "{format:?} should be probed"), + } + if probed.contains(&format) { + assert!(probe_size(&bytes[..10]).is_err(), "{format:?} cut inside its header"); + } + } + assert!(probe_size(&[]).is_err()); +} diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index 6634735ef0..e200b875ea 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -136,9 +136,11 @@ pub(crate) fn source_size_in_blocks<'a>( } /// 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). +/// directory), PNG (`IHDR`) and JPEG (the frame header); WebP, GIF, BMP, EXR and the rest of +/// `photocraft_codecs::probe_size`'s formats from their decoder's header; anything else (or a +/// header that probe cannot read) 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)); @@ -151,10 +153,7 @@ pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { } [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) - }), + _ => photocraft_codecs::probe_size(bytes).or_else(|_| photocraft_codecs::decode(bytes).map(|img| img.dimensions())).ok().map(|(w, h)| size(w, h)), } } @@ -408,9 +407,11 @@ mod tests { 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] { + for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp, Format::WebP, Format::Gif, Format::OpenExr, Format::Tga] { let bytes = photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap(); assert_eq!(source_size_probe(&bytes), Some((7.0, 5.0)), "{format:?}"); + // A header cut short neither panics nor invents a size (the decode fallback fails too). + assert_eq!(source_size_probe(&bytes[..10]), None, "{format:?} truncated"); } let mut psd = b"8BPS\0\x01".to_vec(); psd.extend([0; 6]); From 2d5e60ad51f5edfc33201d1f50d89f02e0a58cd8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:46:51 +0200 Subject: [PATCH 062/167] Compose: build a layer's effect-map identity once per render, not once per tile (#4437) (#4438) effect_maps hashed the whole layer (Debug of effects and vector mask, a walk over every pixel and mask tile, every child of a group) on every call, i.e. once per render tile per effect layer. The identity depends on the layer only; memoise it in the render's Ctx for layers that are part of the document being rendered (their addresses cannot be reused during the render). Co-authored-by: Claude Opus 5.5 --- crates/compose/src/lib.rs | 58 ++++++++++++++++++++++++++++++- crates/compose/src/tests.rs | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8813b122d9..c53a224d63 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -326,6 +326,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { fx_maps: Default::default(), fx_clip: Default::default(), doc: None, + layer_keys: Default::default(), }, advanced::Scope::ROOT, ); @@ -523,6 +524,60 @@ struct Ctx<'a> { fx_clip: std::sync::Mutex>, /// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer. doc: Option<&'a Document>, + /// Effect-map identities of this render's document layers (see [`LayerKeys`]). + layer_keys: LayerKeys, +} + +/// Memo of [`layer_identity`] for the layers of the document being rendered. The identity depends +/// on the layer alone, not on the tile, so it is computed once per layer per render instead of +/// once per tile. Only layers that sit in the document tree are memoised by address: the document +/// is borrowed for the whole render, so no other object can live at such an address. Temporary +/// copies (`advanced::effects_source`) are never in the tree and always take the full path. +#[derive(Default)] +struct LayerKeys { + /// Addresses of every layer in the document tree, collected on first use. + in_doc: std::sync::OnceLock>, + ids: std::sync::Mutex>, + /// Identities computed (tests count them). + #[cfg(test)] + built: std::sync::atomic::AtomicUsize, +} + +impl LayerKeys { + fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 { + let addr = std::ptr::from_ref(layer) as usize; + let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr)); + if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) { + return *id; + } + let id = self.compute(layer); + if in_doc { + self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id); + } + id + } + + fn compute(&self, layer: &Layer) -> u64 { + use std::hash::Hasher; + #[cfg(test)] + self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut h = std::collections::hash_map::DefaultHasher::new(); + layer_identity(layer, &mut h); + h.finish() + } +} + +/// Addresses of all layers of `doc` (groups' children included). +fn doc_layer_addresses(doc: &Document) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + let mut todo: Vec<&Layer> = doc.layers.iter().collect(); + while let Some(l) = todo.pop() { + out.insert(std::ptr::from_ref(l) as usize); + if let LayerContent::Group(g) = &l.content { + todo.extend(g.children.iter()); + } + } + out } impl<'a> Ctx<'a> { @@ -538,6 +593,7 @@ impl<'a> Ctx<'a> { vector_masks: RenderVectorMasks::default(), fx_maps: Default::default(), fx_clip: Default::default(), + layer_keys: Default::default(), } } @@ -1603,7 +1659,7 @@ fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } +// ---------- Effect-map identity is computed once per layer per render (#4437) ---------- + +/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled +/// child, and a layer whose mask hides its effects (rendered from a temporary copy). +fn identity_doc(depth: SampleType) -> Document { + let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE); + let fmt = d.pixel_format(); + let mut sq = Layer::raster("sq", fmt); + sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]); + sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))]; + d.layers.push(sq); + let mut child = Layer::raster("child", fmt); + child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]); + child.effects.items = vec![stroke(2.0, StrokePosition::Outside)]; + let mut group = Layer::group("g", vec![child]); + group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))]; + d.layers.push(group); + let mut hidden = Layer::raster("hidden", fmt); + hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]); + hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)]; + hidden.mask = Some(LayerMask::reveal_all()); + hidden.advanced.layer_mask_hides_effects = true; + d.layers.push(hidden); + d +} + +#[test] +fn memoised_effect_identity_leaves_every_render_unchanged() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = identity_doc(depth); + let b = d.bounds(); + // One tile for the whole canvas never goes through `prepare_effects`; small tiles do. + purge_effect_cache(); + let whole = render_tiled(&d, b, 4096); + purge_effect_cache(); + let tiled = render_tiled(&d, b, 16); + let warm = render_tiled(&d, b, 24); + assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile"); + assert!(whole.px == warm.px, "{depth:?}: warm differs from cold"); + assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw"); + // Edits still invalidate: a changed layer must not keep its old maps. + let mut e = d.clone(); + e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))]; + let edited = render_tiled(&e, b, 16); + purge_effect_cache(); + assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit"); + assert!(edited.px != whole.px, "{depth:?}: the edit should show"); + } +} + +#[test] +fn effect_identity_is_built_once_per_layer_not_per_tile() { + let mut d = identity_doc(SampleType::U8); + // Leave out the temporary-copy layer: it is rebuilt per call by design. + d.layers.pop(); + let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&d, &patterns); + let b = d.bounds(); + let tiles = 6 * 6; + let first = render_tiled_with(&d, b, 16, &cx); + let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed); + // sq, the group and its child: three effect layers, however many tiles. + assert_eq!(built, 3, "identity built {built} times over {tiles} tiles"); + // A second render with the same context finds all of them memoised. + let second = render_tiled_with(&d, b, 16, &cx); + assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3); + assert!(first.px == second.px); +} + #[test] fn render_refuses_rects_beyond_the_pixel_cap() { let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); From b8ae1cd92fccacfc5b30566e64c22bdebe0a75eb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:47:23 +0700 Subject: [PATCH 063/167] fix(brush): preserve Dual Brush tip spacing and scale its size with the primary brush (#4856) * fix(abr): prefer dual brush tip spacing over wrapper spacing * test(abr): cover conflicting dual brush spacing descriptors * fix(paint): scale secondary brush tip when changing primary diameter * fix(engine): preserve dual tip proportions through brush commands * test(brush): cover secondary tip scaling across settings, strokes and presets * style: apply rustfmt to Dual Brush ABR code * style: apply rustfmt to Dual Brush ABR code --- crates/engine/src/brush_cmds.rs | 7 +++++- crates/engine/src/brush_cmds/tests.rs | 32 +++++++++++++++++++++++++++ crates/io/src/abr_map/tests.rs | 23 +++++++++++++++++++ crates/paint/src/brush.rs | 11 ++++++++- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 370b6fcc78..5c1942441a 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -143,6 +143,11 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result Result // A library preset's tips stay on disk until a stroke uses them (#1843). s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; if let Some(v) = num(p, "size") { - b.size = v.max(0.5); + b.resize_with_dual_brush(v.max(0.5)); } if let Some(v) = num(p, "hardness") { b.hardness = v.clamp(0.0, 1.0); diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index d91e9ec4d7..f1c8188246 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -349,6 +349,38 @@ fn set_brush_merges_fields() { assert_eq!(s.tools.brush.texture.pattern, chalk_pattern); } +#[test] +fn resizing_a_brush_resizes_its_enabled_secondary_tip() { + let mut s = session(64, 64); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 12}})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 24.0)); + + s.execute("tools.setBrush", json!({"brush": {"size": 20}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (20.0, 6.0)); + + // Editing both diameters in the same patch is an explicit secondary-tip size. + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"size": 18}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 18.0)); + s.execute("tools.setBrush", json!({"dualBrush": {"size": 9}})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 9.0); + + // The stroke-level size override must use the same proportional dimensions. + let for_stroke = resolve_brush(&s, &json!({"size": 80, "points": [[10, 10]]}), "paint.stroke").unwrap(); + assert_eq!((for_stroke.size, for_stroke.dual_brush.size), (80.0, 18.0)); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0)); +} + +#[test] +fn an_uncaptured_preset_size_keeps_dual_brush_proportions() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual", "captureSize": false})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + s.execute("tools.setBrush", json!({"preset": "Dual"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 20.0)); +} + #[test] fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() { let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0); diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 8bea763861..501e331af9 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -177,6 +177,29 @@ fn v6_settings_map_onto_brush_sections() { } } +#[test] +fn dual_brush_tip_spacing_takes_precedence_over_wrapper_spacing() { + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("Close-spaced dual brush")) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 40.0)))) + .with( + "dualBrush", + Value::Descriptor( + Descriptor::new("dualBrush") + .with("useDualBrush", Value::Boolean(true)) + .with("Spcn", prc(100.0)) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 12.0)).with("Spcn", prc(5.0)))), + ), + ); + let bytes = write_v6(2, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Dual").unwrap(); + assert_eq!(imported.presets.len(), 1); + let brush = &imported.presets[0].brush; + assert!(brush.dual_brush.enabled); + assert!((brush.dual_brush.spacing - 0.05).abs() < 1e-6, "the tip's 5% spacing must not be replaced with the wrapper's 100%"); + assert!((brush.spacing - 0.25).abs() < 1e-6, "main brush spacing is independent"); +} + #[test] fn missing_pattern_and_tip_are_reported() { let orphan = Descriptor::new("brushPreset").with("Nm ", t("Orphan")).with("Brsh", Value::Descriptor(sampled_tip("$nowhere"))); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 0dd4d21d65..354dd61d53 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -582,6 +582,15 @@ impl Default for BrushSettings { } impl BrushSettings { + /// Resize the primary tip and keep an enabled Dual Brush tip at the same relative size. + /// An explicit Dual Brush size edit should bypass this and retain the requested diameter. + pub fn resize_with_dual_brush(&mut self, new_size: f32) { + if self.dual_brush.enabled && self.size.is_finite() && self.size > 0.0 && new_size.is_finite() && new_size > 0.0 { + self.dual_brush.size = (self.dual_brush.size * (new_size / self.size)).clamp(0.5, MAX_BRUSH_SIZE); + } + self.size = new_size; + } + /// Copy the settings with the primary and dual diameters and Scatter amounts constrained for /// rasterization. /// @@ -714,7 +723,7 @@ impl BrushPreset { pub fn picked_over(&self, current: &BrushSettings) -> BrushSettings { let mut b = self.brush.clone().picked_over(current); if !self.captures.size { - b.size = current.size; + b.resize_with_dual_brush(current.size); } if !self.captures.tool_settings { b.mode = current.mode; From 72d43599998cc1017db891bd21b2ba4994ad653b Mon Sep 17 00:00:00 2001 From: Zach Schneider Date: Wed, 14 Oct 2026 12:05:26 -0500 Subject: [PATCH 064/167] Font preview test: skip installed fonts whose Arabic glyphs are blank (#5259) (#5261) a_system_arabic_face_renders_both_samples took the first family whose character map claims Latin and Arabic and expected its Arabic sample to render. A font can map those characters to empty glyphs (for example Black Mountain), so cargo test failed on machines that have one installed. The test now renders every candidate and checks the first one that actually draws an Arabic sample, and skips only if none does. --- crates/ui-egui/src/font_preview.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 74c334d682..678da4191c 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -244,14 +244,20 @@ mod tests { fn a_system_arabic_face_renders_both_samples() { let mut engine = photocraft_text::TextEngine::new(); engine.fonts.load_system_fonts(); - let both = engine.fonts.families().into_iter().find(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()); - let Some(family) = both else { - eprintln!("skipped: no installed family covers both Latin and Arabic"); + let candidates: Vec = engine.fonts.families().into_iter().filter(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()).collect(); + // A face can map the Arabic characters to blank glyphs (#5259): `render` then rightly gives + // no sample, so every candidate is rendered and the first one with an Arabic sample is checked. + let mut rendered = None; + for family in &candidates { + if let Some(arabic) = images(&mut engine, family, 1, MEDIUM).and_then(|i| i.arabic) { + rendered.get_or_insert((family.clone(), arabic)); + } + } + let Some((family, arabic)) = rendered else { + eprintln!("skipped: no installed family renders both Latin and Arabic ({} claim both)", candidates.len()); return; }; - let images = images(&mut engine, &family, 1, MEDIUM).expect(&family); - let arabic = images.arabic.expect("Arabic sample"); - assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize]); + assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize], "{family}"); assert!(arabic.pixels.iter().any(|p| p.a() > 0), "{family}"); } } From b37a148759d52542f448f43f2175e2f059c7df59 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:05:47 +0200 Subject: [PATCH 065/167] ui: remove the title-bar Discord button (#1737) Co-authored-by: highperfocused --- crates/ui-egui/src/panels.rs | 27 +++------------------------ crates/ui-egui/src/titlebar.rs | 17 +++++++++++++---- docs/ui-design.md | 2 +- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 733f923c8a..6d0b9b0037 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -646,9 +646,9 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("Pixel Art".to_string(), tl!("Pixel Art")), ("Motion".to_string(), tl!("Motion")), ]; - // A narrow bar drops what is also in a menu, Discord first (below), then - // the theme toggle (Preferences), then search (Edit › Search), and narrows - // the switcher (Window › Workspace) before anything runs over. + // A narrow bar drops what is also in a menu, the theme toggle first + // (Preferences), then search (Edit › Search), and narrows the switcher + // (Window › Workspace) before anything runs over. let room = ui.available_width(); let icons_shown: u8 = if room >= WORKSPACE_MIN + 2.0 * ICON_SLOT { 2 @@ -680,25 +680,6 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } button.context_menu(|ui| crate::prefs_ui::appearance_context_menu(app, ui)); } - // The community Discord, one click away while the bar has room for it - // (narrow windows drop it first; it is also Help › Discord). - if ui.available_width() >= DISCORD_ROOM { - let discord = egui::Button::image_and_text( - icons::image("message-square", 14.0, t.text_dim), - egui::RichText::new(tl!("Discord")).color(t.text_dim).size(12.0), - ) - .frame(false); - if ui - .add(discord) - .on_hover_text({ - let template = "Join the ArtCraft Discord ({url})"; - crate::i18n::fmt(tl!(template), &[("url", crate::links::DISCORD)]) - }) - .clicked() - { - crate::links::open(app, ui.ctx(), crate::links::DISCORD); - } - } if rtl { ui.min_rect().right() } else { ui.min_rect().left() } }; free = if rtl { (controls_end, menus_end) } else { (menus_end, controls_end) }; @@ -755,8 +736,6 @@ pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) { /// Minimum space kept between the window title and the menus or controls beside it. const TITLE_GAP: f32 = 16.0; -/// Free room the title bar needs after its other controls to show the Discord button. -const DISCORD_ROOM: f32 = 120.0; /// The workspace switcher's narrowest width, and one 28 pt title-bar icon with its spacing. const WORKSPACE_MIN: f32 = 90.0; const ICON_SLOT: f32 = 34.0; diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index c45baa2f0d..de10ca30f3 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -646,7 +646,7 @@ mod tests { for width in [760.0, 1100.0, 1440.0] { let mut w = Win::new(width); w.frame(vec![]); - let out = w.frame(vec![]); + w.frame(vec![]); let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}"); // The bar's bottom point is the panel's separator line, which the buttons leave visible. @@ -657,9 +657,18 @@ mod tests { for a in &widgets { assert!(a.right() <= min.left(), "{a:?} runs into the caption buttons at {width}"); } - // Wide windows show the whole group; the narrowest drops Discord (also Help › Discord). - let has_discord = texts(&out).iter().any(|(t, _)| t == "Discord"); - assert_eq!(has_discord, width > 1000.0, "Discord at {width}"); + } + } + + #[test] + fn the_title_bar_promotes_no_community_chat() { + // Issue #1726: only the always-visible title-bar button is removed. The start-screen + // and About buttons and Help › Discord remain available. + for width in [760.0, 1100.0, 1440.0] { + let mut w = Win::new(width); + w.frame(vec![]); + let out = w.frame(vec![]); + assert!(!texts(&out).iter().any(|(t, _)| t.contains("Discord")), "no Discord in the bar at {width}"); } } diff --git a/docs/ui-design.md b/docs/ui-design.md index 96a389d7f7..c6135ff744 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -97,7 +97,7 @@ The app's top bar (`panels::title_bar`) starts with the brand mark (the app icon - **Windows and Linux:** the window has no OS decorations, so there is one bar, not two stacked. The top bar is the title bar (`titlebar.rs`): Minimize, Maximize/Restore and Close (red on hover, `caption_close` tokens) sit flush in the window's top-right corner, the free gap between the menus and the controls drags the window and a double-click there maximizes it, and invisible 5 pt edges (12 pt corners) resize it. Close runs File › Exit, so unsaved documents are asked about first. - **macOS:** the traffic lights sit over the integrated title strip (`integrated_titlebar`). -- **Narrow windows** drop controls that are also in a menu before anything overlaps: Discord (Help › Discord), then the theme toggle, then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). +- **Narrow windows** drop controls that are also in a menu before anything overlaps: the theme toggle (Preferences), then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). ## Compact tool controls From be64d2dcbc338507a9fa0fcab3349273173e83cf Mon Sep 17 00:00:00 2001 From: mjohnson7cd <61306576+mjohnson7cd@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:05:53 -0400 Subject: [PATCH 066/167] fix(windows): handle GPU initialization failures gracefully (#5191) (#5309) On Windows with the automatic backend, GL adapters now rank after every other adapter, WARP included. VMware SVGA3D over GL and GL-only old GPUs drew a black window (#1788, #1647); WARP renders. --- apps/photocraft/src/gpu_startup.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 8632fd2a55..66acb9a42f 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -233,9 +233,10 @@ pub struct Candidate { /// Rank of an adapter (lower is better): high-performance device types first (egui's power /// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for /// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback -/// for presenting the window (see [`backends`]). +/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter, +/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788). fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { - let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan); + let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl)); let ty = match a.device_type { wgpu::DeviceType::DiscreteGpu => 0, wgpu::DeviceType::IntegratedGpu => 1, @@ -635,6 +636,20 @@ mod tests { assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1)); } + /// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12). + /// GL drew a black window; WARP works. GL stays the last resort. + #[test] + fn windows_auto_prefers_warp_over_gl() { + use wgpu::{Backend as B, DeviceType as T}; + let svga_gl = cand(0x15ad, T::Other, B::Gl); + let warp = cand(0x1414, T::Cpu, B::Dx12); + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0)); + // Elsewhere GL keeps its rank. + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0)); + } + #[test] fn adapters_must_present_even_when_none_support_the_surface() { let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12); From 08def9648ddfd4a27c6f05d0c66ec07b87373a51 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:05:59 +0700 Subject: [PATCH 067/167] fix(ui): make Preferences modal resizable on desktop (#5170) --- crates/ui-egui/src/dialogs.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0530031f93..b71443ec17 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -104,6 +104,10 @@ 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 resizable_dialog(fields: &serde_json::Map) -> bool { + fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields) +} + fn show_modal( modal: egui::Modal, ctx: &egui::Context, @@ -182,7 +186,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. 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| { + let modal = show_modal(modal, ctx, id, resizable_dialog(&fields), interactive, |ui| { sizing = ui.is_sizing_pass(); let available = ui.available_size(); // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title @@ -998,3 +1002,21 @@ mod tests { } } } + +#[cfg(test)] +mod preference_resize_tests { + use super::*; + + #[test] + fn preferences_can_resize_while_standard_native_dialogs_keep_their_layout() { + let prefs = json!({"__prefsui": "prefs"}); + let shortcuts = json!({"__prefsui": "shortcuts"}); + let ordinary = json!({}); + let web = json!({"__web": true}); + + assert!(resizable_dialog(prefs.as_object().unwrap())); + assert!(!resizable_dialog(shortcuts.as_object().unwrap())); + assert!(!resizable_dialog(ordinary.as_object().unwrap())); + assert!(resizable_dialog(web.as_object().unwrap())); + } +} From fe8fa3b9bc1627ab818284971b0c0348e9b72466 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 19:06:05 +0200 Subject: [PATCH 068/167] Color Range: sample colours outside the active selection (#5244) (#5296) The eyedroppers (the points/subtractPoints params, and clicks on the canvas while the dialog is open) refused points outside the active selection. Photoshop samples anywhere and only limits the result to the selection, which Color Range already does; the refusal is gone. --- crates/engine/src/selection_cmds.rs | 46 ++++++++++++---------------- crates/ui-egui/src/color_range_ui.rs | 14 +++------ 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index eda55c7d73..d6fc50aeea 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -30,10 +30,6 @@ fn mode(p: &Value) -> SelectionMode { /// A Color Range replacement is scoped to the selection that was active when it opened. /// Explicit add/subtract/intersect operations retain their normal selection-combine semantics. -fn point_in_selection(doc: &Document, x: i32, y: i32) -> bool { - doc.selection.as_ref().is_none_or(|selection| selection.sample_channel(x, y, 0) > 0.0) -} - fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionMode) { if m != SelectionMode::Replace { return; @@ -153,14 +149,7 @@ fn parse_color(p: &Value) -> [f32; 3] { /// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point /// and where it was picked (area-local, for Localized Color Clusters). -fn eyedropper_samples( - p: &Value, - key: &str, - area: Rect, - px: &[[f32; 4]], - allowed: &impl Fn(i32, i32) -> bool, - bad: &impl Fn(String) -> EngineError, -) -> Result> { +fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; let w = area.width() as usize; @@ -178,9 +167,6 @@ fn eyedropper_samples( let Some(&[r, g, b, _]) = q else { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); }; - if !allowed(xi, yi) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); } Ok(out) @@ -251,9 +237,6 @@ impl ColorRangeSamples { if !area.contains(x, y) { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); } - if mode(p) == SelectionMode::Replace && !point_in_selection(doc, x, y) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } let r = Rect::from_xywh(x, y, 1, 1); let pixel = if !b(p, "sampleAllLayers", true) { s.active().and_then(|d| d.active_layer.and_then(|id| d.doc.layer(id))).and_then(|l| l.surface()).map(|surf| { @@ -377,10 +360,10 @@ fn color_range(s: &mut Session, p: &Value) -> Result { let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - let allowed = |x, y| selection_mode != SelectionMode::Replace || point_in_selection(doc, x, y); - let plus = eyedropper_samples(p, "points", area, &px, &allowed, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &allowed, &bad)?; + // Like Photoshop's, they may sample anywhere on the canvas: an active selection only + // limits the result (scope_color_range), not where colours are picked (#5244). + let plus = eyedropper_samples(p, "points", area, &px, &bad)?; + let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); (area, mask) @@ -868,15 +851,24 @@ mod tests { assert_eq!(coverage(&s, 30, 10), 0.0); } + #[test] + fn color_range_eyedropper_samples_anywhere_but_the_result_stays_in_the_selection() { + // #5244: as in Photoshop, a colour picked outside the active selection is accepted; only + // the result is limited to the selection. + let mut s = session(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + let outside = json!({"points": [[30, 10]], "fuzziness": 0}); + assert!(ColorRangeSamples::new(&s, &outside).is_ok()); + s.execute("select.colorRange", outside).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0, "the red inside the selection is selected"); + assert_eq!(coverage(&s, 30, 10), 0.0, "the sampled red outside it stays out"); + assert_eq!(coverage(&s, 2, 2), 0.0, "white is not the sampled colour"); + } + #[test] fn color_range_eyedropper_respects_selection_but_add_can_extend_it() { let mut s = session(); s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); - let before = current_mask(&s.active().unwrap().doc).1; - let outside = json!({"points": [[30, 10]], "fuzziness": 0}); - assert!(s.execute("select.colorRange", outside.clone()).unwrap_err().to_string().contains("outside the active selection")); - assert!(ColorRangeSamples::new(&s, &outside).err().unwrap().to_string().contains("outside the active selection")); - assert_eq!(current_mask(&s.active().unwrap().doc).1, before); s.execute("select.colorRange", json!({"points": [[10, 10]], "fuzziness": 0})).unwrap(); assert_eq!(coverage(&s, 10, 10), 1.0); assert_eq!(coverage(&s, 30, 10), 0.0); diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index 80417eeec8..59b3bfeb2f 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -649,9 +649,6 @@ pub fn pick_top(app: &mut PhotocraftApp, at: [f64; 2], mods: egui::Modifiers) -> if !at[0].is_finite() || !at[1].is_finite() || at[0] < 0.0 || at[1] < 0.0 || at[0] >= f64::from(doc.size.width) || at[1] >= f64::from(doc.size.height) { return false; } - if doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0].floor() as i32, at[1].floor() as i32, 0) <= 0.0) { - return false; - } pick(app, &mut f, at, mods); match app.ui.dialogs.last_mut() { Some(d) => { @@ -708,9 +705,6 @@ pub fn pick(app: &PhotocraftApp, f: &mut Map, at: [f64; 2], mods: let (w, h) = (f64::from(st.doc.size.width.max(1)), f64::from(st.doc.size.height.max(1))); let [x, y] = at; let at = [x.floor().clamp(0.0, w - 1.0), y.floor().clamp(0.0, h - 1.0)]; - if st.doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0] as i32, at[1] as i32, 0) <= 0.0) { - return; - } let tool = if mods.shift { "add" } else if mods.alt { @@ -831,10 +825,12 @@ mod tests { assert_eq!(mask[5 * 40 + 25], 0.0, "even inverted previews stay within the selection"); } + // As in Photoshop, the eyedropper samples anywhere; only the result is scoped (#5244). open(&mut app); - assert!(!pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); - let fields = &app.ui.dialogs.last().unwrap().fields; - assert!(points(fields, "points").is_empty(), "a click outside the selection is ignored"); + assert!(pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); + assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[25.0, 5.0]], "a colour outside the selection is sampled"); + let mask = proxy_mask(&app, &doc, 1, &json!({"select": "sampledColors", "points": [[25, 5]], "fuzziness": 0})).unwrap(); + assert_eq!(mask[5 * 40 + 25], 0.0, "but the preview stays within the selection"); assert!(pick_top(&mut app, [5.0, 5.0], egui::Modifiers::NONE)); assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[5.0, 5.0]]); } From 08d9c1d67dbb49283b94ffe81c2a4dbb824bdce1 Mon Sep 17 00:00:00 2001 From: mjohnson7cd <61306576+mjohnson7cd@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:06:12 -0400 Subject: [PATCH 069/167] fix(transform): keep masked objects visible during transform (#5239) (#5319) --- crates/ui-egui/src/transform_tool.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 81fc0fadcf..1cd81f3a3f 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -776,6 +776,9 @@ fn preview_image( for l in solo.layers.iter_mut() { if l.id != id && doc.layer(id).is_some_and(|_| !contains(l, id)) { l.visible = false; + } else { + // Its clipping base is hidden here, which would clip it away entirely (#5239). + l.clipped = false; } } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { @@ -4794,6 +4797,27 @@ mod tests { } } + /// #5239: a smart object clipped to the layer under it previewed as nothing while the box + /// was open (its flattened render hid the clipping base too). + #[test] + fn preview_of_a_clipped_smart_object_is_not_empty() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.session.execute("layer.createClippingMask", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + assert!(st.doc.layer(id).unwrap().clipped); + let (img, _) = preview_image(&st.doc, id, None, photocraft_geom::Rect::new(0, 0, 64, 64), 4096, false); + assert_eq!(texel_alpha(&img, 32, 32), 255, "the moving object shows"); + } + #[test] fn targeted_unlinked_mask_transforms_alone_through_the_box() { let mut app = masked_app(false); From 42c5cc64be03784ea47bd53ec3adb7eeba6d8f6f Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:06:18 -0400 Subject: [PATCH 070/167] Canvas: themes without a dot pattern draw no dots texture (#4542) (#5093) * Canvas: themes without a dot pattern draw no dots texture (#4542) Switching the dark theme to Pro filled the empty workspace with red and black blocks on the reporter's machine. The blocks repeat every 22 px, the tile of the canvas-surround dots texture, so it is that texture showing wrong contents. Pro and ten other themes set canvas_dot to their canvas colour: they have no dot pattern, yet still uploaded a texture for it and tiled it over the whole pasteboard. dots() now returns None for them, so nothing is uploaded or drawn. Studio, Studio Light and Solarized Dark, whose dots differ from the canvas, are unchanged. Co-Authored-By: Claude Opus 5.5 * Contributors: credit Higgs32584 by GitHub profile name Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 676d068070..11adb2c79a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2575,9 +2575,10 @@ pub fn fmt_zoom(pct: f32) -> String { if (pct - pct.round()).abs() < 0.05 { format!("{}", pct.round() as i64) } else { format!("{pct:.1}") } } -/// Repeating dot-grid texture for the canvas surround. +/// Repeating dot-grid texture for the canvas surround. A theme whose dots are its canvas colour +/// has no pattern to show, so it gets no texture at all rather than an invisible one (#4542). fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option { - if t.bevel { + if t.bevel || t.canvas_dot == t.canvas { return None; } let key = egui::Id::new(("canvas-dots", format!("{:?}", t.kind))); @@ -6003,6 +6004,32 @@ mod tests { assert_ne!(ensure_texture(&mut app, &ctx, 1, Some(1)).unwrap().0, a, "reopening a preserved ID creates a fresh texture"); } + #[test] + fn only_themes_with_a_dot_pattern_tile_the_canvas_surround() { + // #4542: switching to Pro filled the empty workspace with red blocks on one GPU. Pro's + // dots are its canvas colour, so the tiled texture had nothing to show in the first place. + use crate::theme::ThemeKind; + let tiles = |ctx: &egui::Context, kind: ThemeKind| { + crate::theme::apply(ctx, kind); + let mut output = ctx.run_ui(Default::default(), |ui| paint_dots(ui, ui.max_rect(), true)); + output.textures_delta.clear(); + output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))) + }; + let uploaded = |ctx: &egui::Context| ctx.tex_manager().read().allocated().filter(|(_, meta)| meta.name == "canvas-dots").count(); + for kind in ThemeKind::ALL { + let ctx = egui::Context::default(); + let dotted = matches!(kind, ThemeKind::Studio | ThemeKind::StudioLight | ThemeKind::SolarizedDark); + assert_eq!(tiles(&ctx, kind), dotted, "{kind:?}"); + assert_eq!(uploaded(&ctx), usize::from(dotted), "{kind:?}"); + } + // The reported switch: a dotted theme, then Pro. + let ctx = egui::Context::default(); + assert!(tiles(&ctx, ThemeKind::Studio)); + assert!(!tiles(&ctx, ThemeKind::Pro)); + assert!(!tiles(&ctx, ThemeKind::ProMedium)); + assert_eq!(uploaded(&ctx), 1, "only Studio's dots were uploaded"); + } + #[test] fn closed_documents_release_cpu_canvas_textures() { let mut app = app_on_two_displays(); From df10129f6c218e0ca6d1e3205271a1a0092ca57e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=C2=B7=20VanTan?= <77483789+VanTan666@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:06:25 +0200 Subject: [PATCH 071/167] fix(ui): fit Curves dialog width to its graph (#5160) --- crates/ui-egui/src/adjust_dialog.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 6 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 5f43348507..eb8f89a685 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -451,6 +451,30 @@ mod tests { } } + /// #5113: the dialog should fit its curve graph instead of reserving a blank right column. + #[test] + fn curves_dialog_fits_its_graph_and_controls() { + for language in ["en", "ru"] { + let _language = crate::i18n::language_scope(crate::i18n::Lang::from_code(language).unwrap()); + for theme in crate::theme::ThemeKind::ALL { + for size in [egui::vec2(1280.0, 900.0), egui::vec2(900.0, 720.0)] { + let mut h = Harness::builder().with_size(size).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); + PhotocraftApp::setup_context(&h.ctx, theme); + let id = open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap(); + let graph = h.ctx.data(|d| d.get_temp::(dialog_mem("curves").with("curves-graph"))).unwrap(); + assert!(window.right() - graph.right() < 40.0, "blank right column: window {window:?}, graph {graph:?}"); + assert!(graph.width() > 270.0, "keep the graph usable: {graph:?}"); + assert!(window.contains_rect(graph)); + for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] { + assert!(window.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window"); + } + } + } + } + } + #[test] fn cancel_leaves_the_document_untouched() { let mut harness = diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b71443ec17..2930889bab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -192,8 +192,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title // rule and the button row fill the width they are given, so it needs its own maximum too. let compact = d.fields.contains_key("__compact"); + // The curve editor caps its graph allocation at 300 points (#5113). + let curves = fields.get("__adjust").and_then(Value::as_str) == Some("curves"); if !compact { - ui.set_min_width(380.0); + ui.set_min_width(if curves { 300.0 } else { 380.0 }); } let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); if let Some(w) = wide { @@ -215,6 +217,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { 600.0 } else if compact { 260.0 + } else if curves { + 300.0 } else { 440.0 })); From a904bd7a390fa50eaa8c9aac3a4eca4f18952e5d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:06:32 +0200 Subject: [PATCH 072/167] Frame tool: take type, shape and smart object layers into the frame (#4435) (#4441) --- crates/engine/src/frame_cmds.rs | 35 ++++++++++++++++++++++++++------ crates/ui-egui/src/frame_tool.rs | 9 ++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index c97006e746..7a401594fd 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -3,7 +3,7 @@ //! primitives: group the selected layers, then clip the group with a rectangular layer mask the //! size of their bounds — the same visual result Photoshop's Frame tool produces for this command. //! `layer.new.frame` is the Frame tool's (K) command: a rectangular or elliptical frame drawn on -//! the canvas, empty or holding the pixel layer it was drawn over. Headless and scriptable like +//! the canvas, empty or holding the layer it was drawn over. Headless and scriptable like //! every other command. use photocraft_doc::{Layer, LayerContent, LayerId, LayerMask, PixelFormat}; @@ -107,8 +107,9 @@ fn ellipse_mask(rect: Rect, area: Rect) -> LayerMask { } /// Frame tool (K): a new frame — a group clipped to the drawn rectangle or ellipse — as one -/// history step. With `layer` (a pixel layer, not the Background), that layer becomes the frame's -/// content, as when a frame is drawn over a selected pixel layer in Photoshop; else it is empty. +/// history step. With `layer` (a pixel, type, shape or smart object layer, not the Background), +/// that layer becomes the frame's content, as when a frame is drawn over a selected layer in +/// Photoshop; else it is empty. fn new_frame(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.new.frame"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; @@ -137,8 +138,9 @@ 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(_)) || doc.is_background(id) { - return Err(bad("only a pixel layer (not the Background) can go in a frame".into())); + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || doc.is_background(id) { + return Err(bad("only a pixel, type, shape or smart object layer (not the Background) can go in a frame".into())); } let gid = doc.insert_above(Some(id), Layer::group(name, vec![])); let child = doc.remove(id).ok_or(EngineError::NoLayer(id))?; @@ -163,7 +165,7 @@ pub fn specs() -> Vec { label: "New Frame", menu: &[], shortcut: None, - params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel layer `layer` if given"#, + params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel, type, shape or smart object layer `layer` if given"#, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), journal: true, run: new_frame, @@ -274,6 +276,27 @@ mod tests { } } + /// #4435: a type layer (and the other rendered layer kinds) goes into a frame like a pixel layer. + #[test] + fn new_frame_takes_a_type_layer() { + let (mut s, a, _) = session(); + let tid = s + .edit("setup", |doc, _| { + let cache = doc.layer(a).and_then(|l| l.surface()).cloned(); + let t = Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() })); + let id = t.id; + doc.layers.push(t); + Ok(id) + }) + .unwrap(); + let r = s.execute("layer.new.frame", json!({"x": 0, "y": 0, "width": 40, "height": 30, "layer": tid.0})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + let g = s.active().unwrap().doc.layer(gid).unwrap(); + assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([tid])), "the frame holds the type layer"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(tid).is_some(), "undo gives the type layer back"); + } + #[test] fn needs_a_selected_layer() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/frame_tool.rs b/crates/ui-egui/src/frame_tool.rs index 28eb556282..4a1210b874 100644 --- a/crates/ui-egui/src/frame_tool.rs +++ b/crates/ui-egui/src/frame_tool.rs @@ -1,6 +1,6 @@ //! Frame tool (K): drag on the canvas to draw a rectangular or elliptical frame (the options bar //! picks the shape). ⇧ makes it a square or circle, ⌥ draws it from the centre. Drawn over the -//! selected pixel layer's content, the frame takes that layer in, as in Photoshop; else it is an +//! selected layer's content, the frame takes that layer in, as in Photoshop; else it is an //! empty placeholder. Each frame is one engine command (`layer.new.frame`), so one undo step; this //! module only turns the drag into it. @@ -48,12 +48,13 @@ fn drawn(d: &FrameDrag) -> [i64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| v.round() as i64) } -/// The selected pixel layer the frame `r` takes in: one whose content it overlaps and that can -/// move (not the Background or a position-locked layer). +/// The selected pixel, type, shape or smart object layer the frame `r` takes in: one whose content +/// it overlaps and that can move (not the Background or a position-locked layer). fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { let st = app.session.active()?; let l = st.doc.layer(st.active_layer?)?; - if !matches!(l.content, LayerContent::Raster(_)) || l.locks.position || l.locks.all { + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || l.locks.position || l.locks.all { return None; } let b = l.surface()?.content_bounds(); From 8428645311b176c89b95c727de3b6cd3d45597d2 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Wed, 14 Oct 2026 18:06:39 +0100 Subject: [PATCH 073/167] perf(gpu): keep the DX12 mip scratch textures for Intel adapters only (#5354) The separate mip render targets work around reduced-preview corruption on Intel DX12 drivers (#892, verified on Intel UHD 620), but every DX12 adapter took them. Creating a scratch texture per level on every refresh costs about 1 ms of CPU time per brush dab on an NVIDIA DX12 adapter. Gate them to Intel under DX12; PHOTOCRAFT_SEPARATE_MIPS=1/0 forces either way. Part of #4515. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/gpu_canvas.rs | 41 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 3862aec534..ce45364c4c 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -112,6 +112,21 @@ pub struct GpuCanvas { health: photocraft_gpu::DeviceHealth, } +/// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on +/// Intel adapters under DX12 only, where some drivers corrupt reduced previews when a pass +/// samples one mip level and renders into another of the same texture (#892, Intel UHD 620). +/// Everywhere else it renders straight into the level: on an NVIDIA DX12 adapter the scratch +/// textures, created per level on every refresh, cost about 1 ms of CPU time per brush dab +/// (#4515). `PHOTOCRAFT_SEPARATE_MIPS` (`setting`) forces it on (`1`) or off (`0`). +pub fn separate_mip_targets(backend: wgpu::Backend, vendor: u32, setting: Option<&str>) -> bool { + const INTEL: u32 = 0x8086; + match setting.map(str::trim) { + Some("1") => true, + Some("0") => false, + _ => backend == wgpu::Backend::Dx12 && vendor == INTEL, + } +} + /// Can `adapter` use `Rgba16Float` as a sampled, filtered, render-target and copy texture? /// (Core WebGPU guarantees it; WebGL2 needs `EXT_color_buffer_float`, and some drivers lack it.) pub fn supports_f16_canvas(adapter: &wgpu::Adapter) -> bool { @@ -141,7 +156,8 @@ impl GpuCanvas { let health = photocraft_gpu::DeviceHealth::watch(&rs.device); let mut res = Resources::new(&rs.device, &rs.queue, rs.target_format, high != HighPolicy::Off); res.health = health.clone(); - res.separate_mip_targets = rs.adapter.get_info().backend == wgpu::Backend::Dx12; + let info = rs.adapter.get_info(); + res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref()); rs.renderer.write().callback_resources.insert(res); log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format); Self { rs: rs.clone(), tile, high, health } @@ -1283,7 +1299,7 @@ struct Resources { views: HashMap, out_linear: bool, /// Build each mip level in a scratch texture and copy it back instead of rendering into - /// the level directly (DX12 only: works around reduced-preview corruption on Intel drivers). + /// the level directly (see [`separate_mip_targets`]). separate_mip_targets: bool, /// The wgpu layer compositor (created on first use). compositor: Option, @@ -1692,8 +1708,8 @@ impl DocTextures { wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.mip_sampler) }, ], }); - // Keep the sampled source and render attachment in separate resources on DX12 - // (`Resources::separate_mip_targets`): some Intel DX12 drivers corrupt reduced + // Keep the sampled source and render attachment in separate resources on Intel + // DX12 adapters ([`separate_mip_targets`]): some of their drivers corrupt reduced // previews when both are mip levels of the same texture, even though the // subresources do not overlap. Elsewhere render straight into the level, which // spares a scratch texture and a copy per level on every edit. @@ -2199,6 +2215,23 @@ mod mip_tests; mod tests { use super::*; + /// The scratch-texture workaround is for Intel's DX12 drivers (#892); other DX12 adapters + /// render straight into the level like every other backend (#4515). + #[test] + fn separate_mip_targets_only_for_intel_on_dx12() { + use wgpu::Backend::{Dx12, Metal, Vulkan}; + let (intel, nvidia, amd) = (0x8086, 0x10de, 0x1002); + assert!(separate_mip_targets(Dx12, intel, None)); + assert!(!separate_mip_targets(Dx12, nvidia, None)); + assert!(!separate_mip_targets(Dx12, amd, None)); + assert!(!separate_mip_targets(Vulkan, intel, None)); + assert!(!separate_mip_targets(Metal, 0, None)); + // PHOTOCRAFT_SEPARATE_MIPS forces either way; anything else leaves the default. + assert!(separate_mip_targets(Dx12, nvidia, Some("1"))); + assert!(!separate_mip_targets(Dx12, intel, Some(" 0 "))); + assert!(separate_mip_targets(Dx12, intel, Some("yes"))); + } + #[test] fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; From 89a763a368979aaf2b0894748b5178ae6680d030 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:06:47 -0400 Subject: [PATCH 074/167] Display profile notice: one card for all displays without a usable profile (#5312) Two monitors without ICC profiles (a common Linux desk, e.g. DP-4 and DP-5) showed two identical "Display profile not used" cards at every launch. A reading now posts one notice: one reason line per display, then the advice line naming them all. Displays added by a later reading still get a card of their own, and a reading with nothing new stays quiet, as before. Claude-Session: https://claude.ai/code/session_01SvxNXnS4tcg6BHT9VG67t7 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/monitor_status.rs | 52 +++++++++++++++++++++------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/monitor_status.rs b/crates/ui-egui/src/monitor_status.rs index 8e29b8c2f2..2350e6dc30 100644 --- a/crates/ui-egui/src/monitor_status.rs +++ b/crates/ui-egui/src/monitor_status.rs @@ -218,19 +218,24 @@ pub fn apply(app: &mut PhotocraftApp, r: Detection) { }) .collect() }; - for (id, name, reason) in fallbacks { - if app.monitors.reported.iter().any(|(i, r)| *i == id && *r == reason) { - continue; - } - app.monitors.reported.push((id, reason.clone())); - let template = if name.is_empty() { - "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - } else { - "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - }; - let lines = vec![reason, crate::i18n::fmt(tl!(template), &[("display", &name)])]; - crate::notices::post(app, FALLBACK_TITLE, lines, true, None); + let fresh: Vec<_> = fallbacks.into_iter().filter(|(id, _, reason)| !app.monitors.reported.iter().any(|(i, r)| i == id && r == reason)).collect(); + if fresh.is_empty() { + return; } + app.monitors.reported.extend(fresh.iter().map(|(id, _, reason)| (*id, reason.clone()))); + // One notice per reading, not one per display: two profile-less monitors (common on Linux + // desks) gave two cards saying the same thing at every launch. Each display keeps its own + // reason line; the names share the existing template, so no catalog change is needed. + let names: Vec<&str> = fresh.iter().map(|(_, name, _)| name.as_str()).filter(|n| !n.is_empty()).collect(); + let template = if names.is_empty() { + "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + } else { + "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + }; + let advice = crate::i18n::fmt(tl!(template), &[("display", &names.join(", "))]); + let mut lines: Vec = fresh.into_iter().map(|(_, _, reason)| reason).collect(); + lines.push(advice); + crate::notices::post(app, FALLBACK_TITLE, lines, true, None); } /// The frame of the window `ctx` draws, in OS points (as `Display::frame`). egui reports it in @@ -427,6 +432,29 @@ mod tests { assert!(lines[0].starts_with("Monitor profile on Built-in Retina Display: sRGB"), "{lines:?}"); } + #[test] + fn displays_without_profiles_share_one_notice() { + // Two profile-less monitors (a Linux desk: DP-4 and DP-5) gave two identical cards. + let mut app = app(); + let desk = |id, name: &str, x| Display { id, name: name.into(), frame: [x, 0.0, 1920.0, 1080.0], profile_name: None, icc: None }; + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + let lines = &app.ui.notices[0].lines; + assert_eq!(lines[0], "DP-4 has no ICC profile"); + assert_eq!(lines[1], "DP-5 has no ICC profile"); + assert!(lines[2].starts_with("The canvas on DP-4, DP-5 is shown as sRGB."), "{lines:?}"); + // The same reading again: nothing new to say. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + // A third display plugged in later: a card for it alone. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0), desk(3, "HDMI-A-1", 3840.0)])); + assert_eq!(app.ui.notices.len(), 2); + let lines = &app.ui.notices[1].lines; + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "HDMI-A-1 has no ICC profile"); + assert!(lines[1].starts_with("The canvas on HDMI-A-1 is shown"), "{lines:?}"); + } + #[test] fn displays_are_read_again_when_they_may_have_changed() { let mut app = app(); From 0f3d2f133e4fe5ae9c53b7705242cd9058612548 Mon Sep 17 00:00:00 2001 From: prekabreki <133610573+prekabreki@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:07:08 +0200 Subject: [PATCH 075/167] Type: hovering a font previews it with the Character section shown too (#5152) (#5222) The options bar and the Properties panel's Character section both draw the "type-font" family picker, and the Character one is drawn later. With its menu closed it removed the shared hover key every frame, so the options bar's hover never reached the canvas. Only the picker whose menu is open now clears the key; follow_hover already drops it each frame once nothing writes it. Test: options_bar_hover_previews_with_the_character_picker_also_shown runs the whole app (Pro theme, both pickers on screen), hovers a font in the options bar and checks the canvas shows it, then that leaving the row restores it. It fails without the fix. Co-authored-by: prekabreki Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/type_font_picker_tests.rs | 40 ++++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 4 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index f17032523d..176e683a9d 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -490,3 +490,43 @@ fn wheel_family_index_clamps_to_the_ends() { assert_eq!(super::wheel_family_index(1, 3, 0.0), None); assert_eq!(super::wheel_family_index(0, 0, -1.0), None); } + +/// #5152: in the full app the Properties panel's Character section draws a second font picker +/// after the options bar; hovering a font in the options bar still previews it on the canvas. +#[test] +fn options_bar_hover_previews_with_the_character_picker_also_shown() { + use crate::{PhotocraftApp, Services}; + use photocraft_doc::LayerContent; + use serde_json::json; + + let theme = crate::theme::ThemeKind::Pro; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.ui.theme = theme; + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app.ui.tool = crate::state::Tool::Type; + app.sync_views(); + app + }); + h.run_steps(8); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let font = |h: &mut Harness<'static, PhotocraftApp>| { + let (shown, _) = crate::canvas::display_doc(h.state_mut(), 0); + let LayerContent::Text(t) = &shown.layer(layer).unwrap().content else { panic!("type layer") }; + t.char_runs()[0].style.font_family.clone() + }; + let pickers: Vec<_> = h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Inter")).collect(); + assert_eq!(pickers.len(), 2, "options bar and Character section both show the font picker"); + pickers[0].click(); + h.run(); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").type_text("JetBrains Mono"); + h.run(); + h.get_by_label("JetBrains Mono").hover(); + h.run(); + assert_eq!(font(&mut h), "JetBrains Mono"); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").hover(); + h.run(); + assert_eq!(font(&mut h), "Inter", "leaving the row restores the committed font"); +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f00fb57157..bee49293b5 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1553,7 +1553,9 @@ pub(crate) fn family_picker_in( let hover_id = egui::Id::new("type-font-hover"); if let Some(f) = hovered_font { ui.ctx().data_mut(|d| d.insert_temp(hover_id, f)); - } else { + } else if egui::ComboBox::is_open(ui.ctx(), response.response.id) { + // Only the open menu clears it: the options bar and the Character section both draw + // this picker, and the closed one must not wipe the other's hover (#5152). ui.ctx().data_mut(|d| d.remove::(hover_id)); } } From 44bb43e94dc12bfaa42d2e50ba41674668f73fbb Mon Sep 17 00:00:00 2001 From: prekabreki <133610573+prekabreki@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:07:15 +0200 Subject: [PATCH 076/167] Number fields: centre the number in its box (#5150) (#5226) #1322 lifted the number's slot 1 pt above the box's centre because digits have no descenders. Measured on today's mono font at 4x, the digits' ink already sits within 0.2 pt of centre without the lift; with it the ink is 0.9 pt high, the focus outline has 1 pt of gap above and 3 pt below, and the digits start 1 pt above the unit. The slot is centred again. Tests: value_field_digits_ink_is_centred_and_level_with_the_unit renders the field (Pro and Studio) and checks the digits' ink is centred and starts level with the "%"; value_field_number_slot_is_centred_in_its_box replaces the 1 pt-lift test, and the #2823 slim-box test now expects centring. Both new assertions fail with the lift restored. Co-authored-by: prekabreki Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 52 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..1b19e1ccc7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -519,12 +519,13 @@ fn value_field_box( // 6 pt from the right edge), so a one-character unit like `%` leaves the number room (#3040). let suffix_font = theme::mono(11.0); let suffix_w = if suffix.is_empty() { 0.0 } else { ui.painter().layout_no_wrap(suffix.to_owned(), suffix_font.clone(), t.text_faint).size().x + 3.0 }; - // 1 pt higher than centred: digits have no descenders, so centred text looks low. The slot - // is at least one text row tall, so a slim box's number isn't pushed out of it (#2823). + // Centred: the digits' ink then sits within 0.2 pt of the box's centre, where a 1 pt lift + // (#1322) put it 0.9 pt high and the focus outline off-centre (#5150). The slot is at least + // one text row tall, so a slim box's number isn't pushed out of it (#2823). let font = theme::mono(12.0); let row_h = ui.painter().layout_no_wrap("0".into(), font.clone(), t.text).size().y; let slot_h = (rect.height() - 4.0).max(row_h).min(rect.height() - 2.0); - let cy = rect.center().y - 1.0; + let cy = rect.center().y; let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. let fine = range.end() - range.start() <= 10.0; @@ -1842,9 +1843,9 @@ mod tests { h } - /// Digits have no descenders, so the number sits 1 pt above the box's centre to look centred. + /// The number's slot (and its focus outline) is centred in the box (#5150). #[test] - fn value_field_digits_sit_one_point_above_centre() { + fn value_field_number_slot_is_centred_in_its_box() { let mut h = Harness::new_ui_state( |ui, s: &mut (f32, egui::Rect)| { s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0, super::FieldOpts::default()).1; @@ -1854,11 +1855,46 @@ mod tests { h.run(); let number = h.get_by_role(egui::accesskit::Role::SpinButton).rect(); let field = h.state().1; - assert!((field.center().y - number.center().y - 1.0).abs() < 0.01, "field {field:?}, number {number:?}"); + assert!((field.center().y - number.center().y).abs() < 0.01, "field {field:?}, number {number:?}"); + } + + /// The digits' ink, not just their slot, is centred in the box, and starts level with the + /// `%`'s, so the number lines up with the box and its unit (#5150). + #[test] + fn value_field_digits_ink_is_centred_and_level_with_the_unit() { + let scale = 4.0; + for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut h = Harness::builder().with_size(egui::vec2(100.0, 40.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, s: &mut (f32, egui::Rect)| { + s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "%", 80.0, 0.0, super::FieldOpts::default()).1; + }, + (100.0, egui::Rect::NOTHING), + ); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let image = h.render().expect("render value field"); + let (field, suffix_left) = (h.state().1, h.state().1.right() - 14.0); + let px = |v: f32| (v * scale) as u32; + let bg = image.get_pixel(px(field.left() + 2.0), px(field.center().y)).0; + let lum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + // Top and bottom of the ink (pt) between x0 and x1. + let ink = |x0: f32, x1: f32| { + let rows: Vec = (px(field.top() + 2.0)..px(field.bottom() - 2.0)) + .filter(|&y| (px(x0)..px(x1)).any(|x| lum(image.get_pixel(x, y).0) > lum(bg) + 150)) + .collect(); + let (top, bottom) = (rows.first().copied().unwrap_or(0), rows.last().copied().unwrap_or(0) + 1); + (top as f32 / scale, bottom as f32 / scale) + }; + let digits = ink(field.left() + 4.0, suffix_left - 2.0); + let unit = ink(suffix_left, field.right() - 2.0); + let what = format!("{kind:?}: field {field:?}, digits {digits:?}, unit {unit:?}"); + assert!(((digits.0 + digits.1) / 2.0 - field.center().y).abs() <= 0.25, "{what}"); + assert!((digits.0 - unit.0).abs() <= 0.25, "{what}"); + } } /// With the app's theme (its 24 pt interact height and 4 pt button padding), in a full-size - /// box and a slim status-bar box alike, the number stays inside the box, 1 pt above its centre, + /// box and a slim status-bar box alike, the number stays inside the box, centred in it, /// and a long number doesn't spill out of the box's left edge (#2823). #[test] fn themed_value_field_number_sits_inside_its_box_in_every_size() { @@ -1878,7 +1914,7 @@ mod tests { let field = harness.state().1; let what = format!("{kind:?} {w}x{h}: field {field:?}, number {number:?}"); assert!(field.contains_rect(number), "{what}"); - assert!((field.center().y - number.center().y - 1.0).abs() < 0.51, "{what}"); + assert!((field.center().y - number.center().y).abs() < 0.51, "{what}"); } } } From f053a924a668ec1b43d9bafcb473930cf0c65824 Mon Sep 17 00:00:00 2001 From: Pharbie <22204443+Pharbi@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:07:22 -0700 Subject: [PATCH 077/167] fix(ui): Color Picker hex field accepts repeated pastes (#5398) The hex field's six-character limit dropped any paste into a full field (every paste after the first) and any paste with a leading #. A pasted hex colour now replaces the whole code. Co-authored-by: kwesi <22204443+yankskwesi@users.noreply.github.com> --- crates/ui-egui/src/color_picker_ui.rs | 46 +++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 0d51f5d2f5..09a3ce8d4c 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -685,9 +685,17 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo crate::field_tab::select_all(ui.ctx(), id, current_hex.trim_start_matches('#')); ui.memory_mut(|m| m.request_focus(id)); } - 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_ascii_uppercase()); + let focused = ui.memory(|m| m.has_focus(id)); + let pasted = if focused { take_hex_paste(ui) } else { None }; + let typing = if focused { ui.data(|d| d.get_temp::(id)) } else { None }; + let mut h = match pasted { + Some(c) => hex(c).trim_start_matches('#').to_ascii_uppercase(), + None => typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()), + }; let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6)); + if let Some(c) = pasted { + edit = Some((c, Keep::Nothing)); + } if r.gained_focus() { crate::field_tab::select_all(ui.ctx(), id, &h); } @@ -743,6 +751,23 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo } } +fn take_hex_paste(ui: &egui::Ui) -> Option<[f32; 3]> { + ui.input_mut(|i| { + let mut found = None; + i.events.retain(|e| match e { + egui::Event::Paste(s) => match parse_hex(s) { + Some(c) => { + found = Some(c); + false + } + None => true, + }, + _ => true, + }); + found + }) +} + /// Add to Swatches was clicked: add the picked colour to the Swatches panel. pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map) { if f.remove("__addSwatch").and_then(|v| v.as_bool()) == Some(true) { @@ -884,6 +909,23 @@ mod tests { assert_eq!(color(&h), "#ff8800"); } + #[test] + fn hex_field_takes_every_paste_with_or_without_hash() { + let mut h = picker("#336699"); + for (paste, want) in [("ff8800", "#ff8800"), ("#00ff00", "#00ff00"), (" 1234AB\n", "#1234ab")] { + h.event(egui::Event::Paste(paste.into())); + h.run_steps(2); + assert_eq!(color(&h), want, "paste {paste:?}"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some(&want[1..].to_ascii_uppercase()[..])); + } + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.event(egui::Event::Paste("ff".into())); + h.run_steps(2); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("ff")); + assert_eq!(color(&h), "#1234ab"); + } + #[test] fn color_picker_opens_with_hex_focused_and_selected() { let mut h = picker("#336699"); From a7f7f1fd1e327fcddb2697f0f03236fc3db2c2cd Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 19:07:28 +0200 Subject: [PATCH 078/167] =?UTF-8?q?Edit=20=E2=80=BA=20Menus:=20Menus,=20Ke?= =?UTF-8?q?yboard=20Shortcuts,=20Preferences,=20Save=20and=20Exit=20can't?= =?UTF-8?q?=20be=20hidden=20(#5096)=20(#5183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding Edit › Menus (or Keyboard Shortcuts) left no way back into the dialog, and Save, Save As, Exit and Preferences could disappear too. edit.menus now refuses to hide them, the dialog greys their Visible box, and the menus keep them even when a preferences file lists them. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/prefs.rs | 17 +++++++++++++++++ crates/engine/src/prefs/tests.rs | 13 +++++++++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 16 ++++++++++++++-- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b163d40d88..295870b3ca 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -954,6 +954,20 @@ pub struct MenuCustomization { pub colors: BTreeMap, } +impl MenuCustomization { + /// False for the items Edit › Menus can't hide (#5096): the way back into this dialog + /// (Edit › Menus, Edit › Keyboard Shortcuts), Preferences, Save, Save As and Exit. + pub fn hideable(id: &str) -> bool { + !matches!(id, "edit.menus" | "edit.keyboardShortcuts" | "file.save" | "file.saveAs" | "file.exit") && !id.starts_with("edit.preferences") + } + + /// Whether the menus leave `id` out. Items that can't be hidden stay shown even when an + /// older or hand-edited preferences file lists them. + pub fn is_hidden(&self, id: &str) -> bool { + Self::hideable(id) && self.hidden.iter().any(|h| h == id) + } +} + /// Edit › Toolbar: hidden tools and a custom order (tool names as in `ui.set {tool}`). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -1898,6 +1912,9 @@ fn menus(s: &mut Session, p: &Value) -> Result { } } let (hide, show) = (ids("hide"), ids("show")); + if let Some(id) = hide.iter().find(|id| !MenuCustomization::hideable(id)) { + return Err(bad("edit.menus", format!("`{id}` can't be hidden"))); + } s.edit_prefs(|pr| { if p.get("reset").and_then(Value::as_bool) == Some(true) { pr.menus = MenuCustomization::default(); diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 6b56cbc7ba..7f5c863e7f 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -397,6 +397,19 @@ fn menus_and_toolbar_customisation_persist() { assert_eq!(back.toolbar.hidden, vec!["Sponge".to_string()]); } +/// #5096: Edit › Menus can't hide the way back into itself, Preferences, Save or Exit. +#[test] +fn menus_refuse_to_hide_essential_items() { + let mut s = session(); + for id in ["edit.menus", "edit.keyboardShortcuts", "edit.preferences.general", "file.save", "file.saveAs", "file.exit"] { + assert!(s.execute("edit.menus", json!({"hide": ["edit.fade", id]})).is_err(), "{id}"); + assert!(s.prefs().menus.hidden.is_empty(), "{id}: a refused call changes nothing"); + } + // A preferences file that lists them anyway still shows them. + let menus = MenuCustomization { hidden: vec!["file.exit".into(), "edit.fade".into()], ..Default::default() }; + assert!(!menus.is_hidden("file.exit") && menus.is_hidden("edit.fade")); +} + #[test] fn preference_sections_are_commands() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 403994ac10..d25d6a41de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1111,7 +1111,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { } } if !prefs.menus.hidden.is_empty() { - items.retain(|i| !prefs.menus.hidden.contains(&i.id)); + items.retain(|i| !prefs.menus.is_hidden(&i.id)); } if prefs.interface.show_menu_colors { for it in &mut items { diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index db908ae23f..497a7dba62 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1753,8 +1753,10 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map