diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b..272715dc8 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,10 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + verified-appimage-inputs: + name: Verified AppImage inputs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: bash packaging/linux/verified-download-test.sh diff --git a/Cargo.lock b/Cargo.lock index 54187a84f..b7c179c88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4207,6 +4207,7 @@ dependencies = [ "objc2-foundation 0.3.2", "open", "photocraft-automation", + "photocraft-cms", "photocraft-codecs", "photocraft-color", "photocraft-compose", diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index c031c4495..740a75b3f 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -64,6 +64,7 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] +photocraft-cms = { workspace = true } x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Wayland desktop color selection through the existing XDG portal transport. zbus = "=5.19.0" diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 33efd4baf..a267673aa 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -230,6 +230,8 @@ fn main() -> eframe::Result { // The display server to open the window on: Xwayland for a pen on Wayland, which gives this // app no pen input (#639). #[cfg(target_os = "linux")] + let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok()); + #[cfg(target_os = "linux")] let session = tablet::display_session(); // Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too, // where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can @@ -297,6 +299,9 @@ fn main() -> eframe::Result { // Read the displays' ICC profiles while the window opens (colour-managed canvas; `None` // where the platform has no reader). + #[cfg(target_os = "linux")] + let monitor = monitor_profile::detect_async_for_session(native_session); + #[cfg(not(target_os = "linux"))] let monitor = monitor_profile::detect_async(); // Brush presets load in the background; the app attaches them when they arrive. let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async); @@ -379,7 +384,14 @@ fn main() -> eframe::Result { // the right profile; a slower reading is applied when it arrives, and the shell reads // again when the displays may have changed. if let Some(rx) = monitor { - app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async)); + #[cfg(target_os = "linux")] + { + app.services.read_displays = Some(std::sync::Arc::new(move || monitor_profile::detect_async_for_session(native_session))); + } + #[cfg(not(target_os = "linux"))] + { + app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async)); + } match rx.recv_timeout(std::time::Duration::from_secs(2)) { Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx), diff --git a/apps/photocraft/src/monitor_profile.rs b/apps/photocraft/src/monitor_profile.rs index c7a7c0489..8e3386969 100644 --- a/apps/photocraft/src/monitor_profile.rs +++ b/apps/photocraft/src/monitor_profile.rs @@ -6,7 +6,12 @@ //! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background //! thread; the shell starts one at launch and again when the displays may have changed //! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display -//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings). +//! it is on. Linux X11: RandR output names and CRTC frames are matched to colord display devices +//! by the exact `XRANDR_name` metadata; cloned outputs get a profile only when every output has +//! the same ICC bytes. Native Wayland, including Xwayland windows opened from a Wayland session, +//! reports no display profiles: eframe/winit has no safe way to set the app surface's Wayland +//! image description, so applying the output ICC in PhotoCraft could double-transform it. +//! Other platforms report no displays (sRGB, or the profile chosen in Color Settings). use std::sync::mpsc::Receiver; @@ -16,15 +21,33 @@ use photocraft_engine::display_color::Display; pub type Detection = Result, String>; /// Starts reading the displays in the background; `None` when the platform has no reader. +#[cfg(not(target_os = "linux"))] pub fn detect_async() -> Option> { - if !cfg!(target_os = "macos") { - return None; + #[cfg(target_os = "macos")] + { + Some(spawn_reader(detect)) } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +/// Linux display profile reads are supported only for an X11 session. In particular, an app +/// window opened through Xwayland still belongs to the Wayland desktop and must not apply a +/// compositor-managed output profile a second time. +#[cfg(target_os = "linux")] +pub fn detect_async_for_session(session: crate::linux_libs::DisplaySession) -> Option> { + (session == crate::linux_libs::DisplaySession::X11).then(|| spawn_reader(detect)) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn spawn_reader(detect: fn() -> Detection) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let _ = tx.send(detect()); }); - Some(rx) + rx } /// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in @@ -110,9 +133,225 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) -> Ok(String::from_utf8_lossy(&out).into_owned()) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] fn detect() -> Detection { - Err("this platform doesn't report display profiles".into()) + linux::detect() +} + +#[cfg(target_os = "linux")] +mod linux { + use std::collections::{BTreeMap, HashMap}; + use std::io::Read as _; + use std::path::Path; + use std::sync::Arc; + use std::time::Duration; + + use photocraft_engine::display_color::Display; + use x11rb::connection::Connection as _; + use x11rb::protocol::randr; + use zbus::blocking::{connection::Builder, proxy::Proxy}; + use zbus::zvariant::OwnedObjectPath; + + use super::Detection; + + const MAX_ICC_BYTES: usize = 64 * 1024 * 1024; + const COLORD: &str = "org.freedesktop.ColorManager"; + const COLORD_ROOT: &str = "/org/freedesktop/ColorManager"; + const COLORD_MANAGER: &str = "org.freedesktop.ColorManager"; + const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device"; + const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + + struct IccProfile { + name: String, + bytes: Arc>, + } + + pub(super) fn detect() -> Detection { + let (connection, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?; + let root = connection.setup().roots.get(screen).ok_or_else(|| "the X11 server didn't provide a screen".to_string())?.root; + let resources = randr::get_screen_resources_current(&connection, root) + .map_err(|e| format!("couldn't request XRandR outputs: {e}"))? + .reply() + .map_err(|e| format!("couldn't read XRandR outputs: {e}"))?; + + let mut output_names = HashMap::new(); + let mut active_crtcs = BTreeMap::new(); + for output in resources.outputs { + let info = match randr::get_output_info(&connection, output, resources.config_timestamp) { + Ok(cookie) => match cookie.reply() { + Ok(info) => info, + Err(e) => { + log::debug!("couldn't read one XRandR output: {e}"); + continue; + } + }, + Err(e) => { + log::debug!("couldn't request one XRandR output: {e}"); + continue; + } + }; + if info.connection != randr::Connection::CONNECTED || info.crtc == 0 { + continue; + } + let Ok(name) = String::from_utf8(info.name) else { + log::debug!("skipping an XRandR output with a non-UTF-8 name"); + continue; + }; + if !name.is_empty() { + output_names.insert(output, name); + active_crtcs.insert(info.crtc, ()); + } + } + + let profiles = match colord_profiles() { + Ok(profiles) => profiles, + Err(e) => { + log::warn!("couldn't read X11 display profiles from colord: {e}"); + HashMap::new() + } + }; + let mut displays = Vec::with_capacity(active_crtcs.len()); + for crtc in active_crtcs.into_keys() { + let crtc_info = match randr::get_crtc_info(&connection, crtc, resources.config_timestamp) { + Ok(cookie) => match cookie.reply() { + Ok(info) if info.status == randr::SetConfig::SUCCESS && info.width > 0 && info.height > 0 => info, + Ok(_) => continue, + Err(e) => { + log::debug!("couldn't read XRandR CRTC {crtc}: {e}"); + continue; + } + }, + Err(e) => { + log::debug!("couldn't request XRandR CRTC {crtc}: {e}"); + continue; + } + }; + let Some(output_names) = crtc_info.outputs.iter().map(|output| output_names.get(output).cloned()).collect::>>() else { continue }; + let Some(primary_name) = output_names.first() else { continue }; + let profile = unambiguous_clone_profile(&output_names, &profiles); + displays.push(Display { + id: crtc, + name: primary_name.clone(), + frame: [f64::from(crtc_info.x), f64::from(crtc_info.y), f64::from(crtc_info.width), f64::from(crtc_info.height)], + profile_name: profile.as_ref().map(|p| p.name.clone()), + icc: profile.map(|p| p.bytes), + }); + } + if displays.is_empty() { + return Err("X11 reported no active RandR outputs".into()); + } + Ok(displays) + } + + fn colord_profiles() -> Result>, String> { + let connection = Builder::system() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))? + .method_timeout(Duration::from_secs(2)) + .build() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))?; + let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't access colord: {e}"))?; + let devices: Vec = + manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list display devices: {e}"))?; + let mut profiles: HashMap> = HashMap::new(); + for device_path in devices { + let device = Proxy::new(&connection, COLORD, &device_path, COLORD_DEVICE).map_err(|e| format!("couldn't access a colord display device: {e}"))?; + let metadata: HashMap = device.get_property("Metadata").map_err(|e| format!("couldn't read colord display metadata: {e}"))?; + let Some(output_name) = metadata.get("XRANDR_name").filter(|name| !name.is_empty()) else { continue }; + // `*` is colord's default profile qualifier. A display without one is kept, with no + // ICC, so the engine reports the normal sRGB fallback. + let profile_path: OwnedObjectPath = match device.call("GetProfileForQualifiers", &(vec!["*"],)) { + Ok(path) => path, + Err(_) => continue, + }; + let profile = Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't access a colord ICC profile: {e}"))?; + let filename: String = match profile.get_property::("Filename") { + Ok(filename) if !filename.is_empty() => filename, + _ => continue, + }; + let Some(profile) = read_icc(Path::new(&filename)) else { + log::warn!("couldn't read a valid ICC profile assigned to X11 output {output_name}"); + continue; + }; + profiles.entry(output_name.clone()).or_default().push(profile); + } + Ok(profiles) + } + + fn read_icc(path: &Path) -> Option { + let mut bytes = Vec::new(); + std::fs::File::open(path).ok()?.take((MAX_ICC_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?; + let bytes = checked_icc_bytes(bytes)?; + let name = path.file_name()?.to_str()?.to_owned(); + Some(IccProfile { name, bytes: Arc::new(bytes) }) + } + + fn checked_icc_bytes(mut bytes: Vec) -> Option> { + if bytes.len() > MAX_ICC_BYTES || bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") { + return None; + } + let declared = u32::from_be_bytes(bytes.get(0..4)?.try_into().ok()?) as usize; + if declared < 132 || declared > bytes.len() { + return None; + } + bytes.truncate(declared); + let profile = photocraft_cms::Profile::parse(&bytes).ok()?; + (profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes) + } + + fn unambiguous_clone_profile(output_names: &[String], profiles: &HashMap>) -> Option { + let mut selected: Option<&IccProfile> = None; + for output in output_names { + let candidates = profiles.get(output)?; + let first = candidates.first()?; + if candidates.iter().any(|candidate| candidate.bytes != first.bytes) { + return None; + } + if selected.is_some_and(|profile| profile.bytes != first.bytes) { + return None; + } + selected = Some(first); + } + selected.map(|profile| IccProfile { name: profile.name.clone(), bytes: profile.bytes.clone() }) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn checked_icc_bytes_require_a_bounded_parseable_rgb_profile() { + let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes(); + let bytes = rgb.as_ref().clone(); + assert_eq!(checked_icc_bytes(bytes.clone()), Some(bytes)); + + let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes(); + assert!(checked_icc_bytes(cmyk.as_ref().clone()).is_none()); + assert!(checked_icc_bytes(vec![0; 132]).is_none()); + assert!(checked_icc_bytes(vec![0; MAX_ICC_BYTES + 1]).is_none()); + } + + #[test] + fn cloned_outputs_use_only_one_unambiguous_profile() { + let srgb = photocraft_cms::Builtin::Srgb.profile().to_bytes(); + let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes(); + let profile = |name: &str, bytes: std::sync::Arc>| IccProfile { name: name.into(), bytes }; + let outputs = ["DP-1".to_owned(), "HDMI-1".to_owned()]; + + let matching = + HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("sRGB", srgb.clone())])]); + assert_eq!(unambiguous_clone_profile(&outputs, &matching).map(|p| p.name), Some("sRGB".into())); + + let differing = + HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("Display P3", p3.clone())])]); + assert!(unambiguous_clone_profile(&outputs, &differing).is_none()); + + let missing = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())])]); + assert!(unambiguous_clone_profile(&outputs, &missing).is_none()); + + let conflicting = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb), profile("Display P3", p3)])]); + assert!(unambiguous_clone_profile(&["DP-1".to_owned()], &conflicting).is_none()); + } + } } /// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down @@ -285,6 +524,7 @@ mod tests { } /// On a Mac, every display is read, and the profiles parse as RGB profiles. + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn detected_displays_parse() { if let Ok(displays) = detect() { diff --git a/apps/photocraft/src/screen_color.rs b/apps/photocraft/src/screen_color.rs index 59fb6ae6d..8a0d4ca90 100644 --- a/apps/photocraft/src/screen_color.rs +++ b/apps/photocraft/src/screen_color.rs @@ -48,6 +48,66 @@ fn count(width: usize, height: usize) -> Result { width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into()) } #[cfg(target_os = "linux")] +const MAX_ICC_PROFILE_BYTES: usize = 1024 * 1024; +#[cfg(target_os = "linux")] +const ICC_PROFILE_PROPERTY_LONG_LENGTH: u32 = 256 * 1024; +#[cfg(target_os = "linux")] +fn icc_profile(bytes: Vec) -> Option> { + if bytes.is_empty() || bytes.len() > MAX_ICC_PROFILE_BYTES { + return None; + } + let profile = match photocraft_cms::Profile::parse(&bytes) { + Ok(profile) => profile, + Err(_) => return None, + }; + (profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes) +} +#[cfg(target_os = "linux")] +fn icc_profile_atom(conn: &C, name: &str) -> Option { + use x11rb::protocol::xproto::ConnectionExt as _; + let cookie = match conn.intern_atom(true, name.as_bytes()) { + Ok(cookie) => cookie, + Err(_) => return None, + }; + let atom = match cookie.reply() { + Ok(reply) => reply.atom, + Err(_) => return None, + }; + (atom != 0).then_some(atom) +} +#[cfg(target_os = "linux")] +fn output_icc_profile(conn: &C, output: u32, atom: u32) -> Option> { + use x11rb::protocol::{randr::ConnectionExt as _, xproto::AtomEnum}; + let cookie = match conn.randr_get_output_property(output, atom, AtomEnum::CARDINAL, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH, false, false) { + Ok(cookie) => cookie, + Err(_) => return None, + }; + let reply = match cookie.reply() { + Ok(reply) => reply, + Err(_) => return None, + }; + if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 8 || reply.bytes_after != 0 { + return None; + } + icc_profile(reply.data) +} +#[cfg(target_os = "linux")] +fn root_icc_profile(conn: &C, root: u32, atom: u32) -> Option> { + use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _}; + let cookie = match conn.get_property(false, root, atom, AtomEnum::ANY, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH) { + Ok(cookie) => cookie, + Err(_) => return None, + }; + let reply = match cookie.reply() { + Ok(reply) => reply, + Err(_) => return None, + }; + if reply.format != 8 || reply.bytes_after != 0 { + return None; + } + icc_profile(reply.value) +} +#[cfg(target_os = "linux")] fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result { if wayland { return portal(stop); @@ -55,10 +115,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac use x11rb::{ connection::Connection, image::{Image, PixelLayout}, - protocol::{ - randr::ConnectionExt as _, - xproto::{AtomEnum, ConnectionExt, VisualClass}, - }, + protocol::{randr::ConnectionExt as _, xproto::VisualClass}, }; let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?; let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?; @@ -71,13 +128,16 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac { first.primary = true; } - let regions: Vec<(i16, i16, u16, u16, bool)> = - if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() }; + let regions: Vec<(i16, i16, u16, u16, bool, Vec)> = if monitors.is_empty() { + vec![(0, 0, w, h, true, Vec::new())] + } else { + monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary, m.outputs.clone())).collect() + }; if regions.len() > 16 { return Err("Too many screens for the color picker".into()); } let mut total = 0usize; - for &(x, y, mw, mh, _) in ®ions { + for &(x, y, mw, mh, _, _) in ®ions { if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) { return Err("Invalid X11 display geometry".into()); } @@ -92,20 +152,20 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac return Err("Screen picking requires an X11 TrueColor visual".into()); } let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?; - let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom; - let primary_profile = if atom == 0 { - None - } else { - let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?; - (reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value) - }; + let output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE"); let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8; let mut images = Vec::new(); - for (x, y, mw, mh, primary) in regions { + for (x, y, mw, mh, primary, outputs) in regions { if stop.load(std::sync::atomic::Ordering::Relaxed) { return Ok(Capture::Color(None)); } + let profile = output_profile_atom.and_then(|atom| outputs.iter().find_map(|output| output_icc_profile(&conn, *output, atom))).or_else(|| { + if !primary { + return None; + } + icc_profile_atom(&conn, "_ICC_PROFILE").and_then(|atom| root_icc_profile(&conn, screen.root, atom)) + }); let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4)); for row in 0..mh { for col in 0..mw { @@ -120,7 +180,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac width: usize::from(mw), height: usize::from(mh), rgba, - profile: if primary { primary_profile.clone() } else { None }, + profile, }); } Ok(Capture::Images(images)) @@ -359,6 +419,18 @@ mod tests { } #[cfg(target_os = "linux")] #[test] + fn x11_screen_capture_accepts_only_bounded_rgb_icc_profiles() { + let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes(); + let bytes = rgb.as_ref().clone(); + assert_eq!(icc_profile(bytes.clone()), Some(bytes)); + + assert!(icc_profile(Vec::new()).is_none()); + assert!(icc_profile(vec![0; MAX_ICC_PROFILE_BYTES + 1]).is_none()); + let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes(); + assert!(icc_profile(cmyk.as_ref().clone()).is_none()); + } + #[cfg(target_os = "linux")] + #[test] #[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"] fn screen_capture_reads_a_known_x11_fixture_pixel() { use x11rb::{ diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 1d25ac2a0..5df122c4d 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -191,6 +191,11 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string()) } +/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389). +fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> { + photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { let mut opts = photocraft_io::ExportOptions::default(); if let Some(q) = settings.jpeg_quality { @@ -207,22 +212,32 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i opts } +/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A +/// 24 MP, 5-layer PSD (572 MB) takes about 3 s to encode and 0.5 s or more to write and flush. +const ENCODE_SHARE: f32 = 0.85; + /// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017): /// encode, then write through `write`. Cancelling while encoding leaves the target untouched; -/// once writing starts the save can't be cancelled. +/// once writing starts the save can't be cancelled. The encoder's progress fills the first +/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389). fn save_through( doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl, - write: impl Fn(&str, &[u8]) -> Result<(), String>, + write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>, ) -> Result, String> { ctl.progress(0.0, "Encoding"); let opts = export_options(settings); - let r = crate::crash_guard::guard("Save", || photocraft_io::export(doc, path, &opts).map_err(|e| e.to_string()))?; + let cancelled = || ctl.check().is_err(); + let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, ""); + let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress); + let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?; ctl.commit()?; - ctl.progress(0.0, "Writing"); - write(path, &r.bytes)?; + ctl.progress(ENCODE_SHARE, "Writing"); + // The last bit is the flush and rename after the last chunk: 100% only once they're done. + write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?; + ctl.progress(1.0, ""); Ok(r.warnings) } @@ -232,7 +247,7 @@ fn save_file( settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl, ) -> Result, String> { - save_through(doc, path, settings, ctl, |p, b| write_atomic(Path::new(p), b)) + save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress)) } type SharedRecovery = Rc>>; @@ -341,7 +356,7 @@ pub fn native(automation: Option) -> }); let automation_save = automation.clone().map(|workspace| { std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| { - save_through(doc, path, settings, ctl, |p, b| workspace.write(p, b).map_err(|e| e.to_string())) + save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string())) }) as photocraft_ui_egui::SaveFileFn }); let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step; diff --git a/book/src/security/automation-security.md b/book/src/security/automation-security.md index ee0f989e7..12a767955 100644 --- a/book/src/security/automation-security.md +++ b/book/src/security/automation-security.md @@ -7,10 +7,15 @@ Automation is a privilege boundary because requests can cause filesystem access, - Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line. - Headless TCP refuses a successfully bound non-loopback address. - Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch. -- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless - JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the - MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections - are limited to 16, and headless/MCP batches to 256 steps. +- Encoded request lines are limited to 1 MiB on desktop and headless TCP, the headless + JSON-lines stdio server (`photocraft-cli serve`), and MCP stdio. The MCP line limit is + enforced before rmcp's line buffer; an oversized MCP line closes that stdio session because + its request ID cannot safely be parsed. Active TCP connections are limited to 16, and + headless/MCP batches to 256 steps. +- Capability-scoped automation file opens read in chunks without reserving from the file's + metadata length. A read rejects data beyond the length observed on the opened file, so growth + after the metadata check cannot expand the read. Large PSB files have no fixed automation file + size ceiling; their materialized bytes and decoded document still require available memory. - JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines transport enforces the same request/reply ceilings as TCP. @@ -49,8 +54,9 @@ Automation is a privilege boundary because requests can cause filesystem access, - no general per-client or per-tool capability model beyond filesystem read/write authority; - no automated bulk apply of the Preferences dialog; agents can change individual safe keys with `prefs.set`; -- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor - scratch-space accounting, or command-duration/cancellation budget; +- no application-specific JSON-depth policy (serde_json's parser has its default finite recursion + limit), aggregate document/session-memory accounting, compositor scratch-space accounting, + or command-duration/cancellation budget; - one thread per accepted TCP connection; - no structured security audit event stream; - desktop screenshot capture/encoding and document import/export have no automation-specific diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index a33d5059a..5000ad2e7 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -30,7 +30,12 @@ use photocraft_geom::Rect; -use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked}; +use std::borrow::Cow; + +use photocraft_raster::{Cancelled, Interrupt}; + +use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked, complete_masked_owned}; +use crate::par; use crate::poisson::membrane_fill_with; /// Options for [`fill`]. @@ -143,7 +148,8 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool { } /// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror. -fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant { +/// `Err` when cancelled. +fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result { let Src { w, h, ch, .. } = src; let (s, c) = (-deg.to_radians()).sin_cos(); // Output size: the rotated, scaled bounding box. @@ -153,8 +159,12 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant { let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0); let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0); let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] }; - let mut px = vec![0.0f32; ch]; - for y in 0..oh { + // Every pixel is sampled on its own, so the rows can be done in any order. + let row = |y: usize, irow: &mut [f32], orow: &mut [bool]| { + if ctl.cancelled() { + return; + } + let mut px = vec![0.0f32; ch]; for x in 0..ow { let (dx, dy) = ((x as f32 - ocx) / scale, (y as f32 - ocy) / scale); let mut sx = icx + dx * c - dy * s; @@ -163,13 +173,14 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant { sx = (w as f32 - 1.0) - sx; } if sample(src, sx, sy, &mut px) { - let i = y * ow + x; - out.ok[i] = true; - out.img[i * ch..(i + 1) * ch].copy_from_slice(&px); + orow[x] = true; + irow[x * ch..(x + 1) * ch].copy_from_slice(&px); } } - } - out + }; + par::chunks2_mut(&mut out.img, ow * ch, &mut out.ok, ow, true, row); + ctl.check()?; + Ok(out) } /// Fill `hole` in `img`, copying only from pixels where `source` is true (and not in the hole). @@ -191,81 +202,136 @@ pub fn fill_with( source: &[bool], opts: &FillOptions, ctl: &photocraft_raster::Interrupt, +) -> Result, photocraft_raster::Cancelled> { + fill_cow(w, h, ch, Cow::Borrowed(img), hole, source, opts, ctl) +} + +/// [`fill_with`] that fills `img` itself rather than a copy of it (a big window need not be held +/// twice). The result differs from `img` only in the hole. +#[allow(clippy::too_many_arguments)] +pub fn fill_owned( + w: usize, + h: usize, + ch: usize, + img: Vec, + hole: &[bool], + source: &[bool], + opts: &FillOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result, photocraft_raster::Cancelled> { + fill_cow(w, h, ch, Cow::Owned(img), hole, source, opts, ctl) +} + +#[allow(clippy::too_many_arguments)] +fn fill_cow( + w: usize, + h: usize, + ch: usize, + img: Cow<[f32]>, + hole: &[bool], + source: &[bool], + opts: &FillOptions, + ctl: &photocraft_raster::Interrupt, ) -> Result, photocraft_raster::Cancelled> { assert_eq!(img.len(), w * h * ch); assert_eq!(hole.len(), w * h); assert_eq!(source.len(), w * h); if !hole.iter().any(|h| *h) { - return Ok(img.to_vec()); + return Ok(img.into_owned()); } let params = CompleteParams { seed: opts.seed, adapt: opts.color_adaptation, ..Default::default() }; let gap = 2 * params.patch_radius + 2; - let ok: Vec = hole.iter().zip(source).map(|(h, s)| !h && *s).collect(); - let src = Src { w, h, ch, img, ok: &ok }; + let ok: Vec = par::map(w * h, true, |i| !hole[i] && source[i]); + let src = Src { w, h, ch, img: &img, ok: &ok }; // Variants: the identity (which holds the hole) first, then the adaptations. let mut extra: Vec = Vec::new(); if opts.mirror { - extra.push(variant(src, 0.0, 1.0, true)); + extra.push(variant(src, 0.0, 1.0, true, ctl)?); } for ° in &opts.rotations { - extra.push(variant(src, deg, 1.0, false)); + extra.push(variant(src, deg, 1.0, false, ctl)?); } if opts.scale { - extra.push(variant(src, 0.0, 0.8, false)); - extra.push(variant(src, 0.0, 1.25, false)); + extra.push(variant(src, 0.0, 0.8, false, ctl)?); + extra.push(variant(src, 0.0, 1.25, false, ctl)?); } - // Mosaic: identity at the left, variants in a column to its right, gaps that are no image. - let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0); - let col_h: usize = extra.iter().map(|v| v.h + gap).sum(); - let mw = if extra.is_empty() { w } else { w + gap + col_w }; - let mh = h.max(col_h); - let mut mimg = vec![0.0f32; mw * mh * ch]; - let mut roles = vec![Px::Void; mw * mh]; - for y in 0..h { - for x in 0..w { - let (s, d) = (y * w + x, y * mw + x); - mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]); - roles[d] = if hole[s] { - Px::Hole - } else if ok[s] { - Px::Source - } else { - Px::Known - }; + let role = |s: usize| { + if hole[s] { + Px::Hole + } else if ok[s] { + Px::Source + } else { + Px::Known } - } - let mut oy = 0; - for v in &extra { - for y in 0..v.h { - for x in 0..v.w { - let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x); - mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]); - if v.ok[s] { - roles[d] = Px::Source; - } + }; + // Mosaic: identity at the left, variants in a column to its right, gaps that are no image. + // Without variants it is the image itself. + let (mw, mh, mosaic, roles): (usize, usize, Option>, Vec) = if extra.is_empty() { + (w, h, None, par::map(w * h, true, role)) + } else { + let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0); + let col_h: usize = extra.iter().map(|v| v.h + gap).sum(); + let mw = w + gap + col_w; + let mh = h.max(col_h); + let mut mimg = vec![0.0f32; mw * mh * ch]; + let mut roles = vec![Px::Void; mw * mh]; + for y in 0..h { + for x in 0..w { + let (s, d) = (y * w + x, y * mw + x); + mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]); + roles[d] = role(s); } } - oy += v.h + gap; - } + let mut oy = 0; + for v in &extra { + ctl.check()?; + for y in 0..v.h { + for x in 0..v.w { + let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x); + mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]); + if v.ok[s] { + roles[d] = Px::Source; + } + } + } + oy += v.h + gap; + } + (mw, mh, Some(mimg), roles) + }; + drop((extra, ok)); ctl.check()?; // The completion is nearly all the work. let cancel = || ctl.cancelled(); let progress = |f: f32| ctl.progress(f * 0.99); - let filled = complete_masked(mw, mh, ch, &mimg, &roles, ¶ms, &photocraft_raster::Interrupt::new(&cancel, &progress))?; - let mut out = img.to_vec(); - match filled { - Some(f) => { - for y in 0..h { - for x in 0..w { - if hole[y * w + x] { - let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch); - out[d..d + ch].copy_from_slice(&f[s..s + ch]); - } - } + let inner = photocraft_raster::Interrupt::new(&cancel, &progress); + let out = match mosaic { + // Without variants the image itself is completed (it changes only in the hole). + None => { + let filled: Result, Cow<[f32]>> = match img { + Cow::Owned(v) => complete_masked_owned(w, h, ch, v, &roles, ¶ms, &inner)?.map_err(Cow::Owned), + Cow::Borrowed(b) => complete_masked(w, h, ch, b, &roles, ¶ms, &inner)?.ok_or(Cow::Borrowed(b)), + }; + match filled { + Ok(f) => f, + Err(img) => membrane_fill_with(w, h, ch, &img, hole, ctl)?, } } - None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?, - } + Some(mimg) => match complete_masked(mw, mh, ch, &mimg, &roles, ¶ms, &inner)? { + Some(f) => { + let mut out = img.into_owned(); + for y in 0..h { + for x in 0..w { + if hole[y * w + x] { + let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch); + out[d..d + ch].copy_from_slice(&f[s..s + ch]); + } + } + } + out + } + None => membrane_fill_with(w, h, ch, &img, hole, ctl)?, + }, + }; ctl.check()?; ctl.progress(1.0); Ok(out) @@ -305,36 +371,50 @@ const AUTO_SIDE: usize = 384; /// pixels that may be copied from (never the hole). Falls back to everything outside the hole /// when the area would be too small to copy from. pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec { - use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_with}; + // Never cancelled; the fallback (everything outside the hole) is unreachable. + auto_sampling_with(w, h, ch, img, hole, &Interrupt::NONE).unwrap_or_else(|_| hole.iter().map(|h| !h).collect()) +} + +/// [`auto_sampling`] that can be cancelled. `Err(Cancelled)` when `ctl` was cancelled. +pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], ctl: &Interrupt) -> Result, Cancelled> { + use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_cancellable}; assert_eq!(img.len(), w * h * ch); assert_eq!(hole.len(), w * h); - let everything: Vec = hole.iter().map(|h| !h).collect(); + let everything = || -> Vec { par::map(hole.len(), true, |i| !hole[i]) }; if w == 0 || h == 0 || ch == 0 || !hole.iter().any(|h| *h) { - return everything; + return Ok(everything()); } // Work at a reduced scale for big windows (the area is smooth at this scale). let k = w.max(h).div_ceil(AUTO_SIDE).max(1); let (sw, sh) = (w.div_ceil(k), h.div_ceil(k)); let nc = ch.min(3); - let mut planes = vec![vec![0.0f32; sw * sh]; nc]; - let mut shole = vec![false; sw * sh]; - let mut cnt = vec![0.0f32; sw * sh]; - for y in 0..h { - for x in 0..w { - let i = (y / k) * sw + x / k; - let p = y * w + x; - if hole[p] { - shole[i] = true; - } - for (c, pl) in planes.iter_mut().enumerate() { - pl[i] += img[p * ch + c]; - } - cnt[i] += 1.0; + // Each cell sums its k × k pixels, a row of cells at a time (in the same order as one pass + // over the image would). + // Per cell: the sums of its colour planes, its pixel count and whether it holds the hole. + let mut sums = vec![0.0f32; sw * sh * (nc + 2)]; + par::chunks_mut(&mut sums, sw * (nc + 2), true, |sy, row| { + if ctl.cancelled() { + return; } - } - for pl in &mut planes { - pl.iter_mut().zip(&cnt).for_each(|(v, n)| *v /= n.max(1.0)); - } + for y in sy * k..((sy + 1) * k).min(h) { + for x in 0..w { + let cell = &mut row[(x / k) * (nc + 2)..(x / k + 1) * (nc + 2)]; + let p = y * w + x; + if hole[p] { + cell[nc + 1] = 1.0; + } + for c in 0..nc { + cell[c] += img[p * ch + c]; + } + cell[nc] += 1.0; + } + } + }); + ctl.check()?; + let cell = |i: usize| &sums[i * (nc + 2)..(i + 1) * (nc + 2)]; + let planes: Vec> = (0..nc).map(|c| (0..sw * sh).map(|i| cell(i)[c] / cell(i)[nc].max(1.0)).collect()).collect(); + let shole: Vec = (0..sw * sh).map(|i| cell(i)[nc + 1] > 0.0).collect(); + drop(sums); // Features: local means and local contrast (of the luma-ish average of the colour planes). let r = 3; let means: Vec> = planes.iter().map(|p| box_mean(sw, sh, p, r)).collect(); @@ -354,8 +434,9 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) let ring_w = 6.0f32 / k as f32 + 1.0; let ring: Vec = (0..sw * sh).filter(|&i| !shole[i] && dist[i] <= ring_w).collect(); if ring.is_empty() { - return everything; + return Ok(everything()); } + ctl.check()?; // A few cluster centres of the ring's features (k-means, deterministic start). let kc = 6.min(ring.len()); let mut f = vec![0.0f32; nf]; @@ -410,20 +491,21 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) // Contrast-sensitive smoothness (the weight of a squared colour difference). The scale of // "similar" and this weight were fitted to Photoshop's Auto overlays (mean IoU 0.92 on ten // synthetic and public-domain cases). - let area = grid_cut_with(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05); + ctl.check()?; + let area = + grid_cut_cancellable(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05, &|| ctl.cancelled()).ok_or(Cancelled)?; let mut out = vec![false; w * h]; - for y in 0..h { - for x in 0..w { - let i = y * w + x; - out[i] = !hole[i] && area[(y / k) * sw + x / k]; + par::chunks_mut(&mut out, w, true, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + *o = !hole[y * w + x] && area[(y / k) * sw + x / k]; } - } + }); // Too little to copy from (fewer source pixels than twice the hole): sample everything. - let n_hole = hole.iter().filter(|h| **h).count(); - if out.iter().filter(|o| **o).count() < 2 * n_hole.max(64) { - return everything; + let n_hole = par::count(hole.len(), |i| hole[i]); + if par::count(out.len(), |i| out[i]) < 2 * n_hole.max(64) { + return Ok(everything()); } - out + Ok(out) } /// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation @@ -497,6 +579,36 @@ mod tests { assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill"); } + #[test] + fn filling_in_place_is_filling_a_copy() { + let (w, h) = (90, 70); + let rgb: Vec = noise(w, h, 4).iter().enumerate().map(|(i, v)| v * 0.5 + 0.3 * ((i / 3 % w) % 11 < 5) as u8 as f32).collect(); + let hole: Vec = (0..w * h).map(|i| (30..52).contains(&(i % w)) && (25..44).contains(&(i / w))).collect(); + let source: Vec = (0..w * h).map(|i| i % w < 75).collect(); + let none = Interrupt::NONE; + for opts in [FillOptions::default(), FillOptions { color_adaptation: Adapt::NONE, mirror: true, ..Default::default() }] { + let copy = fill_with(w, h, 3, &rgb, &hole, &source, &opts, &none).unwrap(); + let owned = fill_owned(w, h, 3, rgb.clone(), &hole, &source, &opts, &none).unwrap(); + assert_eq!(copy.iter().map(|v| v.to_bits()).collect::>(), owned.iter().map(|v| v.to_bits()).collect::>()); + // Only the hole changes. + assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| owned[i * 3 + c].to_bits() == rgb[i * 3 + c].to_bits()))); + } + // Nothing to copy from: the membrane, either way. + let nowhere = vec![false; w * h]; + let copy = fill_with(w, h, 3, &rgb, &hole, &nowhere, &FillOptions::default(), &none).unwrap(); + assert_eq!(copy, fill_owned(w, h, 3, rgb.clone(), &hole, &nowhere, &FillOptions::default(), &none).unwrap()); + } + + #[test] + fn auto_sampling_can_be_cancelled() { + let (w, h) = (200, 160); + let img: Vec = noise(w, h, 5).iter().enumerate().map(|(i, v)| v * 0.1 + if (i / 3) % w < 120 { 0.3 } else { 0.6 }).collect(); + let hole: Vec = (0..w * h).map(|i| (80..110).contains(&(i % w)) && (60..90).contains(&(i / w))).collect(); + let yes = || true; + assert!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::cancel_only(&yes)).is_err()); + assert_eq!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::NONE).unwrap(), auto_sampling(w, h, 3, &img, &hole)); + } + #[test] fn fills_only_the_hole_with_known_texture() { let (w, h) = (48, 32); @@ -671,9 +783,9 @@ mod tests { let img = vec![0.5f32; 10 * 6]; let ok = vec![true; 60]; let src = Src { w: 10, h: 6, ch: 1, img: &img, ok: &ok }; - let v = variant(src, 90.0, 1.0, false); + let v = variant(src, 90.0, 1.0, false, &Interrupt::NONE).unwrap(); assert_eq!((v.w, v.h), (6, 10)); - let v = variant(src, 0.0, 0.8, true); + let v = variant(src, 0.0, 0.8, true, &Interrupt::NONE).unwrap(); assert_eq!((v.w, v.h), (8, 5)); assert!(v.ok.iter().filter(|o| **o).count() > 20); } diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index f72d2d5c2..37e91da49 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -34,14 +34,23 @@ //! `w × h` hole mask, and return a full buffer equal to the input outside the hole. They are //! deterministic (fixed-seed hash RNG, results independent of thread count). +use std::borrow::Cow; + +use crate::par; use crate::poisson::membrane_fill; mod proximity_integral; use proximity_integral::HoleIntegral; -/// Below this many grid cells a level runs single-threaded (thread hand-off costs more than it saves). -#[cfg(not(target_arch = "wasm32"))] -const PAR_MIN: usize = 128 * 128; +/// Below this many cells a pass runs on the calling thread (thread hand-off costs more than it +/// saves). Every pass gives the same result either way. +const PAR_MIN: usize = 64 * 64; + +/// [`PAR_MIN`] for the passes that match or vote a patch per cell. +const PAR_MIN_PATCHES: usize = 16 * 16; + +/// Largest level (in cells) whose source patches' moments [`Solver::prepare`] keeps. +const SOURCE_MOMENTS_MAX: usize = 1 << 20; /// SplitMix64: tiny deterministic RNG. #[derive(Clone)] @@ -57,6 +66,10 @@ impl Rng { z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); z ^ (z >> 31) } + /// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]). + pub(crate) fn skip(&mut self, n: u64) { + self.0 = self.0.wrapping_add(n.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + } /// Uniform integer in `lo..=hi`. pub(crate) fn range(&mut self, lo: i32, hi: i32) -> i32 { if hi <= lo { @@ -128,40 +141,77 @@ pub enum Px { Hole, } -/// One pyramid level. -#[derive(Clone)] -struct Level { +/// One pyramid level. Level 0 borrows the caller's buffers until it is solved. +struct Level<'a> { w: usize, h: usize, - img: Vec, - px: Vec, + img: Cow<'a, [f32]>, + px: Cow<'a, [Px]>, +} + +impl Level<'_> { + const EMPTY: Level<'static> = Level { w: 0, h: 0, img: Cow::Borrowed(&[]), px: Cow::Borrowed(&[]) }; +} + +/// The half-open box `[x0, x1) × [y0, y1)` of a level's grid. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Bounds { + x0: usize, + y0: usize, + x1: usize, + y1: usize, +} + +impl Bounds { + /// Grown by `r` on every side, within a `w × h` grid. + fn grow(self, r: usize, w: usize, h: usize) -> Bounds { + Bounds { x0: self.x0.saturating_sub(r), y0: self.y0.saturating_sub(r), x1: self.x1.saturating_add(r).min(w), y1: self.y1.saturating_add(r).min(h) } + } + + /// The same box on the next coarser level (whose cells are 2 × 2 of these). + fn half(self) -> Bounds { + Bounds { x0: self.x0 / 2, y0: self.y0 / 2, x1: self.x1.div_ceil(2), y1: self.y1.div_ceil(2) } + } + + fn width(&self) -> usize { + self.x1.saturating_sub(self.x0) + } + + fn height(&self) -> usize { + self.y1.saturating_sub(self.y0) + } + + fn area(&self) -> usize { + self.width() * self.height() + } } /// Half-size level: a cell is a hole if any of its pixels is; else it averages its non-void /// pixels, and may be copied from only if all of them may. -fn downsample(l: &Level, ch: usize) -> Level { +fn downsample(l: &Level, ch: usize) -> Level<'static> { let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2)); let mut img = vec![0.0f32; w * h * ch]; let mut px = vec![Px::Void; w * h]; - for y in 0..h { - for x in 0..w { + let (lw, lh, limg, lpx) = (l.w, l.h, &*l.img, &*l.px); + let row = |y: usize, irow: &mut [f32], prow: &mut [Px]| { + for (x, p) in prow.iter_mut().enumerate() { let mut n = 0.0f32; - let o = (y * w + x) * ch; + let o = x * ch; let (mut hole, mut all_source, mut any) = (false, true, false); for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { let (sx, sy) = (x * 2 + dx, y * 2 + dy); - if sx >= l.w || sy >= l.h { + if sx >= lw || sy >= lh { continue; } - let si = sy * l.w + sx; - match l.px[si] { + let si = sy * lw + sx; + match lpx[si] { Px::Hole => hole = true, Px::Void => all_source = false, k => { any = true; all_source &= k == Px::Source; for c in 0..ch { - img[o + c] += l.img[si * ch + c]; + irow[o + c] += limg[si * ch + c]; } n += 1.0; } @@ -169,10 +219,10 @@ fn downsample(l: &Level, ch: usize) -> Level { } if n > 0.0 { for c in 0..ch { - img[o + c] /= n; + irow[o + c] /= n; } } - px[y * w + x] = if hole { + *p = if hole { Px::Hole } else if !any { Px::Void @@ -182,11 +232,14 @@ fn downsample(l: &Level, ch: usize) -> Level { Px::Known }; } - } - Level { w, h, img, px } + }; + par::chunks2_mut(&mut img, w * ch, &mut px, w, w * h >= PAR_MIN, row); + Level { w, h, img: Cow::Owned(img), px: Cow::Owned(px) } } -/// Summed-area table of a boolean grid (`(w+1) × (h+1)`). +/// Summed-area table of a boolean grid (`(w+1) × (h+1)`). Not inlined: in [`synthesize`] it made +/// Create Texture's candidate loop slower (measured). +#[inline(never)] fn integral(w: usize, h: usize, m: &[bool]) -> Vec { let mut s = vec![0u32; (w + 1) * (h + 1)]; for y in 0..h { @@ -225,9 +278,204 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize, b } -/// A finished coarser level: width, height, pixels and match field (source centre per target, -/// `i32::MIN` where there is none). -type Coarser = (usize, usize, Vec, Vec<(i32, i32)>); +/// Bounds of the hole pixels of a role grid. +fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option { + // Per row the first and last hole column; the union of the rows' spans. + let rows: Vec> = par::map(h, w * h >= PAR_MIN, |y| { + let row = px.get(y * w..(y + 1) * w)?; + let x0 = row.iter().position(|k| *k == Px::Hole)?; + let x1 = row.iter().rposition(|k| *k == Px::Hole).unwrap_or(x0) + 1; + Some((x0, x1)) + }); + let mut b: Option = None; + for (y, span) in rows.into_iter().enumerate() { + if let Some((x0, x1)) = span { + b = Some(match b { + None => Bounds { x0, y0: y, x1, y1: y + 1 }, + Some(b) => Bounds { x0: b.x0.min(x0), y0: b.y0, x1: b.x1.max(x1), y1: y + 1 }, + }); + } + } + b +} + +/// Valid source centres: the whole `2r+1` patch inside the grid and copyable. Found per row +/// (each pixel's horizontal run), then per column of runs. `None` when cancelled. +fn valid_sources(w: usize, h: usize, r: usize, px: &[Px], ctl: &photocraft_raster::Interrupt) -> Option> { + const BAND: usize = 64; + let side = 2 * r + 1; + let par = w * h >= PAR_MIN; + let mut run = vec![false; w * h]; + if w < side || h < side { + return Some(run); + } + // Per row: is the run of `side` pixels centred on each one all copyable (a sliding count)? + par::chunks_mut(&mut run, w, par, |y, out| { + if y % BAND == 0 && ctl.cancelled() { + return; + } + let row = &px[y * w..(y + 1) * w]; + let copyable = |x: usize| usize::from(row[x] == Px::Source); + let mut count: usize = (0..side).map(copyable).sum(); + out[r] = count == side; + for (x, o) in out.iter_mut().enumerate().take(w - r).skip(r + 1) { + count = count + copyable(x + r) - copyable(x - r - 1); + *o = count == side; + } + }); + if ctl.cancelled() { + return None; + } + // Per column, in bands of rows: how many such runs end each row in a row; a centre is valid + // where `side` of them stack up to `r` rows below it. + let mut valid = vec![false; w * h]; + par::chunks_mut(&mut valid, BAND * w, par, |band, out| { + if ctl.cancelled() { + return; + } + let (y0, rows) = (band * BAND, out.len() / w); + let mut stack = vec![0usize; w]; + for yy in y0.saturating_sub(r)..(y0 + rows + r).min(h) { + for (x, n) in stack.iter_mut().enumerate() { + *n = if run[yy * w + x] { *n + 1 } else { 0 }; + } + // `yy` is the bottom row of centre `y`'s patch. + if yy < y0 + r || yy < 2 * r { + continue; + } + let y = yy - r; + for (v, n) in out[(y - y0) * w..(y - y0 + 1) * w].iter_mut().zip(&stack).take(w - r).skip(r) { + *v = *n >= side; + } + } + }); + if ctl.cancelled() { + return None; + } + Some(valid) +} + +/// Columns per block of [`Sources`]' index. +const SOURCE_BLOCK: usize = 64; + +/// The valid source centres in row-major order, indexed by counts per row and per block of +/// [`SOURCE_BLOCK`] columns (so the `k`-th is found without listing them all). +struct Sources { + /// Valid centres before each row (`h + 1` entries). + row_start: Vec, + /// Per row, the valid centres in it before each block. + block_start: Vec, + blocks: usize, +} + +impl Sources { + fn new(valid: &[bool], w: usize) -> Sources { + let h = valid.len().checked_div(w).unwrap_or(0); + let blocks = w.div_ceil(SOURCE_BLOCK); + let mut block_start = vec![0u32; h * blocks]; + // Per row: the block prefix, and the row's count. + let counts: Vec = par::map(h, valid.len() >= PAR_MIN, |y| valid[y * w..(y + 1) * w].iter().filter(|v| **v).count()); + par::chunks_mut(&mut block_start, blocks, valid.len() >= PAR_MIN, |y, out| { + let mut n = 0u32; + for (b, o) in out.iter_mut().enumerate() { + *o = n; + n += valid[y * w + b * SOURCE_BLOCK..y * w + ((b + 1) * SOURCE_BLOCK).min(w)].iter().filter(|v| **v).count() as u32; + } + }); + let mut row_start = Vec::with_capacity(counts.len() + 1); + let mut total = 0usize; + row_start.push(0); + for c in counts { + total += c; + row_start.push(total); + } + Sources { row_start, block_start, blocks } + } + + fn len(&self) -> usize { + self.row_start.last().copied().unwrap_or(0) + } + + /// The `k`-th valid centre in row-major order (`k < len`). + fn nth(&self, k: usize, valid: &[bool], w: usize) -> (i32, i32) { + let y = self.row_start.partition_point(|&s| s <= k).saturating_sub(1); + let left = k.saturating_sub(self.row_start.get(y).copied().unwrap_or(0)); + let row_blocks = self.block_start.get(y * self.blocks..(y + 1) * self.blocks).unwrap_or(&[]); + let b = row_blocks.partition_point(|&s| s as usize <= left).saturating_sub(1); + let mut left = left.saturating_sub(row_blocks.get(b).copied().unwrap_or(0) as usize); + let x0 = b * SOURCE_BLOCK; + for (x, v) in valid.iter().skip(y * w + x0).take(w.saturating_sub(x0)).enumerate() { + if *v { + if left == 0 { + return ((x0 + x) as i32, y as i32); + } + left -= 1; + } + } + (0, 0) + } + + /// All of them, in row-major order. + fn all(&self, valid: &[bool], w: usize) -> Vec<(i32, i32)> { + let mut out = Vec::with_capacity(self.len()); + for (i, v) in valid.iter().enumerate() { + if *v { + out.push(((i % w) as i32, (i / w) as i32)); + } + } + out + } +} + +/// Per cell of `tb` (the hole's bounds `hb` grown by the patch radius, where all of them lie): is +/// it a target centre (its patch overlaps the hole)? +fn targets_around(w: usize, r: usize, px: &[Px], hb: Bounds, tb: Bounds) -> Vec { + // Hole counts summed over the hole's bounds: every hole pixel lies inside them. + let (bw, bh) = (hb.width(), hb.height()); + let mut s = vec![0u32; (bw + 1) * (bh + 1)]; + for y in 0..bh { + let mut row = 0u32; + for x in 0..bw { + row += u32::from(px[(hb.y0 + y) * w + hb.x0 + x] == Px::Hole); + s[(y + 1) * (bw + 1) + x + 1] = s[y * (bw + 1) + x + 1] + row; + } + } + let mut targets = vec![false; tb.area()]; + let ri = r as i32; + let (ox, oy) = (hb.x0 as i32, hb.y0 as i32); + for y in tb.y0..tb.y1 { + for x in tb.x0..tb.x1 { + let k = (y - tb.y0) * tb.width() + (x - tb.x0); + let (x, y) = (x as i32, y as i32); + targets[k] = window_count(&s, bw, bh, x - ri - ox, y - ri - oy, x + ri + 1 - ox, y + ri + 1 - oy) > 0; + } + } + targets +} + +/// A finished coarser level: its size, pixels and match field (the source centre per cell of +/// its target box, `i32::MIN` where there is none; empty when it had nothing to copy). +struct Coarser { + w: usize, + h: usize, + img: Vec, + field: Vec<(i32, i32)>, + tbox: Bounds, +} + +impl Coarser { + /// The match of cell `(x, y)` (`i32::MIN` outside the target box). + fn field_at(&self, x: usize, y: usize) -> (i32, i32) { + let t = self.tbox; + if x < t.x0 || x >= t.x1 || y < t.y0 || y >= t.y1 { + return (i32::MIN, i32::MIN); + } + self.field.get((y - t.y0) * t.width() + (x - t.x0)).copied().unwrap_or((i32::MIN, i32::MIN)) + } +} + +/// Most channels whose moments the colour adaptation fits (the rest are copied as they are). +const MOM_CH: usize = 8; /// Per-level PatchMatch/EM state. struct Solver<'a> { @@ -239,11 +487,22 @@ struct Solver<'a> { px: &'a [Px], /// Valid source centres: the whole patch is inside the grid and may be copied from. valid: Vec, - valid_list: Vec<(i32, i32)>, - /// Target centres (patch overlaps the hole), row-major. + /// The hole's bounds and the targets' (the hole's grown by the patch radius): nothing outside + /// them changes or is matched. The match field, its costs and these per-target tables are + /// kept over `tbox`, row-major. + hole_box: Bounds, + tbox: Bounds, + /// Target centres (the patch overlaps the hole). targets: Vec, - /// No pixel of the level is [`Px::Void`] (patches inside the grid need no per-pixel check). - no_void: bool, + /// Per cell of `tbox`: the whole patch is inside the grid and not void (so its distances + /// need no bounds checks and its own moments can be kept). + full: Vec, + /// With colour adaptation, per cell of `tbox` and channel (at most [`MOM_CH`]): `Σt, Σt²` of + /// the patch, for full patches, as of the last [`Solver::costs`]. + tmom: Vec<[f32; 2]>, + /// With colour adaptation where it pays: per source centre and channel, `Σs, Σs²` of its + /// patch (source pixels never change within a level). Empty otherwise. + smom: Vec<[f32; 2]>, adapt: Adapt, /// Cancellation: checked per row band; a cancelled solve's output is discarded. ctl: photocraft_raster::Interrupt<'a>, @@ -268,32 +527,66 @@ impl Solver<'_> { x >= 0 && y >= 0 && (x as usize) < self.w && (y as usize) < self.h && self.valid[y as usize * self.w + x as usize] } - /// Distance between the target patch at `t` (its pixels inside the grid and not void) and the - /// source patch at `s`, per sample; with colour adaptation the source is fitted first. - fn dist(&self, t: (i32, i32), s: (i32, i32), cutoff: f32) -> f32 { - if !self.adapt.is_none() { - return self.dist_adapted(t, s); + /// Mark the full target patches (`full`), and keep the source moments where they pay off. + fn prepare(&mut self, keep_source_moments: bool) { + let (w, h, r, tb) = (self.w, self.h, self.r as usize, self.tbox); + let side = 2 * r + 1; + let mut full = vec![false; tb.area()]; + let px = self.px; + par::chunks_mut(&mut full, tb.width(), tb.area() >= PAR_MIN_PATCHES, |ty, out| { + let y = tb.y0 + ty; + if y < r || y + r >= h { + return; + } + for (tx, f) in out.iter_mut().enumerate() { + let x = tb.x0 + tx; + if x < r || x + r >= w { + continue; + } + *f = (y - r..=y + r).all(|yy| px[yy * w + x - r..yy * w + x - r + side].iter().all(|k| *k != Px::Void)); + } + }); + self.full = full; + if self.adapt.is_none() { + return; } + let c8 = self.ch.min(MOM_CH); + self.tmom = vec![[0.0; 2]; tb.area() * c8]; + if !keep_source_moments { + return; + } + let (ch, img, valid, ctl) = (self.ch, &*self.img, &self.valid, &self.ctl); + let mut smom = vec![[0.0f32; 2]; w * h * c8]; + par::chunks_mut(&mut smom, w * c8, w * h >= PAR_MIN_PATCHES, |y, out| { + if ctl.cancelled() { + return; + } + for x in 0..w { + if valid[y * w + x] { + patch_moments(img, w, ch, c8, side, x - r, y - r, &mut out[x * c8..(x + 1) * c8]); + } + } + }); + self.smom = smom; + } + + /// Distance between the target patch at `t` (its pixels inside the grid and not void; `k` its + /// index in the `tbox` tables) and the source patch at `s`, per sample; with colour adaptation + /// the source is fitted first. + #[inline] + fn dist(&self, t: (i32, i32), k: usize, s: (i32, i32), cutoff: f32) -> f32 { + let full = self.full[k]; + if !self.adapt.is_none() { + return if full { self.dist_adapted_full(k, t, s) } else { self.dist_adapted(t, s) }; + } + if full { self.dist_full(t, s, cutoff) } else { self.dist_clipped(t, s, cutoff) } + } + + /// [`Solver::dist`] without adaptation, of a patch that may be clipped. + fn dist_clipped(&self, t: (i32, i32), s: (i32, i32), cutoff: f32) -> f32 { let (w, h, ch, r) = (self.w as i32, self.h as i32, self.ch, self.r); let full = ((2 * r + 1) * (2 * r + 1)) as f32 * ch as f32; let mut sum = 0.0f32; - // The usual case: the whole target patch is image. The same sums in the same order as - // below, a row at a time. - if self.no_void && t.0 >= r && t.1 >= r && t.0 + r < w && t.1 + r < h { - let len = (2 * r + 1) as usize * ch; - for dy in -r..=r { - let ti = ((t.1 + dy) as usize * self.w + (t.0 - r) as usize) * ch; - let si = ((s.1 + dy) as usize * self.w + (s.0 - r) as usize) * ch; - for (a, b) in self.img[ti..ti + len].iter().zip(&self.img[si..si + len]) { - let d = a - b; - sum += d * d; - } - if sum > cutoff * full { - return f32::INFINITY; - } - } - return sum / full; - } let mut n = 0usize; for dy in -r..=r { let ty = t.1 + dy; @@ -322,6 +615,31 @@ impl Solver<'_> { if n == 0 { f32::INFINITY } else { sum / n as f32 } } + /// [`Solver::dist_clipped`] of a full patch: the same sums, in the same order. It gives up + /// (infinity) as soon as the sum shows the patch is worse than `cutoff`, which the sum, only + /// growing, can show after any pixel: the caller only asks whether it is better. + fn dist_full(&self, t: (i32, i32), s: (i32, i32), cutoff: f32) -> f32 { + let (w, ch, r) = (self.w, self.ch, self.r); + let side = (2 * r + 1) as usize; + let full = ((2 * r + 1) * (2 * r + 1)) as f32 * ch as f32; + let (span, limit) = (side * ch, cutoff * full); + let mut sum = 0.0f32; + for dy in -r..=r { + let ti = ((t.1 + dy) as usize * w + (t.0 - r) as usize) * ch; + let si = ((s.1 + dy) as usize * w + (s.0 - r) as usize) * ch; + for (tp, sp) in self.img[ti..ti + span].chunks_exact(ch).zip(self.img[si..si + span].chunks_exact(ch)) { + for (a, b) in tp.iter().zip(sp) { + let d = a - b; + sum += d * d; + } + if sum > limit { + return f32::INFINITY; + } + } + } + sum / (side * side * ch) as f32 + } + /// Moments of the target patch at `t` and the source patch at `s` per channel: /// `[Σt, Σt², Σs, Σs², Σts]`, and the sample count. fn moments(&self, t: (i32, i32), s: (i32, i32), m: &mut [[f32; 5]]) -> usize { @@ -355,133 +673,345 @@ impl Solver<'_> { n } + /// [`Solver::moments`] of a full target patch (`k` in the `tbox` tables): the target's own + /// sums kept from [`Solver::costs`], the source's from [`Solver::prepare`] when kept, each + /// summed in the same order as there. + fn moments_full(&self, k: usize, t: (i32, i32), s: (i32, i32), m: &mut [[f32; 5]]) { + let (w, ch, r) = (self.w, self.ch, self.r); + let c8 = m.len(); + let side = (2 * r + 1) as usize; + let tm = &self.tmom[k * c8..(k + 1) * c8]; + let sm = self.smom.get((s.1 as usize * w + s.0 as usize) * c8..(s.1 as usize * w + s.0 as usize + 1) * c8); + let mut acc = [[0.0f32; 3]; MOM_CH]; + for dy in -r..=r { + let ti = ((t.1 + dy) as usize * w + (t.0 - r) as usize) * ch; + let si = ((s.1 + dy) as usize * w + (s.0 - r) as usize) * ch; + let (trow, srow) = (&self.img[ti..ti + side * ch], &self.img[si..si + side * ch]); + if sm.is_some() { + for (tp, sp) in trow.chunks_exact(ch).zip(srow.chunks_exact(ch)) { + for c in 0..c8 { + acc[c][2] += tp[c] * sp[c]; + } + } + } else { + for (tp, sp) in trow.chunks_exact(ch).zip(srow.chunks_exact(ch)) { + for c in 0..c8 { + let (a, b) = (tp[c], sp[c]); + acc[c][0] += b; + acc[c][1] += b * b; + acc[c][2] += a * b; + } + } + } + } + for (c, mc) in m.iter_mut().enumerate() { + let [ss, sss] = match sm { + Some(sm) => sm[c], + None => [acc[c][0], acc[c][1]], + }; + *mc = [tm[c][0], tm[c][1], ss, sss, acc[c][2]]; + } + } + fn dist_adapted(&self, t: (i32, i32), s: (i32, i32)) -> f32 { - let mut m = [[0.0f32; 5]; 8]; - let ch = self.ch.min(8); + let mut m = [[0.0f32; 5]; MOM_CH]; + let ch = self.ch.min(MOM_CH); let n = self.moments(t, s, &mut m[..ch]); if n == 0 { return f32::INFINITY; } - let nf = n as f32; - let mut sum = 0.0f32; - for mc in &m[..ch] { - let (g, b) = fit(self.adapt, nf, mc[0], mc[1], mc[2], mc[3]); - // Σ (t − g s − b)², expanded. - sum += mc[1] + g * g * mc[3] + nf * b * b - 2.0 * g * mc[4] - 2.0 * b * mc[0] + 2.0 * g * b * mc[2]; - } - sum.max(0.0) / (nf * ch as f32) + adapted_cost(self.adapt, n as f32, &m[..ch]) + } + + fn dist_adapted_full(&self, k: usize, t: (i32, i32), s: (i32, i32)) -> f32 { + let mut m = [[0.0f32; 5]; MOM_CH]; + let ch = self.ch.min(MOM_CH); + self.moments_full(k, t, s, &mut m[..ch]); + let side = (2 * self.r + 1) as usize; + adapted_cost(self.adapt, (side * side) as f32, &m[..ch]) } /// Gain and bias per channel for the match `t → s` (identity without adaptation). - fn transform(&self, t: (i32, i32), s: (i32, i32), out: &mut [(f32, f32)]) { + fn transform(&self, t: (i32, i32), k: usize, s: (i32, i32), out: &mut [(f32, f32)]) { if self.adapt.is_none() { out.iter_mut().for_each(|v| *v = (1.0, 0.0)); return; } - let mut m = [[0.0f32; 5]; 8]; - let ch = self.ch.min(8); - let n = self.moments(t, s, &mut m[..ch]); + let mut m = [[0.0f32; 5]; MOM_CH]; + let ch = self.ch.min(MOM_CH); + let n = if self.full[k] { + self.moments_full(k, t, s, &mut m[..ch]); + let side = (2 * self.r + 1) as usize; + side * side + } else { + self.moments(t, s, &mut m[..ch]) + }; for (c, o) in out.iter_mut().enumerate() { *o = if n == 0 || c >= ch { (1.0, 0.0) } else { fit(self.adapt, n as f32, m[c][0], m[c][1], m[c][2], m[c][3]) }; } } - /// One PatchMatch pass over all target rows, in parallel bands (each band propagates within itself). + /// One PatchMatch step at target `t` (`k` in the target box) whose match is `cur`: propagation + /// from the matches of the neighbours visited just before it (`left` in its row, `up` in its + /// column, in this pass's direction `step`), then random search in exponentially shrinking + /// windows around the best (two numbers from `rng` per window). + #[allow(clippy::too_many_arguments)] + #[inline] + fn pm_step( + &self, + t: (i32, i32), + k: usize, + cur: ((i32, i32), f32), + left: Option<(i32, i32)>, + up: Option<(i32, i32)>, + step: i32, + rng: &mut Rng, + search_radius: i32, + ) -> ((i32, i32), f32) { + let (mut best, mut bc) = cur; + if let Some(c) = left { + let cand = (c.0 + step, c.1); + if self.is_valid(cand.0, cand.1) && cand != best { + let d = self.dist(t, k, cand, bc); + if d < bc { + (best, bc) = (cand, d); + } + } + } + if let Some(c) = up { + let cand = (c.0, c.1 + step); + if self.is_valid(cand.0, cand.1) && cand != best { + let d = self.dist(t, k, cand, bc); + if d < bc { + (best, bc) = (cand, d); + } + } + } + let mut rad = search_radius; + while rad >= 1 { + let cand = (best.0 + rng.range(-rad, rad), best.1 + rng.range(-rad, rad)); + if self.is_valid(cand.0, cand.1) && cand != best { + let d = self.dist(t, k, cand, bc); + if d < bc { + (best, bc) = (cand, d); + } + } + rad /= 2; + } + (best, bc) + } + + /// One PatchMatch pass over all target rows, in bands of the grid's rows: each band is + /// visited in raster order (backwards on odd passes), propagating within itself, its random + /// numbers seeded by its index. `nnf` and `cost` are over the target box. The bands run in + /// parallel; when there are fewer of them than threads, each is also swept in wavefronts of + /// blocks (a cell depends only on its neighbours before it in its row and its column), which + /// visits every cell with the same neighbours and random numbers as the raster order. fn patchmatch(&self, nnf: &mut [(i32, i32)], cost: &mut [f32], pass: usize, seed: u64, search_radius: i32) { const BAND: usize = 16; - let w = self.w; + let tb = self.tbox; + let tw = tb.width(); + if tw == 0 || tb.height() == 0 || nnf.len() < tb.area() || cost.len() < tb.area() { + return; + } let reverse = pass % 2 == 1; - let band_fn = |bi: usize, nn: &mut [(i32, i32)], co: &mut [f32]| { + let step: i32 = if reverse { -1 } else { 1 }; + let band_seed = |bi: usize| seed ^ (bi as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) ^ ((pass as u64) << 40); + // The bands crossing the target box: index, first row, rows in the box. + let mut bands = Vec::new(); + let mut y = tb.y0; + while y < tb.y1 { + let bi = y / BAND; + let end = ((bi + 1) * BAND).min(tb.y1); + bands.push((bi, y, end - y)); + y = end; + } + let threads = par::threads(); + if tb.area() >= PAR_MIN_PATCHES * 8 && !bands.is_empty() && bands.len() < threads { + self.patchmatch_wavefront(nnf, cost, &bands, reverse, &band_seed, search_radius); + return; + } + let band_fn = |bi: usize, ys: usize, nn: &mut [(i32, i32)], co: &mut [f32]| { if self.ctl.cancelled() { return; } - let mut rng = Rng::new(seed ^ (bi as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) ^ ((pass as u64) << 40)); - let y0 = bi * BAND; - let rows = nn.len() / w; - let step: i32 = if reverse { -1 } else { 1 }; + let rows = nn.len() / tw; + let mut rng = Rng::new(band_seed(bi)); for ry in 0..rows { + if self.ctl.cancelled() { + return; + } let ly = if reverse { rows - 1 - ry } else { ry }; - let y = y0 + ly; - for rx in 0..w { - let x = if reverse { w - 1 - rx } else { rx }; - let li = ly * w + x; - if !self.targets[y * w + x] { + let y = ys + ly; + for rx in 0..tw { + let lx = if reverse { tw - 1 - rx } else { rx }; + let li = ly * tw + lx; + let k = (y - tb.y0) * tw + lx; + if !self.targets[k] { continue; } - let t = (x as i32, y as i32); - let (mut best, mut bc) = (nn[li], co[li]); - // Propagation from the already-visited neighbours (within this band). - let nx = x as i32 - step; - if nx >= 0 && (nx as usize) < w { - let c = nn[ly * w + nx as usize]; - let cand = (c.0 + step, c.1); - if self.is_valid(cand.0, cand.1) && cand != best { - let d = self.dist(t, cand, bc); - if d < bc { - (best, bc) = (cand, d); - } - } - } + // Neighbours outside the target box hold no match: nothing to try. + let nx = lx as i32 - step; + let left = (nx >= 0 && (nx as usize) < tw).then(|| nn[ly * tw + nx as usize]); let nly = ly as i32 - step; - if nly >= 0 && (nly as usize) < rows { - let c = nn[nly as usize * w + x]; - let cand = (c.0, c.1 + step); - if self.is_valid(cand.0, cand.1) && cand != best { - let d = self.dist(t, cand, bc); - if d < bc { - (best, bc) = (cand, d); - } - } - } - // Random search in exponentially shrinking windows. - let mut rad = search_radius; - while rad >= 1 { - let cand = (best.0 + rng.range(-rad, rad), best.1 + rng.range(-rad, rad)); - if self.is_valid(cand.0, cand.1) && cand != best { - let d = self.dist(t, cand, bc); - if d < bc { - (best, bc) = (cand, d); - } - } - rad /= 2; - } - nn[li] = best; - co[li] = bc; + let up = (nly >= 0 && (nly as usize) < rows).then(|| nn[nly as usize * tw + lx]); + let t = ((tb.x0 + lx) as i32, y as i32); + (nn[li], co[li]) = self.pm_step(t, k, (nn[li], co[li]), left, up, step, &mut rng, search_radius); } } }; - #[cfg(not(target_arch = "wasm32"))] - if w * self.h >= PAR_MIN { - use rayon::prelude::*; - nnf.par_chunks_mut(BAND * w).zip(cost.par_chunks_mut(BAND * w)).enumerate().for_each(|(bi, (nn, co))| band_fn(bi, nn, co)); - return; + // The target box's rows, band by band. + let mut parts = Vec::with_capacity(bands.len()); + let (mut nn_rest, mut co_rest) = (&mut nnf[..tb.area()], &mut cost[..tb.area()]); + for &(bi, ys, rows) in &bands { + let (nn, nr) = std::mem::take(&mut nn_rest).split_at_mut(rows * tw); + let (co, cr) = std::mem::take(&mut co_rest).split_at_mut(rows * tw); + parts.push((bi, ys, nn, co)); + (nn_rest, co_rest) = (nr, cr); } - for (bi, (nn, co)) in nnf.chunks_mut(BAND * w).zip(cost.chunks_mut(BAND * w)).enumerate() { - band_fn(bi, nn, co); + let par = tb.area() >= PAR_MIN_PATCHES && parts.len() > 1; + par::each(parts, par, |(bi, ys, nn, co)| band_fn(bi, ys, nn, co)); + } + + /// [`Solver::patchmatch`] swept in wavefronts: the cells of each band in blocks of a row, a + /// block once the block before it in its row and the one above it (in the pass's direction) + /// are done. A target's random numbers are those it would draw in raster order (each target + /// draws the same count). + fn patchmatch_wavefront( + &self, + nnf: &mut [(i32, i32)], + cost: &mut [f32], + bands: &[(usize, usize, usize)], + reverse: bool, + band_seed: &(dyn Fn(usize) -> u64 + Sync), + search_radius: i32, + ) { + use std::sync::atomic::{AtomicU32, AtomicU64, Ordering::Relaxed}; + + let tb = self.tbox; + let tw = tb.width(); + let step: i32 = if reverse { -1 } else { 1 }; + let pack = |(x, y): (i32, i32)| (u64::from(x as u32) << 32) | u64::from(y as u32); + let unpack = |v: u64| ((v >> 32) as u32 as i32, v as u32 as i32); + // Random numbers each target draws. + let mut draws = 0u64; + let mut rad = search_radius; + while rad >= 1 { + draws += 2; + rad /= 2; + } + // Targets before each column of each row of the box. + let mut before = vec![0u32; tb.height() * (tw + 1)]; + for (ry, row) in before.chunks_mut(tw + 1).enumerate() { + for lx in 0..tw { + row[lx + 1] = row[lx] + u32::from(self.targets[ry * tw + lx]); + } + } + // Targets visited before each of a band's rows (in the pass's order). + let row_base: Vec> = bands + .iter() + .map(|&(_, ys, rows)| { + let mut base = Vec::with_capacity(rows); + let mut n = 0u64; + for pr in 0..rows { + base.push(n); + let ly = if reverse { rows - 1 - pr } else { pr }; + n += u64::from(before[(ys + ly - tb.y0) * (tw + 1) + tw]); + } + base + }) + .collect(); + let block = (tw / 8).clamp(4, 32); + let blocks = tw.div_ceil(block); + let nn: Vec = nnf[..tb.area()].iter().map(|m| AtomicU64::new(pack(*m))).collect(); + let co: Vec = cost[..tb.area()].iter().map(|c| AtomicU32::new(c.to_bits())).collect(); + let sweep = |(j, pr, b): (usize, usize, usize)| { + let (bi, ys, rows) = bands[j]; + let ly = if reverse { rows - 1 - pr } else { pr }; + let y = ys + ly; + let ry = y - tb.y0; + let (pc0, pc1) = (b * block, ((b + 1) * block).min(tw)); + // The targets visited before this block, in raster order. + let first = if reverse { tw - 1 - pc0 } else { pc0 }; + let row = &before[ry * (tw + 1)..(ry + 1) * (tw + 1)]; + let in_row = if reverse { row[tw] - row[first + 1] } else { row[first] }; + let mut rng = Rng::new(band_seed(bi)); + rng.skip((row_base[j][pr] + u64::from(in_row)) * draws); + for pc in pc0..pc1 { + let lx = if reverse { tw - 1 - pc } else { pc }; + let k = ry * tw + lx; + if !self.targets[k] { + continue; + } + let nx = lx as i32 - step; + let left = (nx >= 0 && (nx as usize) < tw).then(|| unpack(nn[ry * tw + nx as usize].load(Relaxed))); + let nly = ly as i32 - step; + let up = (nly >= 0 && (nly as usize) < rows).then(|| unpack(nn[(ys + nly as usize - tb.y0) * tw + lx].load(Relaxed))); + let cur = (unpack(nn[k].load(Relaxed)), f32::from_bits(co[k].load(Relaxed))); + let t = ((tb.x0 + lx) as i32, y as i32); + let (m, c) = self.pm_step(t, k, cur, left, up, step, &mut rng, search_radius); + nn[k].store(pack(m), Relaxed); + co[k].store(c.to_bits(), Relaxed); + } + }; + // Wavefront `d`: block `b` of (visited) row `pr` with `pr + b = d`, in every band. Each + // wave ends before the next starts. + let longest = bands.iter().map(|b| b.2).max().unwrap_or(0); + for d in 0..longest + blocks - 1 { + if self.ctl.cancelled() { + return; + } + let wave: Vec<(usize, usize, usize)> = bands + .iter() + .enumerate() + .flat_map(|(j, &(_, _, rows))| (0..rows).filter(move |&pr| pr <= d && d - pr < blocks).map(move |pr| (j, pr, d - pr))) + .collect(); + par::each(wave, true, sweep); + } + for (m, a) in nnf.iter_mut().zip(&nn) { + *m = unpack(a.load(Relaxed)); + } + for (c, a) in cost.iter_mut().zip(&co) { + *c = f32::from_bits(a.load(Relaxed)); } } - /// Recompute every target's match cost (hole pixels change between iterations). - fn costs(&self, nnf: &[(i32, i32)], cost: &mut [f32]) -> Result<(), photocraft_raster::Cancelled> { - let w = self.w; - let row = |(y, co): (usize, &mut [f32])| { - if self.ctl.cancelled() { + /// Recompute every target's match cost (hole pixels change between iterations), and with + /// colour adaptation the full target patches' own moments. + fn costs(&mut self, nnf: &[(i32, i32)], cost: &mut [f32]) -> Result<(), photocraft_raster::Cancelled> { + let (w, ch, r, tb) = (self.w, self.ch, self.r as usize, self.tbox); + let par = tb.area() >= PAR_MIN_PATCHES; + self.ctl.check()?; + if !self.adapt.is_none() { + let c8 = ch.min(MOM_CH); + let side = 2 * r + 1; + let mut tmom = std::mem::take(&mut self.tmom); + let (img, full, ctl) = (&*self.img, &self.full, &self.ctl); + par::chunks_mut(&mut tmom, tb.width() * c8, par, |ty, out| { + if ctl.cancelled() { + return; + } + for tx in 0..tb.width() { + if full[ty * tb.width() + tx] { + patch_moments(img, w, ch, c8, side, tb.x0 + tx - r, tb.y0 + ty - r, &mut out[tx * c8..(tx + 1) * c8]); + } + } + }); + self.tmom = tmom; + } + let this = &*self; + par::chunks_mut(cost, tb.width(), par, |ty, co| { + if this.ctl.cancelled() { return; } - for (x, c) in co.iter_mut().enumerate() { - let i = y * w + x; - if self.targets[i] { - *c = self.dist((x as i32, y as i32), nnf[i], f32::INFINITY); + let y = tb.y0 + ty; + for (tx, c) in co.iter_mut().enumerate() { + let k = ty * tb.width() + tx; + if this.targets[k] { + *c = this.dist(((tb.x0 + tx) as i32, y as i32), k, nnf[k], f32::INFINITY); } } - }; - #[cfg(not(target_arch = "wasm32"))] - if w * self.h >= PAR_MIN { - use rayon::prelude::*; - cost.par_chunks_mut(w).enumerate().for_each(row); - return self.ctl.check(); - } - cost.chunks_mut(w).enumerate().for_each(row); + }); self.ctl.check() } @@ -489,8 +1019,15 @@ impl Solver<'_> { /// (each copy adjusted by its gain and bias). fn vote(&mut self, nnf: &[(i32, i32)], cost: &[f32]) { let (w, h, ch, r) = (self.w, self.h, self.ch, self.r); + let (tb, hb) = (self.tbox, self.hole_box); + // A cancelled solve is thrown away. + if self.ctl.cancelled() { + return; + } + let used = |k: usize| self.targets[k] && cost[k].is_finite(); // σ² from the 75th percentile of match costs (Wexler et al. use a robust scale like this). - let mut cs: Vec = cost.iter().zip(&self.targets).filter(|(c, t)| **t && c.is_finite()).map(|(c, _)| *c).collect(); + let cs: Vec = (0..tb.area()).filter(|&k| used(k)).map(|k| cost[k]).collect(); + let mut cs = cs; let sigma2 = if cs.is_empty() { 1.0 } else { @@ -498,249 +1035,456 @@ impl Solver<'_> { let (_, v, _) = cs.select_nth_unstable_by(k, |a, b| a.total_cmp(b)); v.max(1e-6) }; - // Each target's photometric transform (computed once, used by every pixel it covers). + drop(cs); + // Each target's weight and photometric transform (computed once, used by every pixel it + // covers). let adapted = !self.adapt.is_none(); - let gb: Vec<(f32, f32)> = if adapted { - let mut gb = vec![(1.0f32, 0.0f32); w * h * ch]; + let par = tb.area() >= PAR_MIN_PATCHES; + // A weight (zero for a target that doesn't vote) and its match's centre (as a pixel + // index) per target. + let mut wt = vec![0.0f32; tb.area()]; + let mut src = vec![0isize; tb.area()]; + par::chunks2_mut(&mut wt, tb.width(), &mut src, tb.width(), par, |ty, wrow, srow| { + for (tx, (o, sc)) in wrow.iter_mut().zip(srow.iter_mut()).enumerate() { + let k = ty * tb.width() + tx; + if used(k) { + *o = (-cost[k] / (2.0 * sigma2)).exp().max(1e-8); + *sc = nnf[k].1 as isize * w as isize + nnf[k].0 as isize; + } + } + }); + let mut gb: Vec<(f32, f32)> = Vec::new(); + if adapted { + gb = vec![(1.0f32, 0.0f32); tb.area() * ch]; let this = &*self; - let row = |(y, out): (usize, &mut [(f32, f32)])| { - for x in 0..w { - let i = y * w + x; - if this.targets[i] && cost[i].is_finite() { - this.transform((x as i32, y as i32), nnf[i], &mut out[x * ch..(x + 1) * ch]); + par::chunks_mut(&mut gb, tb.width() * ch, par, |ty, out| { + if this.ctl.cancelled() { + return; + } + let y = tb.y0 + ty; + for tx in 0..tb.width() { + let k = ty * tb.width() + tx; + if used(k) { + this.transform(((tb.x0 + tx) as i32, y as i32), k, nnf[k], &mut out[tx * ch..(tx + 1) * ch]); } } - }; - #[cfg(not(target_arch = "wasm32"))] - if w * h >= PAR_MIN { - use rayon::prelude::*; - gb.par_chunks_mut(w * ch).enumerate().for_each(row); - } else { - gb.chunks_mut(w * ch).enumerate().for_each(row); + }); + } + // The vote, into a buffer over the hole's bounds (every pixel reads the old pixels). + let votes = Votes { w, h, ch, r: r as usize, tbox: tb, img: &*self.img, wt: &wt, src: &src, gb: &gb }; + let (px, ctl) = (self.px, &self.ctl); + let mut out = vec![0.0f32; hb.area() * ch]; + let mut got = vec![false; hb.area()]; + let row = |yy: usize, orow: &mut [f32], grow: &mut [bool]| { + if ctl.cancelled() { + return; } - #[cfg(target_arch = "wasm32")] - gb.chunks_mut(w * ch).enumerate().for_each(row); - gb - } else { - Vec::new() - }; - // Each target's vote weight, computed once rather than for every pixel it covers. - let weight: Vec = - cost.iter().zip(&self.targets).map(|(c, t)| if *t && c.is_finite() { (-*c / (2.0 * sigma2)).exp().max(1e-8) } else { 0.0 }).collect(); - let img = &*self.img; - let row = |y: usize| -> Vec<(usize, Vec)> { - let mut out = Vec::new(); - if self.ctl.cancelled() { - return out; - } - for x in 0..w { - if self.px[y * w + x] != Px::Hole { + let y = hb.y0 + yy; + for x in hb.x0..hb.x1 { + if px[y * w + x] != Px::Hole { continue; } - let mut acc = vec![0.0f32; ch]; - let mut wsum = 0.0f32; - for dy in -r..=r { - let ty = y as i32 + dy; - if ty < 0 || ty as usize >= h { - continue; - } - for dx in -r..=r { - let tx = x as i32 + dx; - if tx < 0 || tx as usize >= w { - continue; - } - let ti = ty as usize * w + tx as usize; - if !self.targets[ti] || !cost[ti].is_finite() { - continue; - } - let s = nnf[ti]; - let (sx, sy) = (s.0 - dx, s.1 - dy); - let wt = weight[ti]; - let si = (sy as usize * w + sx as usize) * ch; - for c in 0..ch { - let v = if adapted { - let (g, b) = gb[ti * ch + c]; - g * img[si + c] + b - } else { - img[si + c] - }; - acc[c] += v * wt; - } - wsum += wt; - } - } - if wsum > 0.0 { - acc.iter_mut().for_each(|v| *v /= wsum); - out.push((x, acc)); - } + let o = (x - hb.x0) * ch; + let dst = &mut orow[o..o + ch]; + grow[x - hb.x0] = match (ch, adapted) { + (1, false) => votes.pixel::<1, false>(x, y, dst), + (3, false) => votes.pixel::<3, false>(x, y, dst), + (4, false) => votes.pixel::<4, false>(x, y, dst), + (1, true) => votes.pixel::<1, true>(x, y, dst), + (3, true) => votes.pixel::<3, true>(x, y, dst), + (4, true) => votes.pixel::<4, true>(x, y, dst), + _ => votes.pixel_any(x, y, adapted, dst), + }; } - out }; - #[cfg(not(target_arch = "wasm32"))] - let rows: Vec)>> = if w * h >= PAR_MIN { - use rayon::prelude::*; - (0..h).into_par_iter().map(row).collect() - } else { - (0..h).map(row).collect() - }; - #[cfg(target_arch = "wasm32")] - let rows: Vec)>> = (0..h).map(row).collect(); - for (y, r) in rows.into_iter().enumerate() { - for (x, v) in r { - self.img[(y * w + x) * ch..(y * w + x + 1) * ch].copy_from_slice(&v); + par::chunks2_mut(&mut out, hb.width() * ch, &mut got, hb.width(), hb.area() >= PAR_MIN_PATCHES, row); + for yy in 0..hb.height() { + for xx in 0..hb.width() { + if got[yy * hb.width() + xx] { + let (d, s) = (((hb.y0 + yy) * w + hb.x0 + xx) * ch, (yy * hb.width() + xx) * ch); + self.img[d..d + ch].copy_from_slice(&out[s..s + ch]); + } } } } } -/// Fill the hole from its edge inwards, one ring at a time: each pixel takes the centre of the -/// source patch that best matches what is already known around it (the first such patch in -/// `sources` order on ties). -/// -/// The search is exhaustive, but most candidates are rejected early (#2771): the patch's known -/// offsets and pixels are gathered once per front pixel, the filled neighbours' sources (shifted -/// the same way, as in PatchMatch propagation; every 16th source where no neighbour is filled yet) -/// give an upper bound on the best error before the scan, and a candidate's sum of squares is -/// abandoned as soon as it can no longer win. The sum is accumulated in the same order as the -/// full one and only grows, so the chosen source, and the fill, are exactly those of the plain -/// scan. -fn onion_peel(w: usize, h: usize, ch: usize, r: i32, img: &mut [f32], px: &[Px], sources: &[(i32, i32)]) { - if ch == 0 { - return; +/// What [`Solver::vote`] votes with: per target (over the target box) its weight (zero when it +/// doesn't vote), its match's centre as a pixel index, and with colour adaptation its gain and +/// bias per channel. +struct Votes<'a> { + w: usize, + h: usize, + ch: usize, + r: usize, + tbox: Bounds, + img: &'a [f32], + wt: &'a [f32], + src: &'a [isize], + gb: &'a [(f32, f32)], +} + +impl Votes<'_> { + /// Hole pixel `(x, y)`'s vote into `out` (`CH` channels): the weighted mean of what the + /// matches of the patches covering it put there, summed patch by patch in raster order of + /// their centres. False when no patch votes. + #[inline(always)] + fn pixel(&self, x: usize, y: usize, out: &mut [f32]) -> bool { + let (w, r, tb) = (self.w, self.r, self.tbox); + let mut acc = [0.0f32; CH]; + let mut wsum = 0.0f32; + for ty in y.saturating_sub(r)..=(y + r).min(self.h - 1) { + let dy = ty as isize - y as isize; + let krow = (ty - tb.y0) * tb.width(); + for tx in x.saturating_sub(r)..=(x + r).min(w - 1) { + let k = krow + tx - tb.x0; + let wt = self.wt[k]; + if wt == 0.0 { + continue; + } + // The pixel of the match's patch that lands here. + let si = (self.src[k] - dy * w as isize - (tx as isize - x as isize)) as usize * CH; + let s = &self.img[si..si + CH]; + for c in 0..CH { + let v = if ADAPT { + let (g, b) = self.gb[k * CH + c]; + g * s[c] + b + } else { + s[c] + }; + acc[c] += v * wt; + } + wsum += wt; + } + } + if wsum > 0.0 { + for (d, a) in out.iter_mut().zip(&acc) { + *d = a / wsum; + } + return true; + } + false } - let mut known: Vec = px.iter().map(|k| matches!(k, Px::Known | Px::Source)).collect(); - // Which source each filled pixel copied (`NONE` until it has been filled), and each valid - // source's index by position, to seed a pixel's search from its filled neighbours. - const NONE: u32 = u32::MAX; - let mut picked = vec![NONE; w * h]; - let mut source_at = vec![NONE; w * h]; - for (k, &(sx, sy)) in sources.iter().enumerate() { - if let Some(slot) = source_at.get_mut(sy as usize * w + sx as usize) { - *slot = u32::try_from(k).unwrap_or(NONE); + + /// [`Votes::pixel`] for any number of channels. + fn pixel_any(&self, x: usize, y: usize, adapted: bool, out: &mut [f32]) -> bool { + let (w, r, tb, ch) = (self.w, self.r, self.tbox, self.ch); + let mut acc = vec![0.0f32; ch]; + let mut wsum = 0.0f32; + for ty in y.saturating_sub(r)..=(y + r).min(self.h - 1) { + let dy = ty as isize - y as isize; + let krow = (ty - tb.y0) * tb.width(); + for tx in x.saturating_sub(r)..=(x + r).min(w - 1) { + let k = krow + tx - tb.x0; + let wt = self.wt[k]; + if wt == 0.0 { + continue; + } + let si = (self.src[k] - dy * w as isize - (tx as isize - x as isize)) as usize * ch; + for (c, a) in acc.iter_mut().enumerate() { + let v = if adapted { + let (g, b) = self.gb[k * ch + c]; + g * self.img[si + c] + b + } else { + self.img[si + c] + }; + *a += v * wt; + } + wsum += wt; + } + } + if wsum > 0.0 { + for (d, a) in out.iter_mut().zip(&acc) { + *d = a / wsum; + } + return true; + } + false + } +} + +/// `Σv, Σv²` per channel (the first `out.len()`) of the `side × side` patch with top-left +/// `(x0, y0)`, summed row by row, pixel by pixel, as [`Solver::moments`] does. +#[allow(clippy::too_many_arguments)] +fn patch_moments(img: &[f32], w: usize, ch: usize, c8: usize, side: usize, x0: usize, y0: usize, out: &mut [[f32; 2]]) { + out.iter_mut().for_each(|v| *v = [0.0; 2]); + for y in y0..y0 + side { + let row = &img[(y * w + x0) * ch..(y * w + x0 + side) * ch]; + for p in row.chunks_exact(ch) { + for (c, o) in out.iter_mut().enumerate().take(c8) { + let a = p[c]; + o[0] += a; + o[1] += a * a; + } } } - let side = (2 * r + 1) as usize; +} + +/// The adapted distance from the moments `[Σt, Σt², Σs, Σs², Σts]` of each channel over `n` +/// samples: what is left of the target after the source is fitted to it, per sample. +#[inline] +fn adapted_cost(a: Adapt, nf: f32, m: &[[f32; 5]]) -> f32 { + let mut sum = 0.0f32; + for mc in m { + let (g, b) = fit(a, nf, mc[0], mc[1], mc[2], mc[3]); + // Σ (t − g s − b)², expanded. + sum += mc[1] + g * g * mc[3] + nf * b * b - 2.0 * g * mc[4] - 2.0 * b * mc[0] + 2.0 * g * b * mc[2]; + } + sum.max(0.0) / (nf * m.len() as f32) +} + +/// `Σ (t − s)²` over a target patch's known pixels (offsets `offs` from its centre, samples +/// `tv`, `CH` per pixel) against the source patch centred at pixel `base`, summed in that order; +/// `None` once the sum reaches `limit` (it only grows). +#[inline(always)] +fn known_sse(image: &[f32], offs: &[isize], tv: &[f32], base: isize, limit: f32) -> Option { + let mut e = 0.0f32; + for (o, t) in offs.iter().zip(tv.as_chunks::().0) { + let si = (base + o) as usize * CH; + let s = &image[si..si + CH]; + for c in 0..CH { + let d = t[c] - s[c]; + e += d * d; + } + if e >= limit { + return None; + } + } + Some(e) +} + +/// [`known_sse`] of [`LANES`] sources at once, each against its own limit (summed in step: the +/// sums are independent, so the processor overlaps them). +#[inline(always)] +fn known_sse_lanes(image: &[f32], offs: &[isize], tv: &[f32], bases: [isize; LANES], limits: [f32; LANES]) -> [Option; LANES] { + let mut e = [0.0f32; LANES]; + for (o, t) in offs.iter().zip(tv.as_chunks::().0) { + for l in 0..LANES { + let si = (bases[l] + o) as usize * CH; + let s = &image[si..si + CH]; + for c in 0..CH { + let d = t[c] - s[c]; + e[l] += d * d; + } + } + // Sums only grow: once all of them reached their limits, none can win. + if (0..LANES).all(|l| e[l] >= limits[l]) { + break; + } + } + std::array::from_fn(|l| (e[l] < limits[l]).then_some(e[l])) +} + +/// Sources [`known_sse_lanes`] sums at once. +const LANES: usize = 4; + +/// [`known_sse`] for any number of channels. +fn known_sse_any(image: &[f32], ch: usize, offs: &[isize], tv: &[f32], base: isize, limit: f32) -> Option { + let mut e = 0.0f32; + for (o, t) in offs.iter().zip(tv.chunks_exact(ch.max(1))) { + let si = (base + o) as usize * ch; + for (tc, sc) in t.iter().zip(&image[si..si + ch]) { + let d = tc - sc; + e += d * d; + } + if e >= limit { + return None; + } + } + Some(e) +} + +/// The best source so far of [`onion_peel`]'s search. +#[derive(Clone, Copy)] +struct Best { + /// Error per sample, and the place in the sources. + v: f32, + j: usize, + /// Sums of squared errors from which `sum / n` is above `v`, and at least `v`. + worse: f32, + equal: f32, +} + +/// The least sum `e ≥ 0` with `e / n > v` (`strict`) or `e / n ≥ v` in float arithmetic (the +/// quotient only grows with `e`); infinity for an infinite `v`. +fn least_sum(v: f32, n: f32, strict: bool) -> f32 { + let holds = |e: f32| if strict { e / n > v } else { e / n >= v }; + if !v.is_finite() || !n.is_finite() || n <= 0.0 { + return f32::INFINITY; + } + let mut e = (v * n).max(0.0); + while e > 0.0 && holds(e.next_down()) { + e = e.next_down(); + } + while !holds(e) && e < f32::INFINITY { + e = e.next_up(); + } + e +} + +/// Fill the hole from its edge inwards, one ring at a time: each pixel takes the centre of the +/// source patch that best matches what is already known around it (the first in `sources` of +/// those that match best). `hb` bounds the hole. +/// +/// The search is exhaustive, but most sources are given up early (#2771, #2805): the target's +/// known pixels are gathered once, the search starts from where the pixel's filled neighbours +/// took their sources (or every 16th source at the hole's edge), and a source's error stops being +/// added up once it can only lose (it only grows). So it finds the same source as a plain scan in +/// order, sooner. +#[allow(clippy::too_many_arguments)] +fn onion_peel( + w: usize, + h: usize, + ch: usize, + r: i32, + img: &mut [f32], + px: &[Px], + hb: Bounds, + sources: &[(i32, i32)], + ctl: &photocraft_raster::Interrupt, +) -> Result<(), photocraft_raster::Cancelled> { + const NONE: u32 = u32::MAX; + let mut known: Vec = px.iter().map(|k| matches!(k, Px::Known | Px::Source)).collect(); + // Each source centre's place in `sources`, and the source each filled pixel took. + let mut index = vec![NONE; w * h]; + for (j, &(x, y)) in sources.iter().enumerate() { + index[y as usize * w + x as usize] = j as u32; + } + let mut took = vec![NONE; w * h]; + let neighbours = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]; loop { - let front: Vec = (0..w * h) - .filter(|&i| { + ctl.check()?; + let mut front: Vec = Vec::new(); + for y in hb.y0..hb.y1 { + for x in hb.x0..hb.x1 { + let i = y * w + x; if known[i] || px[i] != Px::Hole { - return false; + continue; } - let (x, y) = ((i % w) as i32, (i / w) as i32); - (-1..=1).any(|dy: i32| { + let (x, y) = (x as i32, y as i32); + let edge = (-1..=1).any(|dy: i32| { (-1..=1).any(|dx: i32| { let (nx, ny) = (x + dx, y + dy); nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && known[ny as usize * w + nx as usize] }) - }) - }) - .collect(); + }); + if edge { + front.push(i); + } + } + } if front.is_empty() { break; } - let (img_ro, known_ro, picked_ro) = (&*img, &known, &picked); - // The index of the best source for front pixel `i`, or `None` when no pixel around it is - // known (then pixel 0 is copied, as the plain scan did). - let best = |&i: &usize| -> Option { + let (known_now, took_now) = (&known, &took); + let image = &*img; + let best = |&i: &usize| -> (Vec, u32) { let (x, y) = ((i % w) as i32, (i / w) as i32); - // The known pixels of the target patch: their offset (in pixels) and their samples, in - // the plain scan's order. + // The known pixels of the target patch, in the plain scan's order: each one's offset + // from the patch centre (in pixels) and its samples (gathered once, #2805). + let side = (2 * r + 1) as usize; let mut offs: Vec = Vec::with_capacity(side * side); let mut tv: Vec = Vec::with_capacity(side * side * ch); for dy in -r..=r { for dx in -r..=r { let (tx, ty) = (x + dx, y + dy); - if tx < 0 || ty < 0 || tx as usize >= w || ty as usize >= h || !known_ro[ty as usize * w + tx as usize] { - continue; + if tx >= 0 && ty >= 0 && (tx as usize) < w && (ty as usize) < h && known_now[ty as usize * w + tx as usize] { + let ti = (ty as usize * w + tx as usize) * ch; + offs.push(dy as isize * w as isize + dx as isize); + tv.extend_from_slice(&image[ti..ti + ch]); } - let ti = (ty as usize * w + tx as usize) * ch; - offs.push(dy as isize * w as isize + dx as isize); - tv.extend_from_slice(&img_ro[ti..ti + ch]); } } - if offs.is_empty() { - return None; - } - let n = offs.len() as f32; - // Mean square difference against the source patch centred at `base`, abandoned - // (`None`) as soon as the mean so far exceeds `bound` or reaches `best` (the final - // mean can only be larger). - let ssd = |base: isize, bound: f32, best: f32| -> Option { - let mut e = 0.0f32; - for (j, (o, t)) in offs.iter().zip(tv.chunks_exact(ch)).enumerate() { - let si = (base + o) as usize * ch; - for (tc, sc) in t.iter().zip(&img_ro[si..si + ch]) { - let d = tc - sc; - e += d * d; + // The best so far: its error per sample, its place in `sources` (the first of equals + // wins, as in a scan in order), and the sums of squares at which a source surely loses + // to it (`e / n` above the best) or ties it (at least the best). + let mut best = Best { v: f32::INFINITY, j: usize::MAX, worse: f32::INFINITY, equal: f32::INFINITY }; + if !offs.is_empty() { + let nf = offs.len() as f32; + let base_of = |j: usize| sources.get(j).map_or(0, |&(sx, sy)| sy as isize * w as isize + sx as isize); + // The sum at which source `j` can't win: a tie loses to an earlier source. + let limit_of = |j: usize, best: &Best| if j > best.j { best.equal } else { best.worse }; + // Source `j`'s whole sum `e` (it did not reach its limit): does it win? + let adopt = |j: usize, e: f32, best: &mut Best| { + let v = e / nf; + if v < f32::INFINITY && (v < best.v || (v == best.v && j < best.j)) { + *best = Best { v, j, worse: least_sum(v, nf, true), equal: least_sum(v, nf, false) }; } - if j % 4 == 3 { - let m = e / n; - if m > bound || m >= best { - return None; + }; + let consider = |j: usize, best: &mut Best| { + if j >= sources.len() { + return; + } + let (base, limit) = (base_of(j), limit_of(j, best)); + let e = match ch { + 1 => known_sse::<1>(image, &offs, &tv, base, limit), + 3 => known_sse::<3>(image, &offs, &tv, base, limit), + 4 => known_sse::<4>(image, &offs, &tv, base, limit), + _ => known_sse_any(image, ch, &offs, &tv, base, limit), + }; + if let Some(e) = e { + adopt(j, e, best); + } + }; + // A close bound early: where the filled neighbours' sources point (shifted as in + // PatchMatch's propagation), or at the hole's edge, where none is filled yet, + // every 16th source. + for (dx, dy) in neighbours { + let (qx, qy) = (x + dx, y + dy); + if qx < 0 || qy < 0 || qx as usize >= w || qy as usize >= h { + continue; + } + let Some(&(sx, sy)) = sources.get(took_now[qy as usize * w + qx as usize] as usize) else { continue }; + let (cx, cy) = (sx - dx, sy - dy); + if cx >= 0 && cy >= 0 && (cx as usize) < w && (cy as usize) < h && index[cy as usize * w + cx as usize] != NONE { + consider(index[cy as usize * w + cx as usize] as usize, &mut best); + } + } + if best.j == usize::MAX { + for j in (0..sources.len()).step_by(16) { + consider(j, &mut best); + } + } + // The scan in order, `LANES` sources at a time (their sums are independent, so they + // overlap), each against its limit from the best before them: the best only gets + // better, so a source given up would have lost to it anyway. + let mut j = 0; + while j + LANES <= sources.len() { + if j.is_multiple_of(4096) && j > 0 && ctl.cancelled() { + break; + } + let bases: [isize; LANES] = std::array::from_fn(|l| base_of(j + l)); + let limits: [f32; LANES] = std::array::from_fn(|l| limit_of(j + l, &best)); + let sums = match ch { + 1 => known_sse_lanes::<1>(image, &offs, &tv, bases, limits), + 3 => known_sse_lanes::<3>(image, &offs, &tv, bases, limits), + 4 => known_sse_lanes::<4>(image, &offs, &tv, bases, limits), + _ => std::array::from_fn(|l| known_sse_any(image, ch, &offs, &tv, bases[l], limits[l])), + }; + for (l, e) in sums.into_iter().enumerate() { + if let Some(e) = e { + adopt(j + l, e, &mut best); } } + j += LANES; } - Some(e / n) - }; - // An upper bound on the best mean (any candidate's mean is one): the best of the filled - // neighbours' sources, each shifted with its neighbour, or, at the hole's edge where no - // neighbour is filled yet, of every 16th source. - let mut bound = f32::INFINITY; - for dy in -1..=1i32 { - for dx in -1..=1i32 { - let (nx, ny) = (x + dx, y + dy); - if nx < 0 || ny < 0 || nx as usize >= w || ny as usize >= h { - continue; - } - let Some(&(sx, sy)) = sources.get(picked_ro[ny as usize * w + nx as usize] as usize) else { continue }; - let (cx, cy) = (sx - dx, sy - dy); - if cx < 0 || cy < 0 || cx as usize >= w || cy as usize >= h || source_at[cy as usize * w + cx as usize] == NONE { - continue; - } - if let Some(e) = ssd(cy as isize * w as isize + cx as isize, f32::INFINITY, bound) { - bound = e; - } + for j in j..sources.len() { + consider(j, &mut best); } } - if bound == f32::INFINITY { - for &(sx, sy) in sources.iter().step_by(16) { - if let Some(e) = ssd(sy as isize * w as isize + sx as isize, f32::INFINITY, bound) { - bound = e; - } - } - } - // The plain scan, in order, keeping the first strictly smaller mean: a candidate is - // abandoned once its mean exceeds the bound (the best is at most the bound) or reaches - // the best so far (a tie would not replace it). - let mut best = (f32::INFINITY, None); - for (k, &(sx, sy)) in sources.iter().enumerate() { - if let Some(e) = ssd(sy as isize * w as isize + sx as isize, bound, best.0) - && e < best.0 - { - best = (e, Some(k)); - } - } - best.1 + let (sx, sy) = sources.get(best.j).copied().unwrap_or((0, 0)); + let si = (sy as usize * w + sx as usize) * ch; + (image[si..si + ch].to_vec(), if best.j == usize::MAX { NONE } else { best.j as u32 }) }; - #[cfg(not(target_arch = "wasm32"))] - let found: Vec> = { - use rayon::prelude::*; - front.par_iter().map(best).collect() - }; - #[cfg(target_arch = "wasm32")] - let found: Vec> = front.iter().map(best).collect(); - // Pixel 0 as it was before this ring (what a pixel with nothing known around it copies). - let first = img[..ch].to_vec(); - for (&i, k) in front.iter().zip(found) { - match k.and_then(|k| sources.get(k)) { - // Sources are never hole pixels, so none of them changes during the ring. - Some(&(sx, sy)) => img.copy_within((sy as usize * w + sx as usize) * ch..(sy as usize * w + sx as usize + 1) * ch, i * ch), - None => img[i * ch..(i + 1) * ch].copy_from_slice(&first), - } + let vals: Vec<(Vec, u32)> = par::map(front.len(), front.len() * sources.len() >= 4096, |k| best(&front[k])); + ctl.check()?; + for (&i, (v, j)) in front.iter().zip(vals) { + img[i * ch..(i + 1) * ch].copy_from_slice(&v); known[i] = true; - picked[i] = k.and_then(|k| u32::try_from(k).ok()).unwrap_or(NONE); + took[i] = j; } } + Ok(()) } /// Content-aware completion of `hole` (see module docs), copying from any pixel outside it. Returns @@ -778,17 +1522,47 @@ pub fn complete_masked( p: &CompleteParams, ctl: &photocraft_raster::Interrupt, ) -> Result>, photocraft_raster::Cancelled> { + Ok(complete_cow(w, h, ch, Cow::Borrowed(img), px, p, ctl)?.ok()) +} + +/// [`complete_masked`] that fills `img` itself instead of a copy of it (only the hole changes): +/// `Err(img)`, untouched, when there is nothing to copy from. +pub fn complete_masked_owned( + w: usize, + h: usize, + ch: usize, + img: Vec, + px: &[Px], + p: &CompleteParams, + ctl: &photocraft_raster::Interrupt, +) -> Result, Vec>, photocraft_raster::Cancelled> { + Ok(complete_cow(w, h, ch, Cow::Owned(img), px, p, ctl)?.map_err(Cow::into_owned)) +} + +/// The completion, on the caller's pixels or a copy of them: `Err` gives them back when there is +/// nothing to copy from. +fn complete_cow<'a>( + w: usize, + h: usize, + ch: usize, + img: Cow<'a, [f32]>, + px: &'a [Px], + p: &CompleteParams, + ctl: &photocraft_raster::Interrupt, +) -> Result, Cow<'a, [f32]>>, photocraft_raster::Cancelled> { assert_eq!(img.len(), w * h * ch); assert_eq!(px.len(), w * h); - let hole: Vec = px.iter().map(|k| *k == Px::Hole).collect(); - let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, &hole) else { return Ok(Some(img.to_vec())) }; - drop(hole); + let Some(hb0) = hole_bounds(w, h, px) else { return Ok(Ok(img.into_owned())) }; + if ch == 0 { + return Ok(Ok(Vec::new())); + } let r = p.patch_radius.max(1); let psz = 2 * r + 1; // Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the // structure where the texture has averaged out, and fills flat). - let mut levels = vec![Level { w, h, img: img.to_vec(), px: px.to_vec() }]; - let mut ext = (bx1 - bx0).max(by1 - by0); + let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }]; + let mut boxes = vec![hb0]; + let mut ext = hb0.width().max(hb0.height()); let mut exts = vec![ext]; while let Some(l) = levels.last() { ctl.check()?; @@ -797,95 +1571,116 @@ pub fn complete_masked( } let d = downsample(l, ch); levels.push(d); + boxes.push(boxes[boxes.len() - 1].half()); ext = ext.div_ceil(2); exts.push(ext); } let nlev = levels.len(); - // The coarser level's result: its size, pixels and match field (empty when it had nothing to - // copy). + // The coarser level's result. let mut prev: Option = None; let total_px: usize = levels.iter().map(|l| l.w * l.h).sum(); let mut done_px = 0usize; let ri = r as i32; for li in (0..nlev).rev() { ctl.check()?; - let Level { w: lw, h: lh, img: mut limg, px: lpx } = std::mem::replace(&mut levels[li], Level { w: 0, h: 0, img: Vec::new(), px: Vec::new() }); - let lhole: Vec = lpx.iter().map(|k| *k == Px::Hole).collect(); - let source: Vec = lpx.iter().map(|k| *k == Px::Source).collect(); - let hs = integral(lw, lh, &lhole); - let ss = integral(lw, lh, &source); - let full = (psz * psz) as u32; - let mut valid = vec![false; lw * lh]; - let mut valid_list = Vec::new(); - let mut targets = vec![false; lw * lh]; - for y in 0..lh as i32 { - if y % 32 == 0 { - ctl.check()?; - } - for x in 0..lw as i32 { - let i = y as usize * lw + x as usize; - let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32; - if inside && window_count(&ss, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1) == full { - valid[i] = true; - valid_list.push((x, y)); - } - targets[i] = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1) > 0; - } + let Level { w: lw, h: lh, img: limg, px: lpx } = std::mem::replace(&mut levels[li], Level::EMPTY); + let hb = boxes[li]; + let tb = hb.grow(r, lw, lh); + let valid = valid_sources(lw, lh, r, &lpx, ctl).ok_or(photocraft_raster::Cancelled)?; + let sources = Sources::new(&valid, lw); + // Nothing to copy from at the finest level: the caller falls back (and gets its pixels + // back as they were). + if li == 0 && sources.len() == 0 { + return Ok(Err(limg)); } + // The level's own pixels to fill (at level 0 the caller's, or a copy made in parallel). + let mut limg = match limg { + Cow::Owned(v) => v, + Cow::Borrowed(b) => { + const CHUNK: usize = 1 << 16; + let mut v = vec![0.0f32; b.len()]; + par::chunks_mut(&mut v, CHUNK, true, |i, d| d.copy_from_slice(&b[i * CHUNK..i * CHUNK + d.len()])); + v + } + }; + let targets = targets_around(lw, r, &lpx, hb, tb); + ctl.check()?; // Initialise the hole: at the coarsest level peeled from its edge inwards (or a membrane // where nothing can be copied), else the coarser level's pixels, refined below by a vote of // its scaled-up matches. - match &prev { - Some((pw, ph, pimg, _)) => { - for y in 0..lh { - for x in 0..lw { - if lhole[y * lw + x] { + let coarser = prev.take(); + match &coarser { + Some(c) => { + let (pw, ph) = (c.w, c.h); + for y in hb.y0..hb.y1 { + for x in hb.x0..hb.x1 { + if lpx[y * lw + x] == Px::Hole { let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1)); let (d, s) = ((y * lw + x) * ch, (sy * pw + sx) * ch); - limg[d..d + ch].copy_from_slice(&pimg[s..s + ch]); + limg[d..d + ch].copy_from_slice(&c.img[s..s + ch]); } } } } - None if !valid_list.is_empty() => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, &valid_list), + None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?, None => { let unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); let m = membrane_fill(lw, lh, ch, &limg, &unknown); - for (i, hl) in lhole.iter().enumerate() { - if *hl { + for (i, k) in lpx.iter().enumerate() { + if *k == Px::Hole { limg[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]); } } } } - if valid_list.is_empty() { - if li == 0 { - return Ok(None); - } - prev = Some((lw, lh, limg, Vec::new())); + if sources.len() == 0 { + prev = Some(Coarser { w: lw, h: lh, img: limg, field: Vec::new(), tbox: tb }); continue; } - let no_void = !lpx.contains(&Px::Void); - let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, px: &lpx, valid, valid_list, targets, no_void, adapt: p.adapt, ctl: *ctl }; + let refine_only = li + 1 < nlev && exts[li] > p.refine_extent; + let n_targets = targets.iter().filter(|t| **t).count(); + // Source moments are worth keeping when there are few sources for the matches tried. + let keep_source_moments = lw * lh <= SOURCE_MOMENTS_MAX && sources.len() <= n_targets.saturating_mul(if refine_only { 4 } else { 64 }); + let mut solver = Solver { + w: lw, + h: lh, + ch, + r: ri, + img: &mut limg, + px: &lpx, + valid, + targets, + hole_box: hb, + tbox: tb, + full: Vec::new(), + tmom: Vec::new(), + smom: Vec::new(), + adapt: p.adapt, + ctl: *ctl, + }; + solver.prepare(keep_source_moments); + ctl.check()?; // NNF init: the coarser level's matches scaled up where they land on a valid source, // random otherwise. let mut rng = Rng::new(p.seed ^ (li as u64) << 20); - let mut nnf = vec![(0i32, 0i32); lw * lh]; + let tw = tb.width(); + let mut nnf = vec![(0i32, 0i32); tb.area()]; let mut upsampled = false; - for y in 0..lh { + for y in tb.y0..tb.y1 { if y % 32 == 0 { ctl.check()?; } - for x in 0..lw { - if !solver.targets[y * lw + x] { + for x in tb.x0..tb.x1 { + let k = (y - tb.y0) * tw + (x - tb.x0); + if !solver.targets[k] { continue; } let mut cand = None; - if let Some((pw, ph, _, old)) = &prev - && !old.is_empty() + if let Some(c) = &coarser + && !c.field.is_empty() { - let (cx, cy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1)); - let (sx, sy) = old[cy * pw + cx]; + let (cx, cy) = ((x / 2).min(c.w - 1), (y / 2).min(c.h - 1)); + let (sx, sy) = c.field_at(cx, cy); if sx != i32::MIN { // The same displacement, twice as long. let c = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32)); @@ -895,16 +1690,17 @@ pub fn complete_masked( } } upsampled |= cand.is_some(); - nnf[y * lw + x] = cand.unwrap_or_else(|| solver.valid_list[(rng.next() % solver.valid_list.len() as u64) as usize]); + nnf[k] = cand.unwrap_or_else(|| sources.nth((rng.next() % sources.len() as u64) as usize, &solver.valid, lw)); } } - let mut cost = vec![f32::INFINITY; lw * lh]; + // The coarser level has given all it had. + drop(coarser); + let mut cost = vec![f32::INFINITY; tb.area()]; // A finer level starts from the coarse matches voted with its own (sharp) pixels. if upsampled { solver.costs(&nnf, &mut cost)?; solver.vote(&nnf, &cost); } - let refine_only = li + 1 < nlev && exts[li] > p.refine_extent; let em = if li + 1 == nlev { p.em_iters } else if refine_only { @@ -934,14 +1730,17 @@ pub fn complete_masked( solver.vote(&nnf, &cost); ctl.check()?; } - // Keep the match field (unmatched cells marked) for the next level. - let field: Vec<(i32, i32)> = nnf.iter().zip(&solver.targets).map(|(n, t)| if *t { *n } else { (i32::MIN, i32::MIN) }).collect(); + // A vote that saw the cancellation stopped part-way: never hand its pixels on. + ctl.check()?; + // Keep the match field (unmatched cells marked) for the next level (none after the last). + let field: Vec<(i32, i32)> = + if li == 0 { Vec::new() } else { nnf.iter().zip(&solver.targets).map(|(n, t)| if *t { *n } else { (i32::MIN, i32::MIN) }).collect() }; // Finer levels cost ~4× the previous one: weight progress by pixel count. done_px += lw * lh; ctl.progress(done_px as f32 / total_px.max(1) as f32); - prev = Some((lw, lh, limg, field)); + prev = Some(Coarser { w: lw, h: lh, img: limg, field, tbox: tb }); } - Ok(prev.map(|(_, _, img, _)| img)) + Ok(prev.map(|c| c.img).ok_or(Cow::Owned(Vec::new()))) } /// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the @@ -1248,7 +2047,64 @@ mod tests { assert_eq!(best_offset(w, h, ch, &img, &hole, 2, 0), None); } - /// The exhaustive onion peel, as it was before the early-exit search (#2771). + /// Structure, fine noise and flat patches (so that some matches tie exactly), `ch` channels. + fn busy(w: usize, h: usize, ch: usize, seed: u64) -> Vec { + let mut rng = Rng::new(seed); + (0..w * h) + .flat_map(|i| { + let (x, y) = (i % w, i / w); + let flat = (x / 9 + y / 7) % 5 == 0; + let n = (rng.next() % 1000) as f32 / 1000.0; + (0..ch) + .map(move |c| if flat { 0.25 + 0.1 * c as f32 } else { (0.2 + 0.004 * x as f32 + 0.1 * ((x + 2 * y + c) % 6) as f32 + 0.05 * n).min(1.0) }) + }) + .collect() + } + + fn bits(v: &[f32]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() + } + + /// Roles: a hole, a band that is context only, a corner that is no image. + fn roles(w: usize, h: usize, hole: &[bool]) -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if hole[i] { + Px::Hole + } else if x < 4 && y < 6 { + Px::Void + } else if (w * 3 / 4..w * 3 / 4 + 3).contains(&x) { + Px::Known + } else { + Px::Source + } + }) + .collect() + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn results_do_not_depend_on_the_thread_count() { + // Big enough for the parallel passes, and for PatchMatch to sweep in wavefronts where + // there are fewer bands than threads (never with one thread). + let (w, h) = (300, 260); + for (ch, adapt) in [(3, Adapt::NONE), (4, Adapt { gain: (0.9, 1.1), bias: 0.04 })] { + let img = busy(w, h, ch, 3); + let hole = disc(w, h, 140.0, 130.0, 40.0); + let px = roles(w, h, &hole); + let p = CompleteParams { adapt, ..Default::default() }; + let run = |threads: usize| { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + bits(&pool.install(|| complete_masked(w, h, ch, &img, &px, &p, &photocraft_raster::Interrupt::NONE)).unwrap().unwrap()) + }; + let one = run(1); + assert_eq!(one, run(3), "{ch} channels, 3 threads"); + assert_eq!(one, run(16), "{ch} channels, 16 threads"); + } + } + + /// The onion peel as first written: every source in order, every known pixel summed. fn onion_peel_plain(w: usize, h: usize, ch: usize, r: i32, img: &mut [f32], px: &[Px], sources: &[(i32, i32)]) { let mut known: Vec = px.iter().map(|k| matches!(k, Px::Known | Px::Source)).collect(); loop { @@ -1266,7 +2122,7 @@ mod tests { }) .collect(); if front.is_empty() { - return; + break; } let vals: Vec> = front .iter() @@ -1304,10 +2160,11 @@ mod tests { } } + /// #2805's case (upstream's own speed-up of the onion peel, superseded here): repeating + /// texture with many exact ties, two holes; the result must be the plain scan's to the bit. #[test] fn onion_peel_picks_exactly_what_the_plain_scan_picks() { let (w, h, ch, r) = (48, 40, 3, 3); - // Noise over a gradient, with the left half repeating every 6 columns: many exact ties. let mut img = texture(w, h); for y in 0..h { for x in 6..w / 2 { @@ -1316,6 +2173,7 @@ mod tests { } } } + let none = photocraft_raster::Interrupt::NONE; for hole in [disc(w, h, 20.0, 18.0, 7.5), disc(w, h, 34.0, 22.0, 5.0)] { let px: Vec = hole.iter().map(|h| if *h { Px::Hole } else { Px::Source }).collect(); let near = integral(w, h, &hole); @@ -1324,13 +2182,178 @@ mod tests { .filter(|&(x, y)| window_count(&near, w, h, x - r, y - r, x + r + 1, y + r + 1) == 0) .collect(); let (mut fast, mut plain) = (img.clone(), img.clone()); - onion_peel(w, h, ch, r, &mut fast, &px, &sources); + onion_peel(w, h, ch, r, &mut fast, &px, hole_bounds(w, h, &px).unwrap(), &sources, &none).unwrap(); onion_peel_plain(w, h, ch, r, &mut plain, &px, &sources); assert!(fast.iter().zip(&plain).all(|(a, b)| a.to_bits() == b.to_bits())); assert_ne!(fast, img); } } + #[test] + fn onion_peel_takes_the_plain_scans_sources() { + let none = photocraft_raster::Interrupt::NONE; + for (w, h, ch, seed) in [(44, 36, 1, 1), (50, 40, 3, 2), (40, 40, 4, 3)] { + let mut img = busy(w, h, ch, seed); + // Also an image of a few exact values, where many sources tie. + if seed == 3 { + img.iter_mut().for_each(|v| *v = (*v * 3.0).round() / 3.0); + } + let hole = disc(w, h, w as f32 * 0.45, h as f32 * 0.5, 7.5); + let px = roles(w, h, &hole); + let valid = valid_sources(w, h, 3, &px, &none).unwrap(); + let sources = Sources::new(&valid, w).all(&valid, w); + let (mut a, mut b) = (img.clone(), img.clone()); + onion_peel(w, h, ch, 3, &mut a, &px, hole_bounds(w, h, &px).unwrap(), &sources, &none).unwrap(); + onion_peel_plain(w, h, ch, 3, &mut b, &px, &sources); + assert_eq!(bits(&a), bits(&b), "{w}×{h}×{ch}"); + } + } + + #[test] + fn least_sum_is_where_the_quotient_crosses() { + let mut rng = Rng::new(5); + for i in 0..4000 { + let n = (1 + rng.next() % 49) as f32; + let v = match i % 4 { + 0 => 0.0, + 1 => (rng.next() % 1000) as f32 * 1e-9, + _ => (rng.next() % 1_000_000) as f32 / 1_000_000.0, + }; + for strict in [true, false] { + let holds = |e: f32| if strict { e / n > v } else { e / n >= v }; + let e = least_sum(v, n, strict); + assert!(holds(e), "{v} {n} {strict}: {e}"); + assert!(e == 0.0 || !holds(e.next_down()), "{v} {n} {strict}: {e} is not the least"); + } + } + assert_eq!(least_sum(f32::INFINITY, 4.0, true), f32::INFINITY); + } + + #[test] + fn valid_sources_are_the_wholly_copyable_patches() { + let (w, h, r) = (37, 29, 2); + let mut rng = Rng::new(9); + let px: Vec = (0..w * h) + .map(|_| match rng.next() % 23 { + 0 => Px::Hole, + 1 => Px::Void, + 2 => Px::Known, + _ => Px::Source, + }) + .collect(); + let valid = valid_sources(w, h, r, &px, &photocraft_raster::Interrupt::NONE).unwrap(); + let source: Vec = px.iter().map(|k| *k == Px::Source).collect(); + let ss = integral(w, h, &source); + let (ri, full) = (r as i32, ((2 * r + 1) * (2 * r + 1)) as u32); + for y in 0..h as i32 { + for x in 0..w as i32 { + let inside = x >= ri && y >= ri && x + ri < w as i32 && y + ri < h as i32; + let want = inside && window_count(&ss, w, h, x - ri, y - ri, x + ri + 1, y + ri + 1) == full; + assert_eq!(valid[y as usize * w + x as usize], want, "({x}, {y})"); + } + } + // Counting per row and block finds the centres in order. + let sources = Sources::new(&valid, w); + let list = sources.all(&valid, w); + assert!(list.len() > 10 && list.len() == sources.len()); + for (k, p) in list.iter().enumerate() { + assert_eq!(sources.nth(k, &valid, w), *p); + } + // And on a grid wider than a block, with empty blocks and rows. + let (w, h) = (300, 9); + let wide: Vec = (0..w * h).map(|i| (i % w) % 7 == 3 && (i % w) / 64 != 2 && i / w != 4).collect(); + let sources = Sources::new(&wide, w); + let list = sources.all(&wide, w); + for (k, p) in list.iter().enumerate() { + assert_eq!(sources.nth(k, &wide, w), *p); + } + } + + #[test] + fn full_patches_measure_as_clipped_ones() { + let (w, h, ch, r) = (46, 38, 4, 3); + let mut img = busy(w, h, ch, 4); + let hole = disc(w, h, 20.0, 18.0, 6.0); + let px = roles(w, h, &hole); + let none = photocraft_raster::Interrupt::NONE; + let hb = hole_bounds(w, h, &px).unwrap(); + let tb = hb.grow(r, w, h); + let valid = valid_sources(w, h, r, &px, &none).unwrap(); + let picks: Vec<(i32, i32)> = Sources::new(&valid, w).all(&valid, w).iter().step_by(37).copied().collect(); + assert!(picks.len() > 5); + for adapt in [Adapt::NONE, Adapt { gain: (0.8, 1.25), bias: 0.08 }] { + for keep in [false, true] { + let targets = targets_around(w, r, &px, hb, tb); + let mut s = Solver { + w, + h, + ch, + r: r as i32, + img: &mut img, + px: &px, + valid: valid.clone(), + targets, + hole_box: hb, + tbox: tb, + full: Vec::new(), + tmom: Vec::new(), + smom: Vec::new(), + adapt, + ctl: none, + }; + s.prepare(keep); + let nnf = vec![picks[0]; tb.area()]; + let mut cost = vec![f32::INFINITY; tb.area()]; + s.costs(&nnf, &mut cost).unwrap(); + let mut checked = 0; + for k in 0..tb.area() { + if !s.targets[k] || !s.full[k] { + continue; + } + let t = ((tb.x0 + k % tb.width()) as i32, (tb.y0 + k / tb.width()) as i32); + for &src in &picks { + if adapt.is_none() { + let exact = s.dist_clipped(t, src, f32::INFINITY); + assert_eq!(s.dist_full(t, src, f32::INFINITY).to_bits(), exact.to_bits()); + for cutoff in [exact * 0.5, exact, exact * 2.0] { + let (a, b) = (s.dist_full(t, src, cutoff), s.dist_clipped(t, src, cutoff)); + assert_eq!(a < cutoff, b < cutoff); + } + } else { + let (mut m1, mut m2) = ([[0.0f32; 5]; 4], [[0.0f32; 5]; 4]); + s.moments_full(k, t, src, &mut m1); + assert_eq!(s.moments(t, src, &mut m2), (2 * r + 1) * (2 * r + 1)); + assert_eq!(m1.map(|c| c.map(f32::to_bits)), m2.map(|c| c.map(f32::to_bits))); + assert_eq!(s.dist_adapted_full(k, t, src).to_bits(), s.dist_adapted(t, src).to_bits()); + } + checked += 1; + } + } + assert!(checked > 100, "{checked}"); + } + } + } + + #[test] + fn owned_completion_fills_in_place_or_gives_the_pixels_back() { + let (w, h, ch) = (64, 52, 3); + let img = busy(w, h, ch, 6); + let hole = disc(w, h, 30.0, 26.0, 8.0); + let px = roles(w, h, &hole); + let (p, none) = (CompleteParams::default(), photocraft_raster::Interrupt::NONE); + let borrowed = complete_masked(w, h, ch, &img, &px, &p, &none).unwrap().unwrap(); + let owned = complete_masked_owned(w, h, ch, img.clone(), &px, &p, &none).unwrap().unwrap(); + assert_eq!(bits(&owned), bits(&borrowed)); + // Nothing may be copied: no completion, and the pixels come back as they were. + let known: Vec = px.iter().map(|k| if *k == Px::Source { Px::Known } else { *k }).collect(); + assert!(complete_masked(w, h, ch, &img, &known, &p, &none).unwrap().is_none()); + let back = complete_masked_owned(w, h, ch, img.clone(), &known, &p, &none).unwrap().unwrap_err(); + assert_eq!(bits(&back), bits(&img)); + // Cancelled, it stops. + let yes = || true; + assert!(complete_masked_owned(w, h, ch, img, &px, &p, &photocraft_raster::Interrupt::cancel_only(&yes)).is_err()); + } + #[test] fn synthesis_fills_hole_with_plausible_values() { let (w, h, ch) = (64, 64, 3); diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index c68d2e555..ded4ad31a 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -49,6 +49,7 @@ mod other; mod other2; pub mod paint; pub mod panorama; +mod par; pub mod perspective; mod photo_util; mod pixelate; diff --git a/crates/algo/src/par.rs b/crates/algo/src/par.rs new file mode 100644 index 000000000..90a848181 --- /dev/null +++ b/crates/algo/src/par.rs @@ -0,0 +1,77 @@ +//! Data-parallel helpers: rayon on native targets, sequential on wasm (where this crate has no +//! threads). Each gives the same result either way, and whatever `par` says. + +/// `f(index, chunk)` for each `len`-item chunk of `buf`, in parallel when `par`. +pub(crate) fn chunks_mut(buf: &mut [T], len: usize, par: bool, f: impl Fn(usize, &mut [T]) + Sync + Send) { + if len == 0 { + return; + } + #[cfg(not(target_arch = "wasm32"))] + if par { + use rayon::prelude::*; + buf.par_chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c)); + return; + } + let _ = par; + buf.chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c)); +} + +/// [`chunks_mut`] over two buffers in step: the `i`-th `la`-item chunk of `a` with the `i`-th +/// `lb`-item chunk of `b`. +pub(crate) fn chunks2_mut(a: &mut [A], la: usize, b: &mut [B], lb: usize, par: bool, f: impl Fn(usize, &mut [A], &mut [B]) + Sync + Send) { + if la == 0 || lb == 0 { + return; + } + #[cfg(not(target_arch = "wasm32"))] + if par { + use rayon::prelude::*; + a.par_chunks_mut(la).zip(b.par_chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb)); + return; + } + let _ = par; + a.chunks_mut(la).zip(b.chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb)); +} + +/// `[f(0), f(1), …, f(n - 1)]`, in parallel when `par`. +pub(crate) fn map(n: usize, par: bool, f: impl Fn(usize) -> T + Sync + Send) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if par { + use rayon::prelude::*; + return (0..n).into_par_iter().map(f).collect(); + } + let _ = par; + (0..n).map(f).collect() +} + +/// How many of `0..n` satisfy `f`. +pub(crate) fn count(n: usize, f: impl Fn(usize) -> bool + Sync + Send) -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + (0..n).into_par_iter().filter(|&i| f(i)).count() + } + #[cfg(target_arch = "wasm32")] + (0..n).filter(|&i| f(i)).count() +} + +/// `f(item)` for each of `items`, in parallel when `par`. +pub(crate) fn each(items: Vec, par: bool, f: impl Fn(T) + Sync + Send) { + #[cfg(not(target_arch = "wasm32"))] + if par { + use rayon::prelude::*; + items.into_par_iter().for_each(f); + return; + } + let _ = par; + items.into_iter().for_each(f); +} + +/// Threads the parallel helpers can use (1 on wasm). +pub(crate) fn threads() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + rayon::current_num_threads() + } + #[cfg(target_arch = "wasm32")] + 1 +} diff --git a/crates/algo/src/segment/maxflow.rs b/crates/algo/src/segment/maxflow.rs index 868347ae7..dcde4ebdf 100644 --- a/crates/algo/src/segment/maxflow.rs +++ b/crates/algo/src/segment/maxflow.rs @@ -23,6 +23,12 @@ const INF_DIST: u32 = u32::MAX; /// Residual capacities at or below this count as saturated (guards against float round-off /// leaving tiny positive residuals that would cause long chains of negligible augmentations). const EPS: f32 = 1e-5; +/// How many steps the max-flow loops take between asks whether to stop. +const CANCEL_EVERY: u32 = 1024; + +fn never() -> bool { + false +} /// A directed graph with source/sink terminal capacities. #[derive(Clone, Debug, Default)] @@ -110,24 +116,35 @@ impl Graph { /// Computes the maximum flow (= minimum cut capacity). pub fn maxflow(&mut self) -> f64 { + self.maxflow_with(&never).unwrap_or(self.flow) + } + + /// [`Graph::maxflow`] that stops when `cancel` returns true (asked every few thousand steps): + /// `None` then, and the cut is meaningless. Not cancelled, it does exactly what + /// [`Graph::maxflow`] does. + pub fn maxflow_with(&mut self, cancel: &dyn Fn() -> bool) -> Option { if self.node_count() >= 4096 { let mut source = false; let mut sink = false; for &capacity in &self.tr_cap { if !capacity.is_finite() { - return self.maxflow_bk(); + return self.bk(cancel); } source |= capacity > EPS; sink |= capacity < -EPS; } if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) { - return self.maxflow_push_relabel(); + return self.push_relabel(cancel); } } - self.maxflow_bk() + self.bk(cancel) } pub(super) fn maxflow_bk(&mut self) -> f64 { + self.bk(&never).unwrap_or(self.flow) + } + + fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option { let n = self.node_count(); let mut queue: VecDeque = VecDeque::new(); let mut orphans: VecDeque = VecDeque::new(); @@ -145,7 +162,12 @@ impl Graph { } } let mut time = 0u32; + let mut steps = 0u32; while let Some(i) = queue.pop_front() { + steps = steps.wrapping_add(1); + if steps.is_multiple_of(CANCEL_EVERY) && cancel() { + return None; + } let i = i as usize; self.active[i] = false; if self.parent[i] == NONE { @@ -197,7 +219,7 @@ impl Graph { self.adopt(o as usize, time, &mut queue, &mut orphans); } } - self.flow + Some(self.flow) } fn augment(&mut self, m: usize, orphans: &mut VecDeque) { diff --git a/crates/algo/src/segment/maxflow/push_relabel.rs b/crates/algo/src/segment/maxflow/push_relabel.rs index 8ae7632c3..914645d4c 100644 --- a/crates/algo/src/segment/maxflow/push_relabel.rs +++ b/crates/algo/src/segment/maxflow/push_relabel.rs @@ -5,14 +5,20 @@ //! The existing SoA residual graph is reused. Terminal source arcs are saturated //! into a preflow; `back` keeps their reverse capacity, `excess` uses f64 so local //! f32 capacity updates do not accumulate excess-rounding drift. -use super::{EPS, Graph, NONE, TERMINAL}; +use super::{CANCEL_EVERY, EPS, Graph, NONE, TERMINAL}; use std::collections::VecDeque; impl Graph { + #[cfg(test)] pub(super) fn maxflow_push_relabel(&mut self) -> f64 { + self.push_relabel(&super::never).unwrap_or(self.flow) + } + + /// Push/relabel; `None` when `cancel` stopped it. + pub(super) fn push_relabel(&mut self, cancel: &dyn Fn() -> bool) -> Option { let n = self.node_count(); let Some(source_height) = u32::try_from(n).ok().filter(|&n| n < u32::MAX / 2).map(|n| n + 2) else { - return self.maxflow_bk(); + return self.bk(cancel); }; let mut back = vec![0.0f32; n]; let mut excess = vec![0.0f64; n]; @@ -34,7 +40,12 @@ impl Graph { } let mut work = 0usize; let frequency = self.r_cap.len().saturating_mul(2).saturating_add(n); + let mut steps = 0u32; while let Some(i) = queue.pop_front() { + steps = steps.wrapping_add(1); + if steps.is_multiple_of(CANCEL_EVERY) && cancel() { + return None; + } self.active[i] = false; while excess[i] > f64::from(EPS) { if self.dist[i] == 1 && self.tr_cap[i] < -EPS { @@ -127,7 +138,7 @@ impl Graph { a = self.next[ai]; } } - self.flow + Some(self.flow) } fn global_relabel(&mut self, source_height: u32, back: &[f32]) { diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index f14a22d1e..dba4ebfce 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -242,14 +242,38 @@ pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[ grid_cut_weighted::(img, cost_fg, cost_bg, fixed, weight) } +/// [`grid_cut_with`] that stops when `cancel` returns true (asked every few thousand max-flow +/// steps): `None` then. Not cancelled, the same cut. +pub fn grid_cut_cancellable( + img: &RgbImage, + cost_fg: &[f32], + cost_bg: &[f32], + fixed: &[u8], + weight: impl Fn(f32) -> f32, + cancel: &dyn Fn() -> bool, +) -> Option> { + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, cancel) +} + mod edge_starts; // The const parameter also runs the previous full scan in before/after workflow tests. fn grid_cut_weighted(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec { + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default() +} + +fn grid_cut_impl( + img: &RgbImage, + cost_fg: &[f32], + cost_bg: &[f32], + fixed: &[u8], + weight: impl Fn(f32) -> f32, + cancel: &dyn Fn() -> bool, +) -> Option> { let (w, h) = (img.w as i32, img.h as i32); - let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Vec::new() }; + let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) }; if img.w > i32::MAX as usize || img.h > i32::MAX as usize || [img.px.len(), cost_fg.len(), cost_bg.len(), fixed.len()].iter().any(|&len| len < n) { - return Vec::new(); + return Some(Vec::new()); } let mut free = Vec::new(); let mut idx = vec![u32::MAX; n]; @@ -262,7 +286,7 @@ fn grid_cut_weighted(img: &RgbImage, cost_fg: &[f32], cos } let mut out: Vec = fixed.iter().map(|f| *f == HARD_FG).collect(); if count == 0 { - return out; + return Some(out); } let mut g = Graph::with_capacity(count as usize, count as usize * 4); for i in 0..n { @@ -307,7 +331,7 @@ fn grid_cut_weighted(img: &RgbImage, cost_fg: &[f32], cos } } if OPTIMIZED { - g.maxflow(); + g.maxflow_with(cancel)?; } else { g.maxflow_bk(); } @@ -316,7 +340,7 @@ fn grid_cut_weighted(img: &RgbImage, cost_fg: &[f32], cos out[i] = g.in_source(idx[i] as usize); } } - out + Some(out) } fn add_fixed_link(g: &mut Graph, node: usize, fixed: u8, wt: f32) { diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 68ce38348..c3a37dc6d 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -35,6 +35,35 @@ fn maxflow_clrs_network() { assert_eq!(g.residual_out_of_source_side(), 0.0); } +#[test] +fn maxflow_can_be_cancelled_and_otherwise_cuts_the_same() { + // A 120 × 100 grid with terminal links both ways (push/relabel) and a small one (BK). + for (w, h) in [(120usize, 100usize), (20, 15)] { + let mut g = Graph::with_capacity(w * h, w * h * 2); + let mut z = 7u64; + let mut next = || { + z = z.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (z >> 40) as f32 / (1u64 << 24) as f32 + }; + for i in 0..w * h { + g.add_tweights(i, next() * 2.0, next() * 2.0); + if i % w + 1 < w { + g.add_edge(i, i + 1, next(), next()); + } + if i + w < w * h { + g.add_edge(i, i + w, next(), next()); + } + } + let (mut plain, mut asked) = (g.clone(), g.clone()); + let flow = plain.maxflow(); + assert_eq!(asked.maxflow_with(&|| false), Some(flow)); + assert!((0..w * h).all(|i| plain.in_source(i) == asked.in_source(i))); + if w * h > 4096 { + assert_eq!(g.maxflow_with(&|| true), None); + } + } +} + /// Reference max flow (Edmonds–Karp on a dense matrix; node 0 = s, n−1 = t). fn edmonds_karp(cap: &mut [Vec]) -> f64 { let n = cap.len(); diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 26bf54279..e684f87b9 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -186,7 +186,7 @@ pub fn quad_size(q: &[[f64; 2]; 4]) -> (f64, f64) { /// Mapping and Image Warping", 1989, §2.2.3) scaled to the rectangle. `None` when `q` isn't a /// [`convex_quad`] or the size isn't positive. pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option { - if !convex_quad(q) || !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + if !(convex_quad(q) && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { return None; } Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse() diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 2a8c7a586..36d4a313d 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -394,17 +394,17 @@ mod tests { let out = warp_mesh_surface(&src, r, &|x, y| (x + 10.0, y - 2.0), Interp::Bilinear); assert_eq!(out.content_bounds(), r.translate(10, -2)); assert_eq!(out.pixel(20, 10), src.pixel(10, 12)); - // Arc 50 %: the centre column keeps its pixels, the ends drop. + // Arc 50 % (Photoshop): the bottom corners keep their pixels, the top rises and spreads. let b = [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]; let w = Warp::preset(WarpStyle::Arc, 50.0, b); let arc = warp_mesh_surface(&src, r, &|x, y| w.map(x, y), Interp::Bilinear); - let cx = (r.x0 + r.x1) / 2; - let (p, q) = (arc.pixel(cx, 10), src.pixel(cx, 10)); - assert!(p.iter().zip(&q).all(|(a, b)| (a - b).abs() < 0.05), "{p:?} vs {q:?}"); + let corner = w.map(f64::from(r.x0), f64::from(r.y1)); + assert!((corner.0 - f64::from(r.x0)).abs() < 1e-9 && (corner.1 - f64::from(r.y1)).abs() < 1e-9, "{corner:?}"); let (ex, ey) = w.map(f64::from(r.x0) + 2.5, f64::from(r.y0) + 2.5); let e = arc.pixel(ex.floor() as i32, ey.floor() as i32); assert!(e[3] > 0.2, "a point near the top-left corner lands at its mapped position: {e:?}"); - assert!(arc.content_bounds().y1 > r.y1 + 2, "ends drop below the original box"); + let c = arc.content_bounds(); + assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}"); } #[test] diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index afababe64..909e22198 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -841,8 +841,9 @@ impl PhotocraftMcp { self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j})), false).await } - #[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \ - results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")] + #[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \ + results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \ + Not an atomic transaction: earlier successful steps stay applied (no rollback).")] async fn command_batch(&self, Parameters(p): Parameters) -> Result { if p.steps.len() > MAX_BATCH_STEPS { return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len()))); diff --git a/crates/automation/src/server/transport.rs b/crates/automation/src/server/transport.rs index 7cececd59..44ba4596b 100644 --- a/crates/automation/src/server/transport.rs +++ b/crates/automation/src/server/transport.rs @@ -2,6 +2,38 @@ use super::*; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use crate::security::MAX_REQUEST_BYTES; + +/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()` +/// buffers without a limit, while forwarding partial input can make a valid JSON prefix +/// executable at EOF after an oversized suffix is rejected. +async fn bounded_line(source: &mut BufReader, maximum: usize) -> std::io::Result> { + let mut bytes = Vec::new(); + loop { + let available = source.fill_buf().await?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + } + let newline = available.iter().position(|&byte| byte == b'\n'); + let length = newline.map_or(available.len(), |at| at + 1); + if length > maximum.saturating_sub(bytes.len()) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("request exceeds {maximum} bytes"))); + } + bytes.extend_from_slice(&available[..length]); + source.consume(length); + if newline.is_some() { + bytes.pop(); + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + } + } +} + impl PhotocraftMcp { /// Serve a JSON-lines stream. A malformed line receives -32700/id:null; the next line /// is still handled. A single writer serializes parse errors and SDK responses. @@ -14,8 +46,8 @@ impl PhotocraftMcp { let (service_out, from_service) = tokio::io::duplex(1 << 20); let (errors, mut error_rx) = tokio::sync::mpsc::channel::(16); let reader = tokio::spawn(async move { - let mut lines = BufReader::new(input).lines(); - while let Some(line) = lines.next_line().await? { + let mut lines = BufReader::new(input); + while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? { if line.trim().is_empty() { continue; } @@ -59,3 +91,56 @@ impl PhotocraftMcp { result } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn bounded_line_accepts_exact_limit_and_final_line() { + let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec())); + assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234")); + assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!")); + assert!(bounded_line(&mut input, 5).await.unwrap().is_none()); + + let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec())); + assert_eq!(bounded_line(&mut oversized_eof, 5).await.unwrap_err().kind(), std::io::ErrorKind::InvalidData); + } + + #[tokio::test] + async fn oversized_valid_tool_call_never_reaches_dispatch() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "admission-test", "version": "1" } + } + }); + let mut input = format!("{initialize}\n{}\n", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).into_bytes(); + let call = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "doc_new", "arguments": { "width": 8, "height": 8 } } + }) + .to_string() + .into_bytes(); + assert!(call.len() < MAX_REQUEST_BYTES); + input.extend_from_slice(&call); + // The valid JSON prefix spans multiple BufReader fills; only its whitespace suffix + // crosses the byte limit. No part of this line may be forwarded to rmcp. + input.extend(vec![b' '; MAX_REQUEST_BYTES - call.len()]); + input.push(b'\n'); + let server = PhotocraftMcp::headless(); + let observer = server.clone(); + let result = tokio::time::timeout(std::time::Duration::from_secs(5), server.serve_io(std::io::Cursor::new(input), tokio::io::sink())) + .await + .expect("oversized MCP input should end the session"); + assert!(result.unwrap_err().to_string().contains("request exceeds 1048576 bytes")); + let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap(); + assert_eq!(documents, 0, "the oversized tool call must not run"); + } +} diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 51a510ca5..76a14c6e3 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -1,6 +1,6 @@ //! Capability-based filesystem policy for untrusted automation paths. -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; @@ -51,8 +51,7 @@ impl AuthorizedWorkspace { if !metadata.is_file() { return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`"))); } - // Bounded reads, and a clear error for a file larger than memory (#375). - photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e)) + read_to_observed_length(&mut file, metadata.len()) } /// Load one `.pcraft` document in directory form — the native on-disk layout — below the @@ -108,6 +107,34 @@ impl AuthorizedWorkspace { } } +fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Result, AutomationError> { + // Do not reserve from a potentially stale or attacker-controlled metadata length. Read in + // chunks, and reject growth beyond the length observed on the opened file before appending. + let mut bytes = Vec::new(); + let mut chunk = [0u8; 64 << 10]; + loop { + let remaining = observed_length.saturating_sub(bytes.len() as u64); + let ask = chunk.len().min(usize::try_from(remaining.saturating_add(1)).unwrap_or(chunk.len())); + let n = match reader.read(&mut chunk[..ask]) { + Ok(n) => n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(AutomationError::Io(format!("automation read failed: {e}"))), + }; + if n == 0 { + return Ok(bytes); + } + if n as u64 > remaining { + return Err(AutomationError::BadRequest(format!("automation file grew beyond its observed length of {observed_length} bytes"))); + } + let needed = bytes.len().checked_add(n).ok_or_else(|| AutomationError::BadRequest("automation file is too large to read".into()))?; + if needed > bytes.capacity() { + let target = bytes.capacity().saturating_add(bytes.capacity() / 2).max(needed).min(usize::try_from(observed_length).unwrap_or(usize::MAX)); + bytes.try_reserve_exact(target - bytes.len()).map_err(|e| AutomationError::Io(format!("automation read allocation failed: {e}")))?; + } + bytes.extend_from_slice(&chunk[..n]); + } +} + fn open_root(path: Option<&Path>, authority: &str) -> Result, AutomationError> { let Some(path) = path else { return Ok(None) }; if path.as_os_str().is_empty() { @@ -465,6 +492,23 @@ mod tests { assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created"); } + #[test] + fn large_observed_length_does_not_trigger_a_fixed_limit_or_upfront_reservation() { + let mut shortened = std::io::Cursor::new(b"psb"); + assert_eq!(read_to_observed_length(&mut shortened, (1 << 30) + 1).unwrap(), b"psb"); + } + + #[test] + fn read_growth_is_rejected_at_the_observed_length() { + let mut growing = std::io::repeat(b'x').take(65); + let error = read_to_observed_length(&mut growing, 64).unwrap_err().to_string(); + assert!(error.contains("grew beyond its observed length of 64 bytes"), "{error}"); + let mut allowed = std::io::repeat(b'x').take(64); + assert_eq!(read_to_observed_length(&mut allowed, 64).unwrap().len(), 64); + let mut empty = std::io::Cursor::new(b"x"); + assert!(read_to_observed_length(&mut empty, 0).is_err()); + } + #[test] fn open_pcraft_loads_a_directory_below_the_read_root() { let (inside, _, workspace) = roots("pcraft-dir"); diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index 32dae06af..3ec39ebcc 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -1040,6 +1040,18 @@ async fn bridge_reports_unreachable_app() { client.cancel().await.unwrap(); } +#[tokio::test(flavor = "multi_thread")] +async fn command_batch_description_exposes_non_atomic_semantics_when_tools_are_listed() { + let client = connect(PhotocraftMcp::headless()).await; + let tools = client.list_all_tools().await.unwrap(); + let tool = tools.iter().find(|tool| tool.name == "command_batch").expect("command_batch tool"); + let description = tool.description.as_deref().expect("command_batch description"); + for contract in ["in order", "Not an atomic transaction", "first error", "stop_on_error is false", "no rollback"] { + assert!(description.contains(contract), "command_batch description omits `{contract}`: {description}"); + } + client.cancel().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] async fn command_batch_runs_steps_in_order() { let client = connect(PhotocraftMcp::headless()).await; @@ -1216,7 +1228,20 @@ async fn convention_resources_are_live_json() { assert!(read("photocraft://document").await["document"].is_null()); json_of(&call(&client, "doc_new", json!({"width":8,"height":6})).await); assert_eq!(read("photocraft://document").await["width"], 8); - assert_eq!(read("photocraft://commands").await, json_of(&call(&client, "command_list", json!({})).await)); + // Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold + // anything, and the other tests in this binary fill and purge those caches concurrently, so + // its `enabled` flag can flip between the two reads. Every other field must match exactly. + let stable = |mut v: Value| { + for c in v.as_array_mut().into_iter().flatten() { + if c["id"] == "edit.purge.all" { + c.as_object_mut().map(|o| o.remove("enabled")); + } + } + v + }; + let commands = stable(read("photocraft://commands").await); + assert!(commands.as_array().is_some_and(|a| a.len() > 100), "{commands}"); + assert_eq!(commands, stable(json_of(&call(&client, "command_list", json!({})).await))); assert!(client.read_resource(rmcp::model::ReadResourceRequestParams::new("photocraft://missing")).await.is_err()); let preview = call(&client, "render_preview", json!({"max_side":4})).await; assert!(preview.content.iter().any(|c| c.as_image().is_some())); diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e852b27a6..570652933 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -4,7 +4,7 @@ Codecs for flat raster image formats, written in pure Rust. The crate is standal workspace crate and defines its own `Image` type. It also builds for `wasm32-unknown-unknown` and does no I/O: decoders take `&[u8]` and encoders return `Vec`. -It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1: +It follows the "Avoiding GIMP's hole" rules in `docs/architecture.md` §1.1: * **Symmetric.** Every format we write, we can also read. The only exceptions are listed in `ASYMMETRIC_EXCEPTIONS`, each with a reason. The test suite checks this. diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 641a983f6..0f26c6132 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -213,19 +213,9 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth p[..3].copy_from_slice(&o); } } - Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => map_rgb(buf, |c| { - let l = rgb_to_gray(c); - let ws = (1.0 - l * 2.0).clamp(0.0, 1.0); - let wh = (l * 2.0 - 1.0).clamp(0.0, 1.0); - let wm = 1.0 - ws - wh; - let out: [f32; 3] = std::array::from_fn(|i| (c[i] + (shadows[i] * ws + midtones[i] * wm + highlights[i] * wh) / 100.0 * 0.5).clamp(0.0, 1.0)); - if *preserve_luminosity { - let l1 = rgb_to_gray(out).max(1e-6); - out.map(|v| (v * l / l1).clamp(0.0, 1.0)) - } else { - out - } - }), + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + map_rgb(buf, |c| std::array::from_fn(|ch| color_balance_channel([*shadows, *midtones, *highlights], *preserve_luminosity, ch, c[ch]))) + } Adjustment::SelectiveColor { relative, adjustments } => map_rgb(buf, |c| selective_color(c, *relative, adjustments)), Adjustment::ColorLookup { lut: Some(table), size, tetrahedral, dither, .. } if lut3d_len(*size).is_some_and(|len| table.len() >= len) => { let (n, w, x0, y0) = (*size as usize, buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0); @@ -434,6 +424,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 is gamma 2^(−a/2) on the slider's channel and 2^(a/2) on +/// the other two. 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). +/// +/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one +/// channel: within 1 level). +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 && tone != 1 { + 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 = if tone == 0 { black(v, BALANCE_LEVELS * (hi - own)) } else { 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); + let own = src == ch; + if a == 0.0 || (!preserve_luminosity && !own) { + continue; + } + v = match (tone, preserve_luminosity) { + (1, false) => v.powf(2f32.powf(-a)), + (1, true) => v.powf(2f32.powf(if own { -a } else { a } / 2.0)), + (0, false) if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)), + (0, false) => v.powf(2f32.powf(-a / 2.0)), + + (2, false) if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)), + (2, false) => v.powf(2f32.powf(-a / 2.0)), + + _ => v, + }; + } + } + v +} + /// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans, /// blues, magentas (by chroma within the hue sector), whites, neutrals and blacks (by lightness). pub fn selective_color_weights(c: [f32; 3]) -> [f32; 9] { @@ -967,6 +1014,46 @@ mod tone_tests { } } + /// Photoshop 27.11, measured: Color Balance with one cyan–red slider moved, over a gray ramp + /// (x = 0, 16, …, 240, 255); red and green out (blue follows green on gray). + #[test] + fn color_balance_matches_photoshop() { + const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255]; + type Row = (&'static str, f32, bool, [u8; 17], [u8; 17]); + let measured: [Row; 6] = [ + ("midtones", 50.0, false, [0, 36, 59, 78, 96, 112, 128, 142, 156, 170, 183, 196, 209, 221, 233, 244, 255], X), + ("shadows", -100.0, false, [0, 0, 0, 0, 0, 0, 0, 7, 23, 43, 67, 93, 122, 154, 186, 221, 255], X), + ("highlights", 100.0, false, [0, 51, 84, 111, 136, 160, 182, 202, 223, 242, 255, 255, 255, 255, 255, 255, 255], X), + ("shadows", 50.0, true, X, [0, 0, 0, 0, 17, 37, 57, 77, 97, 117, 137, 157, 177, 197, 216, 236, 255]), + ( + "midtones", + 100.0, + true, + [0, 36, 59, 78, 96, 112, 128, 142, 156, 170, 183, 196, 209, 221, 233, 244, 255], + [0, 5, 14, 24, 36, 50, 64, 80, 97, 114, 132, 151, 171, 191, 212, 234, 255], + ), + ("highlights", -100.0, true, X, [0, 26, 53, 79, 105, 132, 158, 184, 211, 237, 255, 255, 255, 255, 255, 255, 255]), + ]; + // Two sliders in one tone, Preserve Luminosity on (measured): shadows red and blue +50 darken + // only green; highlights red −50 with green +50 lift green by 100 and blue by 50. + let two = + |tones: [[f32; 3]; 3], x: f32| -> [i32; 3] { std::array::from_fn(|ch| (color_balance_channel(tones, true, ch, x / 255.0) * 255.0).round() as i32) }; + assert_eq!(two([[50.0, 0.0, 50.0], [0.0; 3], [0.0; 3]], 128.0), [128, 97, 128]); + let hi = two([[0.0; 3], [0.0; 3], [-50.0, 50.0, 0.0]], 128.0); + assert!((hi[0] - 128).abs() <= 1 && (hi[1] - 211).abs() <= 1 && (hi[2] - 159).abs() <= 1, "{hi:?}"); + for (tone, amount, preserve, red, green) in measured { + let mut tones = [[0.0f32; 3]; 3]; + tones[["shadows", "midtones", "highlights"].iter().position(|t| *t == tone).unwrap()][0] = amount; + for (i, x) in X.iter().enumerate() { + let v = f32::from(*x) / 255.0; + let got = |ch| (color_balance_channel(tones, preserve, ch, v) * 255.0).round() as i32; + let (r, g) = (got(0), got(1)); + assert!((r - i32::from(red[i])).abs() <= 1, "{tone} {amount} pl {preserve} at {x}: red {r} vs Photoshop {}", red[i]); + assert!((g - i32::from(green[i])).abs() <= 1, "{tone} {amount} pl {preserve} at {x}: green {g} vs Photoshop {}", green[i]); + } + } + } + #[test] fn levels_and_curves_apply_each_channel_before_the_master() { // These two affine tone adjustments describe the same mapping: diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 712033e54..37c6167e9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -628,6 +628,27 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { } } +/// Where a layer's effects take their shape from: [`layer_bounds`], except that a group's +/// children count with their own effects (a child's outside stroke shapes the group's drop +/// shadow and must not be cut off at the child's pixels, #3274). +pub fn effect_source_bounds(layer: &Layer, canvas: Rect) -> Rect { + match &layer.content { + LayerContent::Group(g) if g.artboard.is_none() && !advanced::shapeless(layer) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| { + let m = if effects::has_effects(c) { effects::margin(c) } else { 0 }; + let b = effect_source_bounds(c, canvas); + let b = if b.is_empty() { b } else { b.inflate(m) }; + if b.is_empty() { + acc + } else if acc.is_empty() { + b + } else { + acc.union(&b) + } + }), + _ => layer_bounds(layer, canvas), + } +} + /// The path a shape layer's effects are shaped by: its fill path. Photoshop builds a filled /// shape's layer effects from its vector outline, not from its pixels: a stroke runs along the /// whole path even where a gradient fill fades out (psd-tools stroke-effects). `None` for other @@ -851,9 +872,10 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP fn empty_in(layer: &Layer, rect: Rect) -> bool { if effects::has_effects(layer) { // Effects reach at most `margin` beyond the layer's pixels (when it is transparent - // outside them): render tiles away from a small text layer skip it entirely. + // outside them; a group's from its children with their effects): render tiles away from a + // small text layer skip it entirely. let canvas = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4); - let bounds = shapeless_stroke_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas)); + let bounds = shapeless_stroke_bounds(layer).unwrap_or_else(|| effect_source_bounds(layer, canvas)); return transparent_outside(layer) && bounds.inflate(effects::margin(layer)).intersect(&rect).is_empty(); } match &layer.content { @@ -1453,7 +1475,7 @@ fn fx_cache() -> &'static std::sync::Mutex { /// (or anything else rendering children over a wider area) gets the whole region. fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) { let m = effects::margin(layer); - let region = layer_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m)); + let region = effect_source_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m)); let clip = *cx.fx_clip.lock().unwrap_or_else(|e| e.into_inner()); match clip { Some(c) if c.contains_rect(&rect) && advanced::shapeless(layer) && effects::maps_are_local(layer) => { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 1bb4afc16..853767ae8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -652,6 +652,22 @@ fn drop_shadow_falls_away_from_light() { assert!(close4(px(&d, 32, 20), [1.0; 4])); } +#[test] +fn group_effects_reach_past_children_effects() { + // A child's outside stroke reaches further than its group's drop shadow: the group's shadow + // follows the stroke, not the child's pixels alone (#3274). + let mut d = doc_white(48, 48); + let mut sq = solid_layer("sq", Rect::new(14, 14, 34, 34), [1.0, 0.0, 0.0, 1.0]); + sq.effects.items = vec![stroke(6.0, StrokePosition::Outside)]; + let mut grp = Layer::group("g", vec![sq]); + grp.effects.items = vec![Effect::DropShadow(shadow(2.0, 90.0))]; + d.layers.push(grp); + assert!(close4(px(&d, 9, 24), [0.0, 0.0, 1.0, 1.0]), "stroke left of the pixels: {:?}", px(&d, 9, 24)); + assert!(close4(px(&d, 24, 38), [0.0, 0.0, 1.0, 1.0]), "stroke below the pixels: {:?}", px(&d, 24, 38)); + assert!(close4(px(&d, 24, 41), [0.0, 0.0, 0.0, 1.0]), "shadow below the stroke: {:?}", px(&d, 24, 41)); + assert!(close4(px(&d, 24, 43), [1.0; 4]), "nothing past the shadow: {:?}", px(&d, 24, 43)); +} + #[test] fn drop_shadow_uses_global_light_and_knockout() { let mut d = fx_doc(vec![Effect::DropShadow(Shadow { use_global_light: true, ..shadow(5.0, 0.0) })]); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 608cc46f4..c92b6db73 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -723,6 +723,42 @@ impl Layer { pub struct Guides { pub horizontal: Vec, pub vertical: Vec, + /// Guides given their own colour (View › Guides › New Guide… › Color). The rest use + /// Preferences › Guides, Grid & Slices › Guide colour. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub colors: Vec, +} + +/// One guide's own colour. It is keyed by orientation and position, not by index, so code that +/// drops or reorders guide positions (crop, artboards, guide layouts) can never move a colour onto +/// another guide: a guide whose entry no longer matches simply uses the preference colour. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GuideColor { + pub vertical: bool, + pub position: f64, + /// sRGB, 0–255. + pub rgb: [u8; 3], +} + +impl Guides { + /// The guide's own colour, if it has one. + pub fn color(&self, vertical: bool, position: f64) -> Option<[u8; 3]> { + self.colors.iter().find(|c| c.vertical == vertical && c.position == position).map(|c| c.rgb) + } + + /// Give the guide at `position` its own colour (`None` returns it to the preference colour). + pub fn set_color(&mut self, vertical: bool, position: f64, rgb: Option<[u8; 3]>) { + self.colors.retain(|c| !(c.vertical == vertical && c.position == position)); + if let Some(rgb) = rgb { + self.colors.push(GuideColor { vertical, position, rgb }); + } + } + + /// Drop colours whose guide no longer exists (after a guide moved, was deleted or cleared). + pub fn prune_colors(&mut self) { + let (h, v) = (&self.horizontal, &self.vertical); + self.colors.retain(|c| if c.vertical { v.contains(&c.position) } else { h.contains(&c.position) }); + } } /// Which areas an alpha channel's overlay colour marks (Channel Options › Color Indicates). diff --git a/crates/engine/examples/content_aware_bench.rs b/crates/engine/examples/content_aware_bench.rs new file mode 100644 index 000000000..0fbf30ccb --- /dev/null +++ b/crates/engine/examples/content_aware_bench.rs @@ -0,0 +1,462 @@ +//! Content-Aware family: timings of the perf scenarios P21, P22 and P49 (the same documents and +//! steps as `photocraft-ui-egui`'s `perf_scenarios`, without the canvas refresh), and a hash of +//! every tool's output, to check that a speed-up leaves the result bit for bit as it was. +//! +//! ```text +//! cargo run --release -p photocraft-engine --example content_aware_bench -- bench caf|heal|spot|cancel|cancelscan|full24 [reps] +//! cargo run --release -p photocraft-engine --example content_aware_bench -- hash|hash-big +//! ``` +//! +//! `bench` prints one line per sample (` `), so runs of two builds can be +//! interleaved and pooled. `hash-big` hashes the benchmarks' own fills (10 and 24 MP). `hash` prints ` ` for Content-Aware Fill +//! (every sampling and adaptation option), Edit › Fill Content-Aware, Delete and Fill, Spot +//! Healing, Patch and Content-Aware Move at 8, 16 and 32 bits, and for the algorithms called +//! directly on synthetic buffers (1, 3 and 4 channels). +use std::time::{Duration, Instant}; + +use photocraft_algo::content_aware::{self, FillOptions}; +use photocraft_algo::inpaint::{self, Adapt, CompleteParams}; +use photocraft_engine::Session; +use photocraft_engine::jobs::{JobId, Started}; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +type Res = Result; + +fn exec(s: &mut Session, id: &str, p: Value) -> Res { + s.execute(id, p).map_err(|e| format!("{id}: {e}")) +} + +/// Wall-clock milliseconds (mod 100 s), to line samples up with a trace. +fn epoch_ms() -> f64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64() * 1000.0) % 100000.0 +} + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1000.0 +} + +/// `perf_scenarios`' photo: gradients, a soft blob, fine noise and a high-contrast disc, through +/// JPEG so it opens like a real photo. +fn open_photo(w: u32, h: u32) -> Res { + use rayon::prelude::*; + let mut px = vec![0u8; w as usize * h as usize * 3]; + let (cx, cy, r) = (w as f32 * 0.55, h as f32 * 0.5, w.min(h) as f32 / 3.0); + px.par_chunks_mut(w as usize * 3).enumerate().for_each(|(y, row)| { + for x in 0..w as usize { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5; + let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp(); + let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < r; + let (r0, g0, b0) = if inside { (0.85, 0.3 + 0.2 * (fx * 40.0).sin(), 0.15) } else { (0.35, 0.45, 0.55) }; + let rgb = [r0 + 0.1 * blob + n * 0.08, g0 + 0.1 * blob + n * 0.08, b0 + 0.2 * fy + n * 0.08]; + for (c, v) in rgb.into_iter().enumerate() { + row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + }); + let img = + photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).map_err(|e| e.to_string())?; + let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).map_err(|e| e.to_string())?; + let doc = photocraft_io::import("photo.jpg", &jpeg).map_err(|e| e.to_string())?.document; + let mut s = Session::new(); + s.open_document(doc, Some("photo.jpg".into())); + Ok(s) +} + +/// Minor page faults of this process so far (Linux; 0 elsewhere). +fn minor_faults() -> u64 { + std::fs::read_to_string("/proc/self/stat") + .ok() + .and_then(|st| st.rsplit(')').next().and_then(|rest| rest.split_whitespace().nth(7)?.parse().ok())) + .unwrap_or(0) +} + +fn start_job(s: &mut Session, id: &str, p: Value) -> Res { + match s.start(id, p).map_err(|e| format!("{id}: {e}"))? { + Started::Job(j) => Ok(j), + Started::Done(_) => Err(format!("{id} did not run as a background job")), + } +} + +fn wait_job(s: &mut Session, id: JobId) -> Res<()> { + let t = Instant::now(); + loop { + if s.poll_jobs().iter().any(|e| e.id == id) { + return Ok(()); + } + if t.elapsed().as_secs() > 120 { + return Err("job did not finish in 120 s".into()); + } + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn bench(which: &str, reps: usize) -> Res<()> { + match which { + // P21: Content-Aware Fill 10 MP (200 px hole). + "caf" => { + let (w, h) = (3872u32, 2592u32); + let mut s = open_photo(w, h)?; + for i in 1..=reps { + exec(&mut s, "select.rect", json!({"x": w / 3 + i as u32 * 40, "y": h / 3, "width": 200, "height": 200, "ellipse": true}))?; + let t = Instant::now(); + exec(&mut s, "edit.contentAwareFill", json!({"seed": i}))?; + println!("caf {:.3}", ms(t)); + exec(&mut s, "edit.undo", json!({}))?; + exec(&mut s, "select.deselect", json!({}))?; + } + } + // P22: Spot Healing stroke 10 MP. + "heal" => { + let (w, h) = (3872u32, 2592u32); + let mut s = open_photo(w, h)?; + for i in 1..=reps { + let (x, y) = (w / 4 + i as u32 * 60, h / 4); + let pts: Vec = (0..10).map(|k| json!([x + k * 12, y + k * 4])).collect(); + let t = Instant::now(); + exec(&mut s, "paint.spotHealing", json!({"points": pts, "size": 60}))?; + println!("heal {:.3}", ms(t)); + } + } + // P49: cancel a Content-Aware Fill job on 24 MP, early, midway and late. + "cancel" => { + let (w, h) = (6000u32, 4000u32); + let mut s = open_photo(w, h)?; + exec(&mut s, "select.rect", json!({"x": w / 3, "y": h / 3, "width": w / 5, "height": h / 4}))?; + for i in 1..=reps { + let job = start_job(&mut s, "edit.contentAwareFill", json!({}))?; + let after = 20 + (i as u64 * 97) % 700; + std::thread::sleep(Duration::from_millis(after)); + let t = Instant::now(); + s.cancel_job(job); + s.join_cancelled_jobs(); + println!("cancel {:.3} (after {after} ms)", ms(t)); + wait_job(&mut s, job)?; + } + } + // P49's job cancelled every `reps` ms through its run: where cancelling waits longest. + "cancelscan" => { + let (w, h) = (6000u32, 4000u32); + let mut s = open_photo(w, h)?; + exec(&mut s, "select.rect", json!({"x": w / 3, "y": h / 3, "width": w / 5, "height": h / 4}))?; + let env = |k: &str, d: u64| std::env::var(k).ok().and_then(|v| v.parse().ok()).unwrap_or(d); + let (mut after, until) = (env("CAF_SCAN_FROM", 10), env("CAF_SCAN_TO", 2500)); + while after < until { + let job = start_job(&mut s, "edit.contentAwareFill", json!({}))?; + std::thread::sleep(Duration::from_millis(after)); + let t = Instant::now(); + if std::env::var_os("CAF_TRACE").is_some() { + eprintln!("CANCEL @{:.1}", epoch_ms()); + } + s.cancel_job(job); + s.join_cancelled_jobs(); + println!("cancelscan {:.3} (after {after} ms)", ms(t)); + if std::env::var_os("CAF_TRACE").is_some() { + eprintln!("JOINED @{:.1}", epoch_ms()); + } + wait_job(&mut s, job)?; + after += reps.max(1) as u64; + } + } + // `retouch_bench`'s Spot Healing dabs (sizes 50, 100, 200, 300; Content-Aware, or the type + // in `CAF_SPOT_TYPE`) on its 6016² document, `reps` rounds. + "spot" => { + let side = 6016u32; + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"width": side, "height": side, "depth": 8}))?; + let area = Rect::new(2000, 2000, 4048, 4048); + s.edit("texture", |doc, active| { + let Some(surf) = active.and_then(|id| doc.layer_mut(id)).and_then(|l| l.surface_mut()) else { return Ok(()) }; + let fmt = surf.format(); + let mut data = Vec::with_capacity(area.width() as usize * area.height() as usize * 4); + for y in area.y0..area.y1 { + for x in area.x0..area.x1 { + let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) as u32 % 1000) as f32 / 1000.0 * 0.08 - 0.04; + let blemish = (x % 400 - 200).abs() < 8 && (y % 400 - 200).abs() < 8; + let v = if blemish { + [0.05, 0.05, 0.05, 1.0] + } else { + [0.3 + 0.0002 * x as f32 + n, 0.4 + ((x + y) as f32 * 0.01).sin() * 0.1 + n, 0.5 + n, 1.0] + }; + data.extend(photocraft_raster::from_rgba(&fmt, v)); + } + } + surf.write_region(area, &data); + Ok(()) + }) + .map_err(|e| e.to_string())?; + // Comma-separated types, run in turn (`CAF_SPOT_TYPE`, default Content-Aware). + let kinds = std::env::var("CAF_SPOT_TYPE").unwrap_or_else(|_| "contentAware".into()); + let mut k = 4u32; + for _ in 0..reps { + for kind in kinds.split(',') { + for size in [50.0, 100.0, 200.0, 300.0] { + k += 1; + let (x, y) = (2200 + (k % 4) * 400, 2200 + (k / 4 % 4) * 400); + let (t, f) = (Instant::now(), minor_faults()); + exec(&mut s, "paint.spotHealing", json!({"points": [[x, y]], "size": size, "hardness": 50, "type": kind}))?; + println!("{}{size} {:.3} faults {}", &kind[..4], ms(t), minor_faults().saturating_sub(f)); + } + } + } + } + // The whole 24 MP fill, uncancelled (how long P49's job runs). + "full24" => { + let (w, h) = (6000u32, 4000u32); + let mut s = open_photo(w, h)?; + exec(&mut s, "select.rect", json!({"x": w / 3, "y": h / 3, "width": w / 5, "height": h / 4}))?; + for _ in 1..=reps { + let t = Instant::now(); + exec(&mut s, "edit.contentAwareFill", json!({}))?; + println!("full24 {:.3}", ms(t)); + exec(&mut s, "edit.undo", json!({}))?; + } + } + o => return Err(format!("unknown scenario `{o}` (caf|heal|spot|cancel|cancelscan|full24)")), + } + Ok(()) +} + +fn hash_f32(v: &[f32]) -> String { + let mut hs = blake3::Hasher::new(); + for x in v { + hs.update(&x.to_bits().to_le_bytes()); + } + hs.finalize().to_hex()[..16].to_string() +} + +/// The active layer's pixels over the canvas. +fn layer_hash(s: &Session) -> Res { + let d = s.active().ok_or("no document")?; + let id = d.active_layer.ok_or("no active layer")?; + let surf = d.doc.layer(id).and_then(|l| l.surface()).ok_or("no surface")?; + Ok(hash_f32(&surf.read_region(d.doc.bounds()))) +} + +/// A textured document: gradients, stripes, a bright band, blobs and fine noise. +fn textured(w: u32, h: u32, depth: u64) -> Res { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"width": w, "height": h, "depth": depth}))?; + s.edit("texture", |doc, active| { + let Some(surf) = active.and_then(|id| doc.layer_mut(id)).and_then(|l| l.surface_mut()) else { return Ok(()) }; + let fmt = surf.format(); + let area = Rect::new(0, 0, w as i32, h as i32); + let mut data = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h as i32 { + for x in 0..w as i32 { + let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) as u32 % 1000) as f32 / 1000.0 * 0.08 - 0.04; + let stripe = if (x / 9) % 2 == 0 { 0.1 } else { 0.0 }; + let band = if (x - y / 2).rem_euclid(400) < 40 { 0.25 } else { 0.0 }; + let blob = (-(((x as f32 / w as f32 - 0.6) * 4.0).powi(2) + ((y as f32 / h as f32 - 0.4) * 5.0).powi(2))).exp() * 0.2; + let v = [0.25 + 0.0002 * x as f32 + stripe + band + n, 0.4 + ((x + y) as f32 * 0.02).sin() * 0.1 + blob + n, 0.5 + band * 0.5 - blob + n, 1.0]; + data.extend(photocraft_raster::from_rgba(&fmt, v.map(|c| c.clamp(0.0, 1.0)))); + } + } + surf.write_region(area, &data); + Ok(()) + }) + .map_err(|e| e.to_string())?; + Ok(s) +} + +/// The tools on an 8-bit document converted to another colour mode. +fn mode_cases(mode: &str, out: &mut Vec<(String, String)>) -> Res<()> { + let mut s = textured(600, 450, 8)?; + exec(&mut s, &format!("image.mode.{mode}"), json!({}))?; + let mut step = |s: &mut Session, name: &str, cmds: &[(&str, Value)]| -> Res<()> { + for (id, p) in cmds { + exec(s, id, p.clone())?; + } + out.push((format!("{mode}/{name}"), layer_hash(s)?)); + Ok(()) + }; + let ellipse = |x: u32, y: u32, sw: u32, sh: u32| ("select.rect", json!({"x": x, "y": y, "width": sw, "height": sh, "ellipse": true})); + step(&mut s, "caf-auto", &[ellipse(250, 200, 70, 60), ("edit.contentAwareFill", json!({}))])?; + step(&mut s, "caf-none", &[ellipse(400, 120, 50, 50), ("edit.contentAwareFill", json!({"colorAdaptation": "none", "sampling": "rectangular"}))])?; + step(&mut s, "patch-ca", &[ellipse(120, 300, 50, 40), ("paint.patch", json!({"offset": [150, -80], "contentAware": true, "color": 4}))])?; + step(&mut s, "move", &[ellipse(450, 320, 60, 50), ("paint.contentAwareMove", json!({"offset": [-90, -60]}))])?; + exec(&mut s, "select.deselect", json!({}))?; + step(&mut s, "spot", &[("paint.spotHealing", json!({"points": [[100, 100], [130, 110], [160, 125]], "size": 30}))])?; + Ok(()) +} + +fn doc_cases(depth: u64, out: &mut Vec<(String, String)>) -> Res<()> { + let (w, h) = (900u32, 700u32); + let mut s = textured(w, h, depth)?; + let mut step = |s: &mut Session, name: &str, cmds: &[(&str, Value)]| -> Res<()> { + for (id, p) in cmds { + exec(s, id, p.clone())?; + } + let hash = layer_hash(s)?; + // A case that changes nothing checks nothing. + let same = out.last().is_some_and(|(_, prev): &(String, String)| *prev == hash); + out.push((format!("doc{depth}/{name}"), if same { format!("{hash} UNCHANGED") } else { hash })); + Ok(()) + }; + let ellipse = |x: u32, y: u32, sw: u32, sh: u32| ("select.rect", json!({"x": x, "y": y, "width": sw, "height": sh, "ellipse": true})); + step(&mut s, "caf-auto", &[ellipse(400, 300, 90, 70), ("edit.contentAwareFill", json!({}))])?; + step( + &mut s, + "caf-rect-none", + &[ellipse(150, 120, 60, 80), ("edit.contentAwareFill", json!({"sampling": "rectangular", "colorAdaptation": "none", "seed": 3}))], + )?; + step(&mut s, "caf-high-rot", &[ellipse(600, 450, 40, 40), ("edit.contentAwareFill", json!({"colorAdaptation": "high", "rotationAdaptation": "low"}))])?; + step( + &mut s, + "caf-veryhigh-mirror-scale", + &[ + ellipse(700, 150, 36, 30), + ("edit.contentAwareFill", json!({"colorAdaptation": "veryHigh", "mirror": true, "scale": true, "rotationAdaptation": "full"})), + ], + )?; + step(&mut s, "caf-custom", &[ellipse(300, 500, 50, 50), ("edit.contentAwareFill", json!({"sampling": "custom", "area": [100, 400, 200, 250]}))])?; + // A wide hole: the finest levels only refine (the hole is wider than `refine_extent`). + step(&mut s, "caf-big", &[("select.rect", json!({"x": 300, "y": 200, "width": 330, "height": 250})), ("edit.contentAwareFill", json!({"seed": 9}))])?; + step(&mut s, "fill-ca", &[ellipse(200, 300, 50, 60), ("edit.fill", json!({"contents": "contentAware"}))])?; + step(&mut s, "fill-ca-noadapt", &[ellipse(650, 550, 50, 40), ("edit.fill", json!({"contents": "contentAware", "colorAdaptation": false}))])?; + step(&mut s, "delete-and-fill", &[ellipse(100, 600, 45, 45), ("edit.deleteAndFillSelection", json!({}))])?; + exec(&mut s, "select.deselect", json!({}))?; + for (k, kind) in ["contentAware", "proximityMatch", "createTexture"].into_iter().enumerate() { + let (x, y) = (150 + 220 * k as u32, 250); + let pts: Vec = (0..8).map(|j| json!([x + j * 10, y + j * 5])).collect(); + step(&mut s, &format!("spot-{kind}"), &[("paint.spotHealing", json!({"points": pts, "size": 40, "type": kind}))])?; + } + step(&mut s, "spot-dab", &[("paint.spotHealing", json!({"points": [[820, 640]], "size": 25}))])?; + step(&mut s, "patch-normal", &[ellipse(350, 100, 60, 50), ("paint.patch", json!({"offset": [120, 60]}))])?; + step(&mut s, "patch-ca", &[ellipse(500, 450, 70, 60), ("paint.patch", json!({"offset": [-150, -40], "contentAware": true, "color": 3}))])?; + step(&mut s, "patch-ca-s1", &[ellipse(200, 150, 50, 50), ("paint.patch", json!({"offset": [100, 90], "contentAware": true, "structure": 1}))])?; + step(&mut s, "move", &[ellipse(420, 380, 80, 60), ("paint.contentAwareMove", json!({"offset": [-120, 80]}))])?; + step(&mut s, "move-color", &[ellipse(600, 200, 60, 60), ("paint.contentAwareMove", json!({"offset": [90, 110], "color": 5, "structure": 2}))])?; + step( + &mut s, + "extend", + &[ellipse(150, 450, 70, 50), ("paint.contentAwareMove", json!({"offset": [80, -60], "mode": "extend", "color": 10, "structure": 7}))], + )?; + // A feathered selection (partial coverage) and the fill onto a duplicate. + step(&mut s, "caf-feather", &[ellipse(640, 330, 70, 50), ("select.modify.feather", json!({"radius": 6})), ("edit.contentAwareFill", json!({"seed": 4}))])?; + step(&mut s, "caf-duplicate", &[ellipse(260, 380, 50, 40), ("edit.contentAwareFill", json!({"output": "duplicate"}))])?; + // Last: the fill lands on a new layer, which becomes the active one. + step(&mut s, "caf-margin-new", &[ellipse(500, 200, 70, 40), ("edit.contentAwareFill", json!({"sampling": "rectangular", "margin": 60, "output": "new"}))])?; + Ok(()) +} + +/// Smooth gradient, stripes and deterministic noise, `ch` channels. +fn synth(w: usize, h: usize, ch: usize, seed: u64) -> Vec { + let mut rng = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + (0..w * h) + .flat_map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + let n = (rng % 1000) as f32 / 1000.0 * 0.06 - 0.03; + let stripe = if (i % w / 5).is_multiple_of(2) { 0.15 } else { 0.0 }; + (0..ch).map(move |c| (0.2 + 0.003 * x * (c as f32 + 1.0) / ch as f32 + 0.002 * y + stripe * (c % 2) as f32 + n).clamp(0.0, 1.0)) + }) + .collect() +} + +fn algo_cases(out: &mut Vec<(String, String)>) { + let disc = |w: usize, h: usize, cx: f32, cy: f32, rx: f32, ry: f32| -> Vec { + (0..w * h).map(|i| (((i % w) as f32 - cx) / rx).hypot(((i / w) as f32 - cy) / ry) < 1.0).collect() + }; + for &(w, h, ch) in &[(96usize, 80usize, 1usize), (160, 140, 3), (300, 260, 4), (520, 400, 3)] { + let img = synth(w, h, ch, (w * h) as u64); + let hole = disc(w, h, w as f32 * 0.45, h as f32 * 0.55, w as f32 * 0.12, h as f32 * 0.1); + for (name, adapt) in [("none", Adapt::NONE), ("default", content_aware::color_level("default")), ("veryHigh", content_aware::color_level("veryHigh"))] { + let p = CompleteParams { adapt, seed: 5, ..Default::default() }; + let r = inpaint::complete(w, h, ch, &img, &hole, &p); + out.push((format!("complete/{w}x{h}x{ch}/{name}"), r.map_or("none".into(), |v| hash_f32(&v)))); + } + let src: Vec = (0..w * h).map(|i| (i % w) < w * 3 / 4).collect(); + let opts = FillOptions { rotations: content_aware::rotation_level("medium"), mirror: true, scale: ch == 3, seed: 2, ..Default::default() }; + out.push((format!("fill/{w}x{h}x{ch}/variants"), hash_f32(&content_aware::fill(w, h, ch, &img, &hole, &src, &opts)))); + let auto = content_aware::auto_sampling(w, h, ch, &img, &hole); + out.push((format!("auto/{w}x{h}x{ch}"), hash_f32(&auto.iter().map(|b| f32::from(u8::from(*b))).collect::>()))); + out.push((format!("offset/{w}x{h}x{ch}"), format!("{:?}", inpaint::best_offset(w, h, ch, &img, &hole, 4, (w / 3) as i32)))); + out.push((format!("synth/{w}x{h}x{ch}"), hash_f32(&inpaint::synthesize(w, h, ch, &img, &hole, 12, 0x5eed)))); + } + // A hole wider than `refine_extent` (refine-only levels) and a long thin one. + let (w, h, ch) = (900usize, 640usize, 3usize); + let img = synth(w, h, ch, 77); + let big = disc(w, h, 450.0, 320.0, 160.0, 120.0); + let p = CompleteParams { adapt: content_aware::color_level("default"), ..Default::default() }; + out.push(("complete/big/default".into(), inpaint::complete(w, h, ch, &img, &big, &p).map_or("none".into(), |v| hash_f32(&v)))); + let thin: Vec = (0..w * h).map(|i| (100..800).contains(&(i % w)) && (300..312).contains(&(i / w))).collect(); + out.push(("complete/thin/none".into(), inpaint::complete(w, h, ch, &img, &thin, &CompleteParams::default()).map_or("none".into(), |v| hash_f32(&v)))); + // Roles: a void gap and a known-but-not-source area. + let (w, h, ch) = (200usize, 160usize, 3usize); + let img = synth(w, h, ch, 3); + let px: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if (80..120).contains(&x) && (60..100).contains(&y) { + inpaint::Px::Hole + } else if x < 12 || y > 150 { + inpaint::Px::Void + } else if x > 170 { + inpaint::Px::Known + } else { + inpaint::Px::Source + } + }) + .collect(); + let p = CompleteParams { adapt: content_aware::color_level("high"), seed: 11, ..Default::default() }; + let r = inpaint::complete_masked(w, h, ch, &img, &px, &p, &photocraft_raster::Interrupt::NONE); + out.push(( + "masked/roles/high".into(), + match r { + Ok(Some(v)) => hash_f32(&v), + Ok(None) => "none".into(), + Err(_) => "cancelled".into(), + }, + )); +} + +fn main() -> Res<()> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("bench") => { + let which = args.get(1).map_or("caf", String::as_str); + let reps = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(8); + bench(which, reps) + } + // The benchmarks' own documents (10 and 24 MP): the fills P21, P22 and P49 time. + Some("hash-big") => { + let (w, h) = (3872u32, 2592u32); + let mut s = open_photo(w, h)?; + for i in 1..=2u32 { + exec(&mut s, "select.rect", json!({"x": w / 3 + i * 40, "y": h / 3, "width": 200, "height": 200, "ellipse": true}))?; + exec(&mut s, "edit.contentAwareFill", json!({"seed": i}))?; + println!("P21/{i} {}", layer_hash(&s)?); + exec(&mut s, "select.deselect", json!({}))?; + let (x, y) = (w / 4 + i * 60, h / 4); + let pts: Vec = (0..10).map(|k| json!([x + k * 12, y + k * 4])).collect(); + exec(&mut s, "paint.spotHealing", json!({"points": pts, "size": 60}))?; + println!("P22/{i} {}", layer_hash(&s)?); + } + let (w, h) = (6000u32, 4000u32); + let mut s = open_photo(w, h)?; + exec(&mut s, "select.rect", json!({"x": w / 3, "y": h / 3, "width": w / 5, "height": h / 4}))?; + exec(&mut s, "edit.contentAwareFill", json!({}))?; + println!("P49 {}", layer_hash(&s)?); + Ok(()) + } + Some("hash") => { + let mut out = Vec::new(); + algo_cases(&mut out); + for depth in [8, 16, 32] { + doc_cases(depth, &mut out)?; + } + for mode in ["grayscale", "cmyk", "lab"] { + mode_cases(mode, &mut out)?; + } + for (k, v) in out { + println!("{k} {v}"); + } + Ok(()) + } + _ => Err("usage: content_aware_bench bench caf|heal|spot|cancel|cancelscan|full24 [reps] | hash | hash-big".into()), + } +} diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index a83a27ea4..5cebe86aa 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -474,6 +474,7 @@ fn clear_artboard_guides(s: &mut Session) -> Result { s.edit("Clear Selected Artboard Guides", |doc, _| { doc.guides.vertical.retain(|x| !inside_x(x)); doc.guides.horizontal.retain(|y| !inside_y(y)); + doc.guides.prune_colors(); Ok(()) })?; Ok(json!({"cleared": n})) diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index 793d4ca0d..1faf4cb75 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -108,6 +108,31 @@ fn clear_artboard_guides_only_inside() { assert_eq!(doc(&s).guides.vertical, vec![10.0]); } +/// Clearing guides drops their colours, so a guide added later at the same place doesn't inherit one. +#[test] +fn clearing_guides_drops_their_colours() { + let mut s = session(8); + let coloured = |s: &mut Session, x: f64| s.execute("view.newGuide", json!({"orientation": "vertical", "position": x, "color": "magenta"})).unwrap(); + let colour = |s: &Session, x: f64| doc(s).guides.color(true, x); + s.execute("layer.new.artboard", json!({"rect": [0, 0, 50, 50]})).unwrap(); + coloured(&mut s, 10.0); + coloured(&mut s, 70.0); + s.execute("view.clearSelectedArtboardGuides", json!({})).unwrap(); + s.execute("view.clearCanvasGuides", json!({})).unwrap(); + assert!(doc(&s).guides.colors.is_empty()); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 70})).unwrap(); + assert_eq!((colour(&s, 10.0), colour(&s, 70.0)), (None, None)); + // New Guide Layout's Clear Existing Guides likewise; a guide it keeps keeps its colour. + s.execute("view.clearGuides", json!({})).unwrap(); + coloured(&mut s, 0.0); + s.execute("view.newGuideLayout", json!({"columns": 1})).unwrap(); + assert_eq!(colour(&s, 0.0), Some([0xff, 0x00, 0xff])); + s.execute("view.newGuideLayout", json!({"columns": 1, "clearExisting": true})).unwrap(); + assert!(doc(&s).guides.vertical.contains(&0.0)); + assert_eq!(colour(&s, 0.0), None); +} + #[test] fn export_to_files_and_pdf() { let mut s = session(16); diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index a493cf1e4..b8f827d12 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -324,9 +324,11 @@ fn write_shim(droplet: &str) -> Result { let abs = std::fs::canonicalize(droplet).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| droplet.to_string()); let base = abs.trim_end_matches(".pcdroplet").trim_end_matches(".json"); let shim = format!("{base}.command"); + // Single quotes keep shell expansions inert; embedded quotes need to end the + // quoted segment, emit a literal quote, then resume it. + let quoted_abs = format!("'{}'", abs.replace('\'', "'\\''")); let body = format!( - "#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet \"{}\" \"$@\"\n", - abs.replace('"', "\\\"") + "#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet {quoted_abs} \"$@\"\n" ); crate::file_cmds::write_file(&shim, body.as_bytes())?; #[cfg(unix)] diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 03548d3ac..d4e0a6379 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -145,6 +145,43 @@ fn droplets_are_written_and_run() { assert!(s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["bogus", {}]]})).is_err()); } +#[cfg(unix)] +#[test] +fn droplet_shim_passes_shell_metacharacters_as_a_literal_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tmp("droplet-shell-quote"); + let path = format!("{dir}/Droplet's \"quote\" $HOME `printf expanded` $(touch shim-injected) \\\nline"); + let mut session = Session::new(); + let result = session.execute("file.automate.createDroplet", json!({"path": path, "steps": [["file.new", {}]]})).unwrap(); + let droplet = result["path"].as_str().unwrap(); + let shim = result["shim"].as_str().unwrap(); + + let cli = format!("{dir}/cli stub"); + let capture = format!("{dir}/captured path"); + let marker = format!("{dir}/shim-injected"); + let input = format!("{dir}/input file"); + std::fs::write( + &cli, + "#!/bin/sh\nif [ \"$#\" -ne 3 ] || [ \"$1\" != droplet ] || [ \"$3\" != \"$PHOTOCRAFT_EXPECTED_INPUT\" ]; then exit 90; fi\nprintf '%s' \"$2\" > \"$PHOTOCRAFT_CAPTURE\"\n", + ) + .unwrap(); + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap(); + let output = std::process::Command::new(shim) + .arg(&input) + .current_dir(&dir) + .env("PHOTOCRAFT_CLI", &cli) + .env("PHOTOCRAFT_CAPTURE", &capture) + .env("PHOTOCRAFT_EXPECTED_INPUT", &input) + .output() + .unwrap(); + + assert!(output.status.success(), "shim exited {:?}: {}", output.status, String::from_utf8_lossy(&output.stderr)); + assert!(!std::path::Path::new(&marker).exists(), "filename command substitution ran"); + let expected = std::fs::canonicalize(droplet).unwrap(); + assert_eq!(std::fs::read(&capture).unwrap(), expected.to_string_lossy().as_bytes()); +} + #[test] fn droplets_carry_the_actions_their_play_steps_call() { use crate::actions_cmds::Action; diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 003accb8d..2167cb1a0 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -374,9 +374,18 @@ fn block_brush(b: BrushSettings, p: &Value) -> Result { }) } +/// Is the stroke in Photoshop's Clear painting mode (`"mode": "clear"`, #3439)? It erases with the +/// Brush's or Pencil's own brush; unlike a blend mode it is not a layer mode, so it is not a +/// [`photocraft_color::BlendMode`]. +fn clear_mode(p: &Value) -> bool { + p.get("mode").and_then(Value::as_str).is_some_and(|m| m.eq_ignore_ascii_case("clear")) +} + /// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name -/// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal. +/// (normal|multiply|screen|…), or `clear` to erase with the brush. The Eraser has no blend mode in +/// Photoshop, so it is forced to Normal. fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { + b.erase |= clear_mode(p); if b.erase { b.mode = photocraft_color::BlendMode::Normal; } else if let Some(m) = p.get("mode").and_then(Value::as_str).and_then(crate::commands::blend_from_str) { @@ -389,7 +398,7 @@ fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { pub fn paint_stroke(s: &mut Session, p: &Value) -> Result { let pts = parse_points(p, "paint.stroke")?; let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p); - let label = if brush.erase { "Eraser" } else { "Brush Tool" }; + let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Brush Tool" }; stroke_with(s, "paint.stroke", p, label, brush, pts, false) } @@ -527,6 +536,12 @@ impl LiveStroke { self.mirrors.iter().zip(&self.mirror_distinct).filter(|(_, distinct)| **distinct).fold(bounds, |b, (r, _)| b.union(&r.bounds())) } + /// `(brush, pointer)` document positions while smoothing holds the brush back from the + /// pointer: the canvas draws a line between them. + pub fn smoothing_string(&self) -> Option<([f64; 2], [f64; 2])> { + self.renderer.smoothing_string() + } + /// Does the stroke change with time while the pointer is held still (airbrush Build-up, or /// smoothing still catching up)? See `photocraft_paint::dynamics::DabGenerator::wants_time`. pub fn wants_time(&self) -> bool { @@ -601,7 +616,7 @@ impl LiveStroke { fn pencil(s: &mut Session, p: &Value) -> Result { let pts = parse_points(p, "paint.pencil")?; let brush = pencil_brush(s, p)?; - let label = if brush.erase { "Eraser" } else { "Pencil" }; + let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Pencil" }; stroke_with(s, "paint.pencil", p, label, brush, pts, flag(p, "autoErase", false)) } @@ -1004,7 +1019,7 @@ pub fn specs() -> Vec { spec!( "paint.pencil", "Pencil", - r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, pencil, true diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 8da49ebad..3a04a470f 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -869,3 +869,17 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() { let r = s.execute("paint.stroke", other).unwrap(); assert!(r.get("fromLive").is_none()); } + +/// Photoshop's Clear painting mode (#3439): a Brush or Pencil stroke in `"mode": "clear"` erases +/// with that brush, and History still names the tool rather than the Eraser. +#[test] +fn clear_mode_erases_with_the_brush_or_pencil() { + for (cmd, label) in [("paint.stroke", "Brush Tool"), ("paint.pencil", "Pencil")] { + let mut s = session(40, 20); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute(cmd, json!({"points": [[2, 10], [38, 10]], "size": 6, "mode": "clear", "brush": {"hardness": 1.0}})).unwrap(); + assert_eq!(rgba(&s, 20, 10)[3], 0.0, "{cmd}: cleared to transparency"); + assert_eq!(rgba(&s, 20, 2), [1.0, 0.0, 0.0, 1.0], "{cmd}: untouched pixels stay"); + assert_eq!(s.active().unwrap().history.undo_label(), Some(label), "{cmd}"); + } +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index f5f9dd710..5fd7e5c97 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -125,10 +125,10 @@ pub(crate) fn fix_view(st: &mut DocState) { match v.target { ChannelTarget::Alpha(i) if i >= n => v.target = ChannelTarget::Composite, ChannelTarget::Color(k) if k >= colors || colors < 2 => v.target = ChannelTarget::Composite, - ChannelTarget::LayerMask if !st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()) => { + ChannelTarget::LayerMask if st.active_layer.and_then(|id| st.doc.layer(id)).is_none_or(|l| l.mask.is_none()) => { v.target = ChannelTarget::Composite; } - ChannelTarget::VectorMask if !st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.vector_mask.is_some()) => { + ChannelTarget::VectorMask if st.active_layer.and_then(|id| st.doc.layer(id)).is_none_or(|l| l.vector_mask.is_none()) => { v.target = ChannelTarget::Composite; } _ => {} @@ -359,8 +359,19 @@ fn ref_planes(doc: &Document, layer: Option, active: Option, r s.read_rgba_into(area, &mut px); vec![px.iter().map(|p| p[3]).collect()] } - // Adjustment and fill layers cover the whole canvas. - None => vec![vec![1.0; area.width() as usize * area.height() as usize]], + // A shape can have no pixel cache yet (for example after loading a document). + // Use its existing renderer rather than treating it as a canvas-wide fill. + None => match &l.content { + LayerContent::Shape(shape) => vec![ + photocraft_vector::CompiledShape::new(shape, photocraft_vector::DEFAULT_TOLERANCE, area) + .render_rgba(area) + .into_iter() + .map(|p| p[3]) + .collect(), + ], + // Adjustment and fill layers cover the whole canvas. + _ => vec![vec![1.0; area.width() as usize * area.height() as usize]], + }, } } ChanRef::LayerMask => { diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 16b2b4294..1223c0589 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -740,3 +740,27 @@ fn clear_with_an_alpha_channel_or_quick_mask_targeted_fills_it_not_the_layer() { assert_eq!(q, 0.0); assert_eq!(px(&s, 5, 5), layer, "the layer is untouched in Quick Mask mode"); } + +#[test] +fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":100,"height":80,"depth":depth,"background":"transparent"})).unwrap(); + let id = s.execute("shape.create", json!({"kind":"rect","rect":[20,10,40,30],"fill":"#cc3333"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("Drop render cache", |doc, _| { + let l = doc.layer_mut(photocraft_doc::LayerId(id)).unwrap(); + if let LayerContent::Shape(shape) = &mut l.content { + shape.cache = None; + } + l.visible = false; + l.opacity = 0.25; + Ok(()) + }) + .unwrap(); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":id})).unwrap(); + assert!(sel_at(&s, 30, 20) > 0.99); + assert!(sel_at(&s, 0, 0) < 0.01, "shape transparency must not select the whole canvas at depth {depth}"); + let b = s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (20, 10, 40, 30)); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index f964c620c..f12e017c4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -256,8 +256,19 @@ pub fn blend_from_str(s: &str) -> Option { /// The active selection as the mask of a layer being created, or None without a selection. /// Photoshop masks a new adjustment or fill layer with the selection, so the adjustment acts on /// the selected area only (#1250); the selection itself stays, as Reveal Selection leaves it. -pub(crate) fn selection_mask(doc: &Document) -> Option { - doc.selection.as_ref().map(|sel| LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }) +pub(crate) fn selection_mask(doc: &Document) -> Result> { + doc.selection.as_ref().map(|sel| mask_at_depth(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }, doc.depth)).transpose() +} + +/// New pixel masks use the document's depth, as PSD saves/imports do. Otherwise a later soft +/// brush stroke quantizes differently depending on whether the document reopened as PSD or +/// native. Converting a selection keeps its coverage and does not alter the selection itself. +fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { + let format = mask.surface.format().with_sample(depth); + if mask.surface.format() != format { + mask.surface = mask.surface.try_convert(format)?; + } + Ok(mask) } /// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the @@ -276,7 +287,7 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => l.mask = Some(selection_mask(doc).unwrap_or_else(LayerMask::reveal_all)), + None => l.mask = Some(mask_at_depth(selection_mask(doc)?.unwrap_or_else(LayerMask::reveal_all), doc.depth)?), } Ok(()) } @@ -553,17 +564,21 @@ fn build() -> Vec { "Duplicate Layer…", ["Layer"], None, - r##"{"layer":id?,"inPlace":bool=false (artboards: keep the copy on the original instead of beside it)} (no layer: every selected layer)"##, + r##"{"layer":id?,"inPlace":bool=false (artboards: keep the copy on the original instead of beside it),"name":str? (one layer: the copy's name)} (no layer: every selected layer)"##, has_layer, |s, p| { let in_place = p.get("inPlace").and_then(Value::as_bool).unwrap_or(false); + let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string); if crate::layer_multi_cmds::multi(s, p) { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let nid = s.edit("Duplicate Layer", |doc, active| { - let dup = layer_copy(doc, id)?; + let mut dup = layer_copy(doc, id)?; + if let Some(name) = &name { + dup.name.clone_from(name); + } let nid = doc.insert_above(Some(id), dup); if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; @@ -903,7 +918,7 @@ fn build() -> Vec { "Brush Stroke", [], None, - r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, crate::brush_cmds::paint_stroke ), @@ -1339,6 +1354,7 @@ fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { fn set_mask(s: &mut Session, p: &Value, label: &str, mask: Option) -> 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()?; for id in &ids { if mask.is_some() { crate::extra_cmds::background_to_layer_for_mask(doc, *id); diff --git a/crates/engine/src/document_preset_cmds.rs b/crates/engine/src/document_preset_cmds.rs index f0a8f6dba..0bb409b81 100644 --- a/crates/engine/src/document_preset_cmds.rs +++ b/crates/engine/src/document_preset_cmds.rs @@ -15,6 +15,8 @@ const _: () = assert!(photocraft_format::MAX_CANVAS_SIDE >= MAX_DIMENSION && photocraft_format::MAX_CANVAS_PIXELS >= MAX_DIMENSION as u64 * MAX_DIMENSION as u64); pub(crate) const MAX_RESOLUTION: f64 = 30_000.0; const MAX_PRESETS: usize = 256; +/// File › New › Recent keeps this many settings, like Photoshop's Recent tab. +pub const MAX_RECENT: usize = 20; /// The formats accepted by `file.new`, also used when validating saved configurations. pub(crate) fn color_mode(mode: &str) -> Option { @@ -205,6 +207,39 @@ pub(crate) fn load(value: Value) -> Vec { presets } +/// Read the Recent list like [`load`]: a damaged entry is dropped, the rest kept, newest first. +pub(crate) fn load_recent(value: Value) -> Vec { + let Some(entries) = value.as_array() else { return Vec::new() }; + let mut recent: Vec = Vec::new(); + for entry in entries { + let Ok(mut settings) = serde_json::from_value::(entry.clone()) else { continue }; + if settings.mode == "grayscale" { + settings.mode = "gray".into(); + } + if settings.validate().is_err() || recent.iter().any(|r| r.command_params() == settings.command_params()) { + continue; + } + recent.push(settings); + if recent.len() >= MAX_RECENT { + break; + } + } + recent +} + +/// Record settings a document was just created with: first in Recent, an equal older entry +/// (the same document, perhaps shown in other units) removed, the oldest dropped past the cap. +fn add_recent(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "document.recent.add"; + let settings = DocumentSettings::from_params(s, p.get("settings").unwrap_or(&Value::Null), CMD)?; + let recent = &mut s.presets.recent_documents; + recent.retain(|r| r.command_params() != settings.command_params()); + recent.insert(0, settings); + recent.truncate(MAX_RECENT); + s.presets_changed(); + Ok(json!({"recent":s.presets.recent_documents})) +} + fn save(s: &mut Session, p: &Value) -> Result { const CMD: &str = "document.presets.save"; let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?; @@ -280,6 +315,27 @@ pub fn specs() -> Vec { run: delete, journal: true, }, + CommandSpec { + id: "document.recent.list", + label: "List Recent Document Settings", + menu: &[], + shortcut: None, + params: "{} -> {recent:[settings]} (newest first; each settings.command params minus unit/resolutionUnit go to file.new)", + enabled: always, + run: |s, _| Ok(json!({"recent":s.presets.recent_documents})), + journal: false, + }, + CommandSpec { + id: "document.recent.add", + label: "Add Recent Document Settings", + menu: &[], + shortcut: None, + params: r#"{"settings":{same fields as document.presets.save}} (File › New records each document it creates; newest first, max 20, duplicates collapsed)"#, + enabled: always, + run: add_recent, + // Bookkeeping for File › New, not an editing step an action should replay. + journal: false, + }, ] } @@ -451,6 +507,36 @@ mod tests { assert_eq!(s.prefs.rev(), rev); } + #[test] + fn recent_lists_created_settings_newest_first_capped_and_survives_reload() { + let mut s = Session::new(); + let add = |s: &mut Session, w: u32, unit: &str| s.execute("document.recent.add", json!({"settings":{"width":w,"height":10,"unit":unit}})); + for w in 1..=MAX_RECENT as u32 + 5 { + add(&mut s, w, "px").unwrap(); + } + // The same document again (shown in other units) moves to the front instead of repeating. + add(&mut s, 10, "mm").unwrap(); + let mut s = reload(&s); + let recent = s.execute("document.recent.list", json!({})).unwrap()["recent"].clone(); + let widths: Vec = recent.as_array().unwrap().iter().map(|r| r["width"].as_u64().unwrap()).collect(); + assert_eq!(widths.len(), MAX_RECENT); + assert_eq!(&widths[..3], &[10, 25, 24]); + assert_eq!(widths.iter().filter(|w| **w == 10).count(), 1); + assert_eq!(recent[0]["unit"], "mm"); + // Bad settings change nothing; damaged stored entries are dropped, the rest kept. + let rev = s.prefs.rev(); + for bad in [json!({}), json!({"settings":{"width":0,"height":1}}), json!({"settings":"x"}), Value::Null] { + assert!(s.execute("document.recent.add", bad).is_err()); + } + assert_eq!(s.prefs.rev(), rev); + let mut value = s.prefs_value(); + value["presets"]["recent_documents"] = json!([{"width":-1}, recent[1], "junk", recent[1], recent[2]]); + let mut next = Session::new(); + next.load_prefs_json(&value.to_string()).unwrap(); + assert_eq!(next.presets.recent_documents.iter().map(|r| r.width).collect::>(), [25, 24]); + assert!(next.documents().is_empty(), "recording never creates a document"); + } + #[test] fn invalid_explicit_background_colour_does_not_create_a_document() { let mut s = Session::new(); diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index ce172ea27..f99a81a64 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -32,8 +32,8 @@ pub struct Clip { /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) -/// (#2670), so Paste recreates it instead of its pixels. Pixel layers, and several selected -/// layers, still copy the active layer's pixels. +/// (#2670), so Paste recreates it instead of its pixels. Pixel layers still copy their pixels; +/// several selected layers are [`multi_copy_ids`]. fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { d.doc.selection.is_none() && d.selected_layers().len() <= 1 @@ -43,19 +43,54 @@ fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { .is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_))) } -/// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour -/// holding it. -fn whole_layer(d: &crate::DocState, id: LayerId) -> Option> { - let l = d.doc.layer(id).filter(|_| takes_whole(d, id))?; - let doc = &d.doc; +/// A scratch document in the source's colour, to hold copied layers. +fn scratch_doc(doc: &Document) -> Document { let mut copies = Document::new("", doc.size, doc.mode, doc.depth); copies.icc_profile = doc.icc_profile.clone(); copies.resolution_dpi = doc.resolution_dpi; copies.patterns = doc.patterns.clone(); + copies +} + +/// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour +/// holding it. +fn whole_layer(d: &crate::DocState, id: LayerId) -> Option> { + let l = d.doc.layer(id).filter(|_| takes_whole(d, id))?; + let mut copies = scratch_doc(&d.doc); copies.layers.push(l.clone()); Some(Box::new(copies)) } +/// The layers Copy takes when several are selected and nothing is selected in the pixels +/// (Photoshop, #2478): the selected layers that no selected group already carries, bottom-to-top. +/// Empty otherwise, and for a single layer ([`takes_whole`] decides that case). +fn multi_copy_ids(d: &crate::DocState) -> Vec { + if d.doc.selection.is_some() || !crate::layer_copy_cmds::layered(d.doc.mode) { + return Vec::new(); + } + let selected = d.selected_layers(); + if selected.len() < 2 { + return Vec::new(); + } + crate::layer_multi_cmds::top_level(&d.doc, &selected) +} + +/// Every selected layer, whole ([`multi_copy_ids`]), in a scratch document: Paste recreates them +/// all, each with its mask, styles and content. The Background becomes an ordinary layer, as in +/// Photoshop's Duplicate Layer. +fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option> { + let mut copies = scratch_doc(&d.doc); + for id in ids { + let mut l = d.doc.layer(*id)?.clone(); + if crate::extra_cmds::is_background(&l) { + l.name = "Layer 0".into(); + l.locks = Default::default(); + } + copies.layers.push(l); + } + Some(Box::new(copies)) +} + fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } @@ -88,7 +123,7 @@ fn has_layer_pixels(s: &Session) -> std::result::Result<(), String> { /// Copy: a targeted alpha channel (#2307), a layer with pixels, or one taken whole (a group with /// no selection, #2670). fn can_copy(s: &Session) -> std::result::Result<(), String> { - if crate::channel_clip::copies_alpha_channel(s) { + if crate::channel_clip::copies_alpha_channel(s) || s.active().is_some_and(|d| !multi_copy_ids(d).is_empty()) { return Ok(()); } has_layer_pixels(s).or_else(|e| s.active().and_then(|d| d.active_layer.filter(|id| takes_whole(d, *id))).map(|_| ()).ok_or(e)) @@ -167,8 +202,9 @@ fn copy(s: &mut Session, merged: bool) -> Result { lift(&merged_surface(&d.doc), d.doc.selection.as_ref(), canvas)? } else { let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?; - let layers = whole_layer(d, id); - match (d.doc.layer(id).and_then(Layer::surface), &layers) { + let several = multi_copy_ids(d); + let layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) }; + match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) { (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, // A group has no pixels of its own: the clipboard pixels are what it shows. (None, Some(copies)) => { @@ -639,10 +675,65 @@ fn transform_again_params(s: &Session) -> Result { Ok(p) } +/// Guides further than this from the canvas origin are refused: far past any document (300000 px) +/// yet well within f32 precision. +const MAX_GUIDE_PX: f64 = 1_000_000.0; + +/// A guide `position` in document pixels: a number of pixels, or text with a unit as in Photoshop's +/// New Guide dialog ("50%", "2.5 cm", "12 pt"). Percent is of the document's width for a vertical +/// guide and its height for a horizontal one; points and picas follow Units & Rulers › Point Size. +/// `None` when there is no `position`. +fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result> { + let Some(v) = p.get("position") else { return Ok(None) }; + let px = match (v.as_f64(), v.as_str()) { + (Some(n), _) => n, + (None, Some(text)) => { + let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| { + EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %")) + })?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let extent = if vertical { doc.size.width } else { doc.size.height }; + unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch()) + } + _ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())), + }; + if !px.is_finite() || px.abs() > MAX_GUIDE_PX { + return Err(EngineError::Other(format!("`position` is out of range (at most {MAX_GUIDE_PX} px from the canvas origin)"))); + } + Ok(Some(px)) +} + +/// Photoshop's guide colours, in its order, by the names `view.newGuide` takes: New Guide's and +/// New Guide Layout's Color lists. Cyan (the default guide colour in Preferences, so a guide +/// dragged from a ruler reads as Cyan), Magenta and Yellow are the values New Guide Layout first +/// shipped with; the rest approximate Photoshop's on screen, rounded to web-safe steps. +pub const GUIDE_COLORS: [(&str, [u8; 3]); 9] = [ + ("lightBlue", [0x33, 0x66, 0xff]), + ("lightRed", [0xff, 0x33, 0x33]), + ("green", [0x33, 0xff, 0x33]), + ("mediumBlue", [0x33, 0x33, 0xff]), + ("yellow", [0xff, 0xcc, 0x00]), + ("magenta", [0xff, 0x00, 0xff]), + ("cyan", [0x4a, 0xff, 0xff]), + ("lightGray", [0xe7, 0xe7, 0xe7]), + ("black", [0x00, 0x00, 0x00]), +]; + +/// A guide `color`: one of [`GUIDE_COLORS`]' names or `"#rrggbb"`. `None` when there is no `color` +/// (the guide uses Preferences › Guides, Grid & Slices › Guide colour). +fn guide_color(p: &Value) -> Result> { + let Some(v) = p.get("color") else { return Ok(None) }; + let text = v.as_str().ok_or(EngineError::Other("`color` must be a colour name or \"#rrggbb\"".into()))?; + GUIDE_COLORS.iter().find(|(name, ..)| name.eq_ignore_ascii_case(text)).map(|(.., rgb)| *rgb).or_else(|| crate::prefs::parse_hex(text)).map(Some).ok_or_else( + || EngineError::Other(format!("`color` {text:?} is not a guide colour: use \"#rrggbb\" or one of {}", GUIDE_COLORS.map(|c| c.0).join(", "))), + ) +} + /// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states). fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical"; - let pos = p.get("position").and_then(Value::as_f64); + let pos = guide_position(s, p, vertical)?; + let color = if op == "new" { guide_color(p)? } else { None }; let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize); let label = match op { "new" => "New Guide", @@ -651,20 +742,35 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { _ => "Clear Guides", }; s.edit(label, |doc, _| { - let g = if vertical { &mut doc.guides.vertical } else { &mut doc.guides.horizontal }; + let guides = &mut doc.guides; + let g = if vertical { &mut guides.vertical } else { &mut guides.horizontal }; match op { - "new" => g.push(pos.ok_or(EngineError::Other("missing `position`".into()))?), + "new" => { + let at = pos.ok_or(EngineError::Other("missing `position`".into()))?; + g.push(at); + if color.is_some() { + guides.set_color(vertical, at, color); + } + } "move" => { let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?; - g[i] = pos.ok_or(EngineError::Other("missing `position`".into()))?; + let to = pos.ok_or(EngineError::Other("missing `position`".into()))?; + let from = std::mem::replace(&mut g[i], to); + // The guide takes its colour along; another guide left at the old position keeps it. + if let Some(rgb) = guides.color(vertical, from) { + guides.set_color(vertical, to, Some(rgb)); + } + guides.prune_colors(); } "delete" => { let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?; g.remove(i); + guides.prune_colors(); } _ => { - doc.guides.horizontal.clear(); - doc.guides.vertical.clear(); + guides.horizontal.clear(); + guides.vertical.clear(); + guides.colors.clear(); } } Ok(()) @@ -758,12 +864,24 @@ pub fn specs() -> Vec { has_doc, |s, _| transform_again_copy(s) ), - spec!("view.newGuide", "New Guide…", &["View"], None, r##"{"orientation":"horizontal|vertical","position":px}"##, has_doc, |s, p| guide_cmd( - s, p, "new" - )), - spec!("view.moveGuide", "Move Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n,"position":px}"##, has_doc, |s, p| guide_cmd( - s, p, "move" - )), + spec!( + "view.newGuide", + "New Guide…", + &["View"], + None, + r##"{"orientation":"horizontal|vertical","position":px or "","color":"lightBlue|lightRed|green|mediumBlue|yellow|magenta|cyan|lightGray|black" or "#rrggbb"?} (units px, in, cm, mm, pt, pc or %; % is of the width for a vertical guide, the height for a horizontal one; without a color the guide uses the guide colour in Preferences)"##, + has_doc, + |s, p| guide_cmd(s, p, "new") + ), + spec!( + "view.moveGuide", + "Move Guide", + &[], + None, + r##"{"orientation":"horizontal|vertical","index":n,"position":px or ""} (units as for view.newGuide)"##, + has_doc, + |s, p| guide_cmd(s, p, "move") + ), spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")), spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")), ] @@ -1399,6 +1517,103 @@ mod tests { assert_eq!(s.active().unwrap().doc.layers.len(), 4); } + /// #2478: with several layers selected and no pixel selection, Copy takes every one of them + /// and Paste recreates them all, in order, as one undo step; with a pixel selection Copy still + /// takes the active layer's selected pixels only. + #[test] + fn copy_and_paste_several_selected_layers() { + let mut s = session(); + let a = s.active().unwrap().active_layer.unwrap(); + let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(b).unwrap().surface_mut().unwrap().fill_rect(Rect::new(60, 60, 80, 90), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap(); + assert!(s.is_enabled("edit.copy")); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 70, 80]), "both layers' pixels"); + let before = s.active().unwrap().doc.layers.len(); + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), before + 2, "every selected layer is pasted"); + assert_eq!(st.history.past_len(), steps + 1, "one undo step"); + let n = st.doc.layers.len(); + let (pa, pb) = (st.doc.layers[n - 2].surface().unwrap(), st.doc.layers[n - 1].surface().unwrap()); + assert_eq!(pa.content_bounds(), Rect::new(10, 10, 50, 30), "the lower layer first"); + assert_eq!(pb.content_bounds(), Rect::new(60, 60, 80, 90)); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), before); + + // A pixel selection takes the active layer's selected pixels, as a single layer. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 30})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), before + 1); + } + + /// #2478, #3588: several layers copied in one document paste into another, in that document's + /// bit depth and with their own pixels, in order, as one undo step. + #[test] + fn paste_several_layers_into_another_document() { + for depth in [16, 32] { + let mut s = session(); + let a = s.active().unwrap().active_layer.unwrap(); + let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(b).unwrap().surface_mut().unwrap().fill_rect(Rect::new(60, 60, 80, 90), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + + s.execute("file.new", json!({"width": 120, "height": 120, "depth": depth})).unwrap(); + assert_eq!(s.active_index().unwrap(), 1, "the new document is the active tab"); + let (before, steps) = (s.active().unwrap().doc.layers.len(), s.active().unwrap().history.past_len()); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), before + 2, "{depth}-bit: both layers arrive"); + assert_eq!(st.history.past_len(), steps + 1, "{depth}-bit: one undo step"); + let n = st.doc.layers.len(); + let (pa, pb) = (st.doc.layers[n - 2].surface().unwrap(), st.doc.layers[n - 1].surface().unwrap()); + assert_eq!(pa.format().sample, st.doc.depth, "{depth}-bit: converted to the target depth"); + assert_eq!(pb.format().sample, st.doc.depth, "{depth}-bit: converted to the target depth"); + assert_eq!(pa.content_bounds(), Rect::new(10, 10, 50, 30), "{depth}-bit: the lower layer first"); + assert_eq!(pb.content_bounds(), Rect::new(60, 60, 80, 90)); + let (mut red, mut blue) = ([[0.0f32; 4]; 1], [[0.0f32; 4]; 1]); + pa.read_rgba_into(Rect::new(20, 20, 21, 21), &mut red); + pb.read_rgba_into(Rect::new(70, 70, 71, 71), &mut blue); + assert!(red[0][0] > 0.9 && red[0][1] < 0.1 && red[0][2] < 0.1, "{depth}-bit: red kept: {:?}", red[0]); + assert!(blue[0][2] > 0.9 && blue[0][0] < 0.1 && blue[0][1] < 0.1, "{depth}-bit: blue kept: {:?}", blue[0]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), before, "{depth}-bit: undo removes both"); + assert_eq!(s.documents().len(), 2, "the source document is untouched"); + } + } + + /// #2478: the Background in a multi-layer copy pastes as an ordinary, unlocked layer. + #[test] + fn copying_the_background_with_another_layer_pastes_an_ordinary_layer() { + let mut s = session(); + let a = s.active().unwrap().active_layer.unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": bg.0, "mode": "toggle"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), 4); + let pasted = &st.doc.layers[st.doc.layers.len() - 2]; + assert!(!crate::extra_cmds::is_background(pasted)); + assert_eq!(pasted.locks, Default::default()); + assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched"); + } + /// #2428: a smart object copied in one document pastes into another as a smart object, in /// that document's bit depth. #[test] @@ -1554,6 +1769,123 @@ mod tests { assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err()); } + /// The last guide added in `orientation` for `position`. + fn new_guide(s: &mut Session, orientation: &str, position: Value) -> f64 { + s.execute("view.newGuide", json!({"orientation": orientation, "position": position})).unwrap(); + let g = &s.active().unwrap().doc.guides; + *if orientation == "vertical" { &g.vertical } else { &g.horizontal }.last().unwrap() + } + + #[test] + fn guide_positions_take_photoshop_units() { + let mut s = Session::new(); + // 4 × 2 inches at 100 ppi. + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); + // Percent follows the guide's own axis: the width for a vertical guide, the height for a horizontal one. + assert_eq!(new_guide(&mut s, "vertical", json!("50%")), 200.0); + assert_eq!(new_guide(&mut s, "horizontal", json!("50%")), 100.0); + for one_inch in ["1in", "1 in", "2.54cm", "25.4 mm", "72pt", "6pc", "100px", "100", "1,0 inch"] { + assert!((new_guide(&mut s, "vertical", json!(one_inch)) - 100.0).abs() < 1e-3, "{one_inch}"); + } + // Fractional and negative positions are kept; a plain number is pixels, as before. + assert_eq!(new_guide(&mut s, "horizontal", json!("12.5px")), 12.5); + assert_eq!(new_guide(&mut s, "vertical", json!("-10px")), -10.0); + assert_eq!(new_guide(&mut s, "vertical", json!(40)), 40.0); + // Points and picas follow Units & Rulers › Point Size. + s.execute("prefs.set", json!({"path": "unitsAndRulers.pointSize", "value": "traditional"})).unwrap(); + assert!((new_guide(&mut s, "vertical", json!("72.27pt")) - 100.0).abs() < 1e-3); + // A guide moved with a unit lands where a new one would. + s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": "25%"})).unwrap(); + assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0); + } + + #[test] + fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200})).unwrap(); + let color = |s: &Session, vertical: bool, p: f64| s.active().unwrap().doc.guides.color(vertical, p); + // Without a colour a guide uses the preference colour, as ruler-drag guides always did. + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + assert_eq!(color(&s, true, 10.0), None); + assert!(s.active().unwrap().doc.guides.colors.is_empty()); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 20, "color": "#123456"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30, "color": "LightBlue"})).unwrap(); + assert_eq!(color(&s, true, 20.0), Some([0xff, 0x00, 0xff])); + assert_eq!(color(&s, false, 20.0), Some([0x12, 0x34, 0x56]), "the same position across is a different guide"); + assert_eq!(color(&s, true, 30.0), Some([0x33, 0x66, 0xff]), "names are case-insensitive"); + // Moving takes the colour along. + s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 1, "position": 25})).unwrap(); + assert_eq!((color(&s, true, 20.0), color(&s, true, 25.0)), (None, Some([0xff, 0x00, 0xff]))); + // Undo restores it where it was. + s.undo(); + assert_eq!((color(&s, true, 20.0), color(&s, true, 25.0)), (Some([0xff, 0x00, 0xff]), None)); + // Deleting drops it; clearing drops them all. + s.execute("view.deleteGuide", json!({"orientation": "vertical", "index": 2})).unwrap(); + assert_eq!(color(&s, true, 30.0), None); + assert_eq!(s.active().unwrap().doc.guides.colors.len(), 2); + s.execute("view.clearGuides", json!({})).unwrap(); + assert!(s.active().unwrap().doc.guides.colors.is_empty()); + } + + #[test] + fn moving_one_of_two_coloured_guides_at_one_position_leaves_the_other_its_colour() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200})).unwrap(); + for _ in 0..2 { + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 50, "color": "green"})).unwrap(); + } + s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 60})).unwrap(); + let g = &s.active().unwrap().doc.guides; + assert_eq!((g.color(true, 50.0), g.color(true, 60.0)), (Some([0x33, 0xff, 0x33]), Some([0x33, 0xff, 0x33]))); + } + + #[test] + fn cyan_is_the_default_guide_colour_so_ruler_guides_read_as_cyan() { + let default = crate::prefs::Preferences::default().guides_grid_and_slices.guide_color; + let cyan = GUIDE_COLORS.iter().find(|(name, ..)| *name == "cyan").map(|(.., rgb)| *rgb); + assert_eq!(cyan, crate::prefs::parse_hex(&default)); + } + + #[test] + fn bad_guide_colours_are_refused_without_a_guide() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200})).unwrap(); + let undo_before = s.active().unwrap().history.can_undo(); + for bad in [json!("puce"), json!("#12345"), json!("#gggggg"), json!(""), json!(7), json!(null), json!(["cyan"])] { + assert!(s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": bad.clone()})).is_err(), "{bad}"); + } + let g = &s.active().unwrap().doc.guides; + assert!(g.vertical.is_empty() && g.colors.is_empty()); + assert_eq!(s.active().unwrap().history.can_undo(), undo_before); + } + + #[test] + fn bad_guide_positions_are_refused_without_a_guide() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); + let undo_before = s.active().unwrap().history.can_undo(); + for bad in [ + json!("abc"), + json!("50 furlongs"), + json!(""), + json!("%"), + json!("NaN"), + json!("inf"), + json!("1e400"), + json!("2000000px"), + json!(1e300), + json!(true), + json!([1]), + ] { + assert!(s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad.clone()})).is_err(), "{bad}"); + } + assert!(s.execute("view.newGuide", json!({"orientation": "vertical"})).is_err(), "no position"); + let g = &s.active().unwrap().doc.guides; + assert!(g.vertical.is_empty() && g.horizontal.is_empty()); + assert_eq!(s.active().unwrap().history.can_undo(), undo_before, "a refused guide records no history step"); + } + /// Guides keep their exact place (#2918): 1 cm at 300 ppi through New Guide and .pcraft, and /// a 1/32 px PSD position past 2^19 px, where f32 has no 1/32 steps left. #[test] diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index e289e43d6..103a5edc6 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -347,7 +347,7 @@ fn delete_and_fill(s: &mut Session, _: &Value) -> Result { } fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { - use photocraft_algo::content_aware::{FillOptions, auto_sampling, color_level, fill_with, rotation_level}; + use photocraft_algo::content_aware::{FillOptions, auto_sampling_with, color_level, fill_owned, rotation_level}; let id = pixel_layer(s).map_err(EngineError::Other)?; let st = s.active().ok_or(EngineError::NoDocument)?; let doc = st.doc.clone(); @@ -401,61 +401,90 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & label, true, move |ctx| { + use rayon::prelude::*; + + use crate::fill_cmds::{read_window, read_window_cover, read_window_mask}; ctx.progress(0.0, label); + let cancel = || ctx.cancelled(); let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; - let img = surf.read_region(window); - let mut cover = vec![0.0f32; w * h]; - let mut hole = vec![false; w * h]; - let mut source = vec![true; w * h]; - for y in 0..h { - if y % 64 == 0 { - ctx.check()?; - } - for x in 0..w { - let (dx, dy) = (window.x0 + x as i32, window.y0 + y as i32); - let i = y * w + x; - cover[i] = sel.sample_channel(dx, dy, 0); - hole[i] = cover[i] > 0.0; - if sampling == "custom" { - source[i] = match (custom_rect, custom_mask.as_ref()) { - (Some(r), _) => r.contains(dx, dy), - (None, Some(m)) => m.sample_channel(dx, dy, 0) > 0.5, - _ => true, - }; - } - } - } + let img = read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)?; + let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?; + let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) { + ("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(), + ("custom", None, Some(m)) => read_window_mask(m, window, &cancel).ok_or(EngineError::Cancelled)?.par_iter().map(|v| *v > 0.5).collect(), + _ => vec![true; w * h], + }; ctx.check()?; // Auto: only what looks like the selection's surroundings. if sampling == "auto" { - source = auto_sampling(w, h, n, &img, &hole); + source = auto_sampling_with(w, h, n, &img, &hole, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|_| EngineError::Cancelled)?; } ctx.check()?; - let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_with(w, h, n, &img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; - Ok((img, cover, hole, filled)) + // The fill changes only the selected pixels: keep the original and the coverage over + // their bounds for the apply step, and fill the window's pixels themselves. + let (bx0, by0, bx1, by1) = mask_bounds(w, h, &hole); + let original: Vec = (by0..by1).flat_map(|y| img[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect(); + let at = |x: usize, y: usize| (window.x0 + x as i32, window.y0 + y as i32); + let ((x0, y0), (x1, y1)) = (at(bx0, by0), at(bx1, by1)); + let cover = read_window_mask(&sel, Rect::new(x0, y0, x1, y1), &cancel).ok_or(EngineError::Cancelled)?; + let n_hole = cover.iter().filter(|c| **c > 0.0).count(); + let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; + Ok(FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole }) }, - move |s, (img, cover, hole, filled)| apply_content_aware_fill(s, label, id, &output, fmt, window, &img, &cover, &hole, &filled), + move |s, done| apply_content_aware_fill(s, label, id, &output, fmt, window, done), ) } +/// A Content-Aware Fill's result for its apply step: the filled window (the same as the +/// original outside the selected pixels) and, over the bounds of the selected pixels (in window +/// coordinates), the original pixels and the selection's coverage. +struct FilledWindow { + filled: Vec, + bounds: (usize, usize, usize, usize), + original: Vec, + cover: Vec, + n_hole: usize, +} + +/// `f(window index, index over the bounds, coverage)` for each selected pixel of a +/// [`FilledWindow`] (`w` wide). +fn each_selected(w: usize, (bx0, by0, bx1, by1): (usize, usize, usize, usize), cover: &[f32], mut f: impl FnMut(usize, usize, f32)) { + let bw = bx1.saturating_sub(bx0); + for y in by0..by1 { + for x in bx0..bx1 { + let k = (y - by0) * bw + (x - bx0); + if let Some(&c) = cover.get(k) + && c > 0.0 + { + f(y * w + x, k, c); + } + } + } +} + +/// Bounds `(x0, y0, x1, y1)` of the set cells of a `w × h` mask (all zero when there are none). +fn mask_bounds(w: usize, h: usize, m: &[bool]) -> (usize, usize, usize, usize) { + use rayon::prelude::*; + if w == 0 { + return (0, 0, 0, 0); + } + let rows: Vec> = + m.par_chunks(w).take(h).map(|row| Some((row.iter().position(|v| *v)?, row.iter().rposition(|v| *v)? + 1))).collect(); + let mut b: Option<(usize, usize, usize, usize)> = None; + for (y, span) in rows.into_iter().enumerate() { + if let Some((x0, x1)) = span { + b = Some(b.map_or((x0, y, x1, y + 1), |(bx0, by0, bx1, _)| (bx0.min(x0), by0, bx1.max(x1), y + 1))); + } + } + b.unwrap_or((0, 0, 0, 0)) +} + /// Write a Content-Aware Fill result (the job's apply step): into the layer, a new layer or a /// duplicate, as one undo step. -#[allow(clippy::too_many_arguments)] -fn apply_content_aware_fill( - s: &mut Session, - label: &str, - id: LayerId, - output: &str, - fmt: PixelFormat, - window: Rect, - img: &[f32], - cover: &[f32], - hole: &[bool], - filled: &[f32], -) -> Result { +fn apply_content_aware_fill(s: &mut Session, label: &str, id: LayerId, output: &str, fmt: PixelFormat, window: Rect, done: FilledWindow) -> Result { let n = fmt.channels(); let (w, h) = (window.width() as usize, window.height() as usize); - let n_hole = hole.iter().filter(|h| **h).count(); + let FilledWindow { mut filled, bounds, original, cover, n_hole } = done; let layer = s.edit(label, |doc, active| { let target = match output { "new" => { @@ -464,14 +493,12 @@ fn apply_content_aware_fill( let lf = l.surface().map(|s| s.format()).unwrap_or(fmt); let ln = lf.channels(); px.resize(w * h * ln, 0.0); - for i in 0..w * h { - if hole[i] { - let rgba = to_rgba(&fmt, &filled[i * n..(i + 1) * n]); - let mut enc = [0.0f32; 8]; - from_rgba_into(&lf, [rgba[0], rgba[1], rgba[2], rgba[3] * cover[i]], &mut enc); - px[i * ln..(i + 1) * ln].copy_from_slice(&enc[..ln]); - } - } + each_selected(w, bounds, &cover, |i, _, c| { + let rgba = to_rgba(&fmt, &filled[i * n..(i + 1) * n]); + let mut enc = [0.0f32; 8]; + from_rgba_into(&lf, [rgba[0], rgba[1], rgba[2], rgba[3] * c], &mut enc); + px[i * ln..(i + 1) * ln].copy_from_slice(&enc[..ln]); + }); if let Some(s) = l.surface_mut() { s.write_region(window, &px); s.prune(); @@ -490,16 +517,14 @@ fn apply_content_aware_fill( _ => id, }; let surf = writable_surface(doc, target)?; - let mut out = img.to_vec(); - for (i, &k) in cover.iter().enumerate() { - if k > 0.0 { - for c in 0..n { - let j = i * n + c; - out[j] = img[j] + (filled[j] - img[j]) * k; - } + // The fill blended in by the coverage (elsewhere it is the original). + each_selected(w, bounds, &cover, |i, k, c| { + for ch in 0..n { + let (j, o) = (i * n + ch, k * n + ch); + filled[j] = original[o] + (filled[j] - original[o]) * c; } - } - surf.write_region(window, &out); + }); + surf.write_region(window, &filled); Ok(target) })?; Ok(json!({"layer": layer.0, "filled": n_hole, "window": [window.x0, window.y0, window.width(), window.height()]})) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 490a44a9a..6e4bf49f1 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1338,6 +1338,7 @@ fn clear_canvas_guides(s: &mut Session) -> Result { s.edit("Clear Canvas Guides", |doc, _| { doc.guides.vertical.retain(|x| in_x(*x)); doc.guides.horizontal.retain(|y| in_y(*y)); + doc.guides.prune_colors(); Ok(()) })?; Ok(json!({"cleared": n})) diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 1c2048464..a7350dafc 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -246,6 +246,67 @@ pub(crate) fn window_size(window: Rect) -> Result<(usize, usize)> { Ok((w as usize, h as usize)) } +/// [`Surface::read_region`] of `r`, read in parallel bands and asking `cancelled` before each +/// (`None` once it says so): a Content-Aware window can be tens of megapixels. +pub(crate) fn read_window(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option> { + use rayon::prelude::*; + const BAND: usize = 64; + let n = surf.channels(); + let (w, h) = (r.width() as usize, r.height() as usize); + let mut out = vec![0.0f32; w * h * n]; + if out.is_empty() { + return Some(out); + } + // Each worker reads its bands through one buffer of its own. + out.par_chunks_mut(BAND * w * n).enumerate().for_each_init(Vec::new, |buf, (i, chunk)| { + if cancelled() { + return; + } + let y0 = r.y0 + (i * BAND) as i32; + let band = Rect::new(r.x0, y0, r.x1, y0 + (chunk.len() / (w * n)) as i32); + surf.read_region_into(band, buf); + if buf.len() == chunk.len() { + chunk.copy_from_slice(buf); + } + }); + if cancelled() { None } else { Some(out) } +} + +/// Which pixels of `r` a selection (or mask) covers at all: channel 0 above zero, read in +/// parallel bands as [`read_window`] does, without holding the coverage itself. +pub(crate) fn read_window_cover(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option> { + use rayon::prelude::*; + const BAND: usize = 64; + let n = surf.channels().max(1); + let (w, h) = (r.width() as usize, r.height() as usize); + let mut out = vec![false; w * h]; + if out.is_empty() { + return Some(out); + } + out.par_chunks_mut(BAND * w).enumerate().for_each_init(Vec::new, |buf, (i, chunk)| { + if cancelled() { + return; + } + let y0 = r.y0 + (i * BAND) as i32; + surf.read_region_into(Rect::new(r.x0, y0, r.x1, y0 + (chunk.len() / w) as i32), buf); + for (o, px) in chunk.iter_mut().zip(buf.chunks_exact(n)) { + *o = px[0] > 0.0; + } + }); + if cancelled() { None } else { Some(out) } +} + +/// Channel 0 of [`read_window`] (a selection's or an alpha channel's coverage). +pub(crate) fn read_window_mask(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option> { + use rayon::prelude::*; + let n = surf.channels().max(1); + let all = read_window(surf, r, cancelled)?; + if n == 1 { + return Some(all); + } + Some(all.par_chunks_exact(n).map(|px| px[0]).collect()) +} + /// How the source goes onto the target. #[derive(Clone, Copy)] struct Blend { @@ -291,7 +352,7 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf let done = std::sync::Mutex::new(done); tiles.into_par_iter().try_for_each(|(tc, tile)| -> Result<()> { let tr = tc.rect().intersect(&area); - let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() }); + let mask = sel.map(|m| Mask::of(m, tc)); if let Some(s) = &solid && tr == tc.rect() && mask.as_ref().is_none_or(|m| m.full(tr)) @@ -315,21 +376,25 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf } /// The selection's coverage over one tile (its channel 0). -struct Mask<'a> { +pub(crate) struct Mask<'a> { bytes: &'a [u8], fmt: photocraft_color::PixelFormat, origin: Rect, } -impl Mask<'_> { - fn at(&self, x: i32, y: i32) -> f32 { +impl<'a> Mask<'a> { + /// `sel` over the tile at `tc` (its default pixel where it has no tile there). + pub(crate) fn of(sel: &'a Surface, tc: photocraft_geom::TileCoord) -> Self { + Mask { bytes: sel.tile(tc).map_or(sel.default_bytes(), |t| t.bytes()), fmt: sel.format(), origin: tc.rect() } + } + pub(crate) fn at(&self, x: i32, y: i32) -> f32 { let bpp = self.fmt.bytes_per_pixel(); // A default-pixel mask is one pixel long: every position reads it. let i = if self.bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp }; self.bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0)) } /// Fully selected over `r`? - fn full(&self, r: Rect) -> bool { + pub(crate) fn full(&self, r: Rect) -> bool { (r.y0..r.y1).all(|y| (r.x0..r.x1).all(|x| self.at(x, y) >= 1.0)) } } @@ -461,7 +526,7 @@ mod tests { assert!(blacks > 300 && blacks < 900, "{blacks}"); assert!((0..40).all(|x| { let v = surf.rgba(x, 3)[0]; - !v.is_nan() && !(0.01..=0.99).contains(&v) + v.is_finite() && !(0.01..=0.99).contains(&v) })); } diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index ac6005e66..041d31169 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -308,7 +308,7 @@ pub fn new_layer(s: &Session, doc: &Document, p: &Value) -> Result { let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); l.opacity = opacity; l.blend = blend; - l.mask = crate::commands::selection_mask(doc); + l.mask = crate::commands::selection_mask(doc)?; Ok(l) } diff --git a/crates/engine/src/guide_layout.rs b/crates/engine/src/guide_layout.rs index 7c24c05a0..4a4261faf 100644 --- a/crates/engine/src/guide_layout.rs +++ b/crates/engine/src/guide_layout.rs @@ -162,6 +162,9 @@ pub fn calculate(s: &Session, p: &Value) -> Result { } else { guides = Guides::default(); } + // Cleared guides take their colours along, so a new guide at the same place doesn't + // inherit one. + guides.prune_colors(); } for x in &added.vertical { push_unique(&mut guides.vertical, *x)?; diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index d9ca98f96..bb9cb3439 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -2,14 +2,34 @@ //! "Create new document from current state" button). Stepping to a state is Edit › Undo / Redo. //! //! States are indexed like [`photocraft_ops::History::entries`]: 0 is the oldest state still -//! held, the last index is the current document. Snapshots are not implemented. +//! held, the last index is the current document. +//! +//! Snapshots (#2966, the camera button) are named copies of the document that outlive the +//! states: `history.newSnapshot` takes one, `history.selectSnapshot` brings it back as a new, +//! undoable state. They live on [`crate::DocState::snapshots`] for the session only, as in +//! Photoshop, and are addressed by an id that is never reused. Their tiles are shared +//! copy-on-write with the document and its history, and are not counted in the history's pixel +//! budget. +use std::sync::Arc; + +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_ops::LayerTarget; use serde_json::{Value, json}; use crate::commands::{CommandSpec, int}; use crate::image_cmds::reidentify_copied_layers; use crate::{EngineError, Result, Session}; +/// A History panel snapshot: the document as it was when taken, and the layers it targeted. +#[derive(Clone, Debug)] +pub struct Snapshot { + pub id: u64, + pub name: String, + pub doc: Arc, + pub layers: LayerTarget, +} + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -72,6 +92,105 @@ fn new_document(s: &mut Session, p: &Value) -> Result { Ok(json!({ "document": index, "name": name })) } +/// The `snapshot` param: the index of the active document's snapshot with that id. +fn snapshot_index(s: &Session, cmd: &str, p: &Value) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let id = p.get("snapshot").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`snapshot` must be a snapshot id"))?; + st.snapshots.iter().position(|snap| snap.id == id).ok_or_else(|| bad(cmd, format!("no snapshot {id}"))) +} + +/// A non-blank `name` param, trimmed; `None` when absent. +fn name_param(cmd: &str, p: &Value) -> Result> { + match p.get("name") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) if !n.trim().is_empty() => Ok(Some(n.trim().to_string())), + Some(_) => Err(bad(cmd, "`name` must be a non-empty string")), + } +} + +/// History panel › Create new snapshot (the camera button): a named copy of the document, +/// "Snapshot N" by default. `from` is the New Snapshot dialog's From: the full document (default), +/// its layers merged into one, or only the active layer. Not a history state. +fn new_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.newSnapshot"; + let name = name_param(CMD, p)?; + let from = match p.get("from") { + None | Some(Value::Null) => "fullDocument", + Some(Value::String(f)) if matches!(f.as_str(), "fullDocument" | "mergedLayers" | "currentLayer") => f.as_str(), + Some(_) => return Err(bad(CMD, "`from` must be \"fullDocument\", \"mergedLayers\" or \"currentLayer\"")), + }; + let st = s.active().ok_or(EngineError::NoDocument)?; + let one_layer = |doc: &Document, layer: Layer| { + let mut doc = doc.clone(); + let id = layer.id; + doc.layers = vec![layer]; + (Arc::new(doc), LayerTarget { active: Some(id), selected: vec![id] }) + }; + let (doc, layers) = match from { + "mergedLayers" => { + let fmt = st.doc.pixel_format(); + one_layer(&st.doc, Layer::new("Merged Layers", LayerContent::Raster(crate::file_cmds::flattened(&st.doc, fmt)))) + } + "currentLayer" => { + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or_else(|| bad(CMD, "no active layer"))?; + one_layer(&st.doc, layer.clone()) + } + _ => (st.doc.clone(), st.layer_target()), + }; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let id = st.next_snapshot; + st.next_snapshot = id.saturating_add(1); + let name = name.unwrap_or_else(|| format!("Snapshot {id}")); + st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers }); + Ok(json!({ "snapshot": id, "name": name })) +} + +/// Clicking a snapshot: the document returns to it as one new, undoable state named after it. +fn select_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.selectSnapshot"; + let i = snapshot_index(s, CMD, p)?; + // A background job is computing from the current state: it must not move under it. + if s.active_job().is_some() { + return Err(EngineError::Other("the document is busy with a background job".into())); + } + let snap = s.active().and_then(|st| st.snapshots.get(i).cloned()).ok_or(EngineError::NoDocument)?; + s.edit(&snap.name, |doc, active| { + // Keep the identity (colour state, views and caches are keyed by it) and the tab name. + let (id, name) = (doc.id, doc.name.clone()); + *doc = (*snap.doc).clone(); + doc.id = id; + doc.name = name; + *active = snap.layers.active.filter(|l| doc.layer(*l).is_some()).or_else(|| doc.top_layer()); + Ok(()) + })?; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + if st.active_layer == snap.layers.active { + st.selected_layers.clone_from(&snap.layers.selected); + crate::fix_selection(st); + } + Ok(json!({ "snapshot": snap.id, "name": snap.name })) +} + +/// Renaming a snapshot (double-click its name). +fn rename_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.renameSnapshot"; + let i = snapshot_index(s, CMD, p)?; + let name = name_param(CMD, p)?.ok_or_else(|| bad(CMD, "`name` is required"))?; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let snap = st.snapshots.get_mut(i).ok_or_else(|| bad(CMD, "no such snapshot"))?; + snap.name.clone_from(&name); + Ok(json!({ "snapshot": snap.id, "name": name })) +} + +/// Deleting a snapshot; the document and its history are unchanged. +fn delete_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.deleteSnapshot"; + let i = snapshot_index(s, CMD, p)?; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let snap = st.snapshots.remove(i); + Ok(json!({ "snapshot": snap.id, "snapshots": st.snapshots.len() })) +} + fn can_delete(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into()) } @@ -102,6 +221,46 @@ pub fn specs() -> Vec { run: new_document, journal: true, }, + CommandSpec { + id: "history.newSnapshot", + label: "New Snapshot", + menu: &[], + shortcut: None, + params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument"} → {snapshot,name}: a named copy of the document for the session (not a history state)"##, + enabled: has_doc, + run: new_snapshot, + journal: true, + }, + CommandSpec { + id: "history.selectSnapshot", + label: "Select Snapshot", + menu: &[], + shortcut: None, + params: r##"{"snapshot":id} → {snapshot,name}: the document returns to the snapshot as a new history state named after it"##, + enabled: has_doc, + run: select_snapshot, + journal: true, + }, + CommandSpec { + id: "history.renameSnapshot", + label: "Rename Snapshot", + menu: &[], + shortcut: None, + params: r##"{"snapshot":id,"name":str} → {snapshot,name}"##, + enabled: has_doc, + run: rename_snapshot, + journal: true, + }, + CommandSpec { + id: "history.deleteSnapshot", + label: "Delete Snapshot", + menu: &[], + shortcut: None, + params: r##"{"snapshot":id} → {snapshot,snapshots}: the document and its history are unchanged"##, + enabled: has_doc, + run: delete_snapshot, + journal: true, + }, ] } @@ -193,6 +352,114 @@ mod tests { assert_eq!(entries(&s), before); } + /// The top layer's pixel at (1, 1). + fn top_pixel(s: &Session) -> [f32; 4] { + s.active().unwrap().doc.layers.last().unwrap().surface().unwrap().rgba(1, 1) + } + + fn snapshots(s: &Session) -> Value { + crate::inspect::document(s.active().unwrap())["snapshots"].clone() + } + + #[test] + fn new_snapshot_keeps_the_document_and_is_not_a_history_state() { + let mut s = session(); + let before = entries(&s); + let r = s.execute("history.newSnapshot", json!({})).unwrap(); + assert_eq!(r, json!({"snapshot": 1, "name": "Snapshot 1"})); + assert_eq!(entries(&s), before, "taking a snapshot is not a step"); + let r = s.execute("history.newSnapshot", json!({"name": "Before green"})).unwrap(); + assert_eq!(r, json!({"snapshot": 2, "name": "Before green"})); + assert_eq!(snapshots(&s), json!([{"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}])); + } + + #[test] + fn select_snapshot_returns_to_it_as_one_undoable_state() { + let mut s = session(); + s.execute("history.newSnapshot", json!({})).unwrap(); + let green = top_pixel(&s); + let id = s.active().unwrap().doc.id; + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let blue = top_pixel(&s); + let before = entries(&s); + s.execute("history.selectSnapshot", json!({"snapshot": 1})).unwrap(); + assert_eq!(top_pixel(&s), green); + assert_eq!(entries(&s).len(), before.len() + 1, "one new state"); + assert_eq!(entries(&s).last().map(String::as_str), Some("Snapshot 1")); + assert_eq!(s.active().unwrap().doc.id, id, "the same document"); + assert!(s.active().unwrap().active_layer.is_some()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(top_pixel(&s), blue, "undo leaves the snapshot"); + assert_eq!(snapshots(&s).as_array().unwrap().len(), 1, "snapshots are not history"); + } + + #[test] + fn snapshots_from_merged_layers_or_the_current_layer() { + let mut s = session(); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + s.execute("history.newSnapshot", json!({"from": "mergedLayers"})).unwrap(); + s.execute("history.newSnapshot", json!({"from": "currentLayer"})).unwrap(); + s.execute("history.newSnapshot", json!({"from": "fullDocument"})).unwrap(); + s.execute("history.selectSnapshot", json!({"snapshot": 1})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + assert_eq!(doc.layers.len(), 1, "merged into one layer"); + assert_eq!(doc.layers[0].surface().unwrap().rgba(1, 1), [0.0, 1.0, 0.0, 1.0], "the visible result"); + s.execute("history.selectSnapshot", json!({"snapshot": 2})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1, "only the active layer"); + s.execute("history.selectSnapshot", json!({"snapshot": 3})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + } + + #[test] + fn rename_and_delete_snapshots_keep_the_other_ids() { + let mut s = session(); + for _ in 0..3 { + s.execute("history.newSnapshot", json!({})).unwrap(); + } + s.execute("history.renameSnapshot", json!({"snapshot": 2, "name": "Lesson start"})).unwrap(); + s.execute("history.deleteSnapshot", json!({"snapshot": 1})).unwrap(); + assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}])); + let r = s.execute("history.newSnapshot", json!({})).unwrap(); + assert_eq!(r["snapshot"], json!(4), "ids are never reused"); + } + + #[test] + fn snapshot_commands_refuse_bad_params() { + let mut s = Session::new(); + for (cmd, p) in [ + ("history.newSnapshot", json!({})), + ("history.selectSnapshot", json!({"snapshot": 1})), + ("history.renameSnapshot", json!({"snapshot": 1, "name": "x"})), + ("history.deleteSnapshot", json!({"snapshot": 1})), + ] { + assert!(s.execute(cmd, p).is_err(), "{cmd}: no document"); + } + let mut s = session(); + s.execute("history.newSnapshot", json!({})).unwrap(); + let (before, snaps) = (entries(&s), snapshots(&s)); + for (cmd, p) in [ + ("history.newSnapshot", json!({"from": "everything"})), + ("history.newSnapshot", json!({"from": 3})), + ("history.newSnapshot", json!({"name": 7})), + ("history.selectSnapshot", json!({})), + ("history.selectSnapshot", json!({"snapshot": 99})), + ("history.selectSnapshot", json!({"snapshot": -1})), + ("history.selectSnapshot", json!({"snapshot": "1"})), + ("history.selectSnapshot", json!({"snapshot": f64::MAX})), + ("history.renameSnapshot", json!({"snapshot": 1})), + ("history.renameSnapshot", json!({"snapshot": 1, "name": " "})), + ("history.renameSnapshot", json!({"snapshot": 99, "name": "x"})), + ("history.deleteSnapshot", json!({"snapshot": 99})), + ("history.deleteSnapshot", json!({"snapshot": [1]})), + ] { + assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}"); + assert_eq!((entries(&s), snapshots(&s)), (before.clone(), snaps.clone()), "{cmd} {p} changed nothing"); + } + // Current Layer needs an active layer. + s.execute("select.deselectLayers", json!({})).unwrap(); + assert!(s.execute("history.newSnapshot", json!({"from": "currentLayer"})).is_err()); + } + #[test] fn new_document_refuses_bad_params() { let mut s = Session::new(); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 2e926c307..c8055a4d3 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -961,7 +961,7 @@ mod tests { let surf = st.doc.layers[0].surface().unwrap(); for (x, y) in [(0, 0), (1, 1), (2, 3)] { let v = surf.rgba(x, y)[0]; - assert!(!v.is_nan() && !(0.01..=0.99).contains(&v), "nearest produced an averaged value {v} at ({x},{y})"); + assert!(v.is_finite() && !(0.01..=0.99).contains(&v), "nearest produced an averaged value {v} at ({x},{y})"); } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index b5d4bf329..d0b2a72e9 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -27,6 +27,7 @@ pub fn document(d: &DocState) -> Value { let selected = d.selected_layers(); json!({ "name": doc.name, + "path": &d.path, "width": doc.size.width, "height": doc.size.height, "mode": format!("{:?}", doc.mode), @@ -39,6 +40,7 @@ pub fn document(d: &DocState) -> Value { "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)).collect::>(), "history": d.history.entries(), + "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), "canRedo": d.history.can_redo(), "revision": d.revision, diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 6c63a1d9c..42c7fa486 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -639,7 +639,7 @@ impl Session { } // Pixel commands follow the Channels panel target unless the caller names one. let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone())); - if let Err(why) = crate::smart_cmds::target_enabled(self, spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) { + if let Err(why) = self.explicit_layer_enabled(spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) { return Err(EngineError::Disabled(id.to_string(), why)); } if let Some(why) = self.job_conflict(id, spec.journal) { diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index eb2d4d318..29eff4c8d 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -169,6 +169,9 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { } copies.layers.push(l); } + if let (Some(name), [l]) = (p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()), copies.layers.as_mut_slice()) { + l.name = name.to_string(); + } adapt_copies(s, &mut copies, &ddoc)?; // Placement: centred on the destination's canvas, centred on a point, or offset from where // the layers are in the source (default: the same canvas position). @@ -216,7 +219,7 @@ pub fn specs() -> Vec { label: "Copy Layers to Document", menu: &[], shortcut: None, - params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source)}"##, + params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source),"name":str? (one layer: the copy's name)}"##, enabled, run: copy_to_document, journal: true, diff --git a/crates/engine/src/layer_copy_cmds/tests.rs b/crates/engine/src/layer_copy_cmds/tests.rs index cf6902ca7..476df1879 100644 --- a/crates/engine/src/layer_copy_cmds/tests.rs +++ b/crates/engine/src/layer_copy_cmds/tests.rs @@ -217,3 +217,16 @@ fn copied_link_groups_are_distinct_from_each_other_and_destination() { assert!(first.is_some() && second.is_some()); assert_ne!(first, second); } + +#[test] +fn a_named_duplicate_takes_the_name_in_one_step() { + let (mut s, paint) = two_docs(40, 30, 8); + let before = s.active().unwrap().history.past_len(); + let same = LayerId(s.execute("layer.duplicate", json!({"name": "Ink"})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(doc(&s, 0).layer(same).unwrap().name, "Ink"); + assert_eq!(s.active().unwrap().history.past_len(), before + 1); + s.undo(); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + let r = s.execute("layer.copyToDocument", json!({"document": 1, "name": "Ink"})).unwrap(); + assert_eq!(doc(&s, 1).layer(copied(&r)).unwrap().name, "Ink"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 17539ab30..21f3c8adb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -217,6 +217,11 @@ pub struct DocState { /// pixel coordinates. Per-document view state: discarded when the document closes, never saved /// to `.pcraft` or PSD, and not a history step. pub color_samplers: Vec<[f64; 2]>, + /// History panel snapshots, oldest first (`history.newSnapshot`, #2966). Kept for the + /// session only, as in Photoshop: not history states, and not saved with the document. + pub snapshots: Vec, + /// The id the next snapshot gets; ids are never reused, so deleting one keeps the others. + next_snapshot: u64, } impl DocState { @@ -243,6 +248,8 @@ impl DocState { show_only: None, floating: None, color_samplers: Vec::new(), + snapshots: Vec::new(), + next_snapshot: 1, } } /// The selected layers in bottom-to-top document order, always including the active layer. @@ -578,6 +585,26 @@ impl Session { && params.get("layer").and_then(Value::as_u64).is_some_and(|l| self.active().is_some_and(|st| st.doc.layer(LayerId(l)).is_some())) } + /// The precondition of a command whose params document `"layer":id` (it acts on that layer), + /// when `params` name an existing layer of the active document: checked on that layer rather + /// than on the active one, which stays active (#466). A script, action or agent can then + /// Replace Contents, Rasterize or Merge Down a layer without selecting it first. `None` for + /// other commands, or without such a layer. + pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Option> { + if !spec.params.contains("\"layer\":id") { + return None; + } + let target = LayerId(params.get("layer")?.as_u64()?); + let d = self.active_mut()?; + d.doc.layer(target)?; + let active = d.active_layer.replace(target); + let r = (spec.enabled)(self); + if let Some(d) = self.active_mut() { + d.active_layer = active; + } + Some(r) + } + /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { let restrict = self.color_restrict; diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 76f3c6340..323d6ccda 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -2,7 +2,7 @@ use photocraft_compose::{Buffer, adjust}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; -use photocraft_geom::Rect; +use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::{Surface, to_rgba}; /// Composite `layers` inside their visible composite bounds (effects included) and write the result directly into a @@ -130,73 +130,154 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, } /// Fill (respecting selection coverage) with a straight RGBA colour. +/// Works tile by tile in parallel on the encoded pixels; a fully selected tile of an opaque fill +/// that may set alpha becomes one shared solid tile. pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Option<&Surface>, lock_transparency: bool) -> crate::Result<()> { let fmt = s.format(); - let n = fmt.channels(); - let mut region = crate::allocation::read_region(s, area, "filling pixels")?; - fill_region(&mut region, fmt, n, area, color, selection, lock_transparency); - s.try_write_region(area, ®ion) - .map_err(|e| crate::EngineError::Other(format!("not enough memory while writing filled pixels ({e}); the document was not changed"))) + // Coverage 1 of an opaque colour replaces the colour (and the alpha, unless it is kept). + let opaque = color[3] == 1.0 && (!lock_transparency || !fmt.alpha) && selection.is_none_or(|m| m.format().sample != photocraft_color::SampleType::F32); + let solid = opaque.then(|| { + let mut enc = [0.0f32; 8]; + let n = photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc); + s.solid_tile(enc.get(..n).unwrap_or(&[])) + }); + let whole = |full: bool| solid.as_ref().filter(|_| full).map(|t| TileEdit::Put(t.clone())); + edit_tiles(s, area, selection, "filling pixels", whole, |k, px| { + if k <= 0.0 { + return false; + } + fill_pixel(&fmt, px, color, k, lock_transparency); + true + }) } -fn fill_region( - region: &mut [f32], - fmt: photocraft_color::PixelFormat, - n: usize, - area: Rect, - color: [f32; 4], - selection: Option<&Surface>, - lock_transparency: bool, -) { - let w = area.width() as usize; - for (i, px) in region.chunks_exact_mut(n).enumerate() { - let x = area.x0 + (i % w) as i32; - let y = area.y0 + (i / w) as i32; - let k = selection.map_or(1.0, |sel| sel.sample_channel(x, y, 0)); - if k <= 0.0 { - continue; +/// One pixel of [`try_fill_surface`]: `color` over `px` (encoded) at coverage `k`. +fn fill_pixel(fmt: &photocraft_color::PixelFormat, px: &mut [f32], color: [f32; 4], k: f32, lock_transparency: bool) { + let d = to_rgba(fmt, px); + let sa = color[3] * k; + let oa = sa + d[3] * (1.0 - sa); + let mut o = [0.0f32; 4]; + if oa > 0.0 { + for c in 0..3 { + o[c] = (color[c] * sa + d[c] * d[3] * (1.0 - sa)) / oa; } - let d = to_rgba(&fmt, px); - let sa = color[3] * k; - let oa = sa + d[3] * (1.0 - sa); - let mut o = [0.0f32; 4]; - if oa > 0.0 { - for c in 0..3 { - o[c] = (color[c] * sa + d[c] * d[3] * (1.0 - sa)) / oa; - } - } - o[3] = if lock_transparency { d[3] } else { oa }; - let mut enc = [0.0f32; 8]; - photocraft_raster::from_rgba_into(&fmt, o, &mut enc); - px.copy_from_slice(&enc[..n]); + } + o[3] = if lock_transparency { d[3] } else { oa }; + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(fmt, o, &mut enc); + let n = px.len().min(m); + if let (Some(dst), Some(src)) = (px.get_mut(..n), enc.get(..n)) { + dst.copy_from_slice(src); } } /// Clear pixels (make transparent) within the selection. +/// Works tile by tile in parallel on the encoded pixels; a fully selected tile is dropped (it +/// would only hold empty pixels), and tiles the selection leaves alone stay shared with history. pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) -> crate::Result<()> { let fmt = s.format(); let n = fmt.channels(); if !fmt.alpha { return Ok(()); } - let mut region = crate::allocation::read_region(s, area, "erasing pixels")?; - clear_region(&mut region, n, area, selection); - s.try_write_region(area, ®ion) - .map_err(|e| crate::EngineError::Other(format!("not enough memory while erasing pixels ({e}); the document was not changed")))?; + // Cleared pixels become all zeros; when that is also the default pixel, a cleared tile is no tile. + let empty_default = s.default_bytes().iter().all(|&b| b == 0); + let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop); + edit_tiles(s, area, selection, "erasing pixels", whole, |k, px| { + let Some(a) = px.get_mut(n - 1) else { return false }; + *a *= 1.0 - k; + // Fully cleared pixels become the empty pixel, so bounds and tile pruning see them as gone. + if *a <= 0.0 { + px.fill(0.0); + } + true + })?; s.prune(); Ok(()) } -fn clear_region(region: &mut [f32], n: usize, area: Rect, selection: Option<&Surface>) { - let w = area.width() as usize; - for (i, px) in region.chunks_exact_mut(n).enumerate() { - let k = selection.map_or(1.0, |sel| sel.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0)); - px[n - 1] *= 1.0 - k; - // Fully cleared pixels become the empty pixel, so bounds and tile pruning see them as gone. - if px[n - 1] <= 0.0 { - px.fill(0.0); +/// A whole-tile shortcut of [`edit_tiles`]. +enum TileEdit { + /// Replace the tile with this (shared) one. + Put(std::sync::Arc), + /// Remove the tile (it reads as the default pixel again). + Drop, +} + +/// Edit `s` over `area` tile by tile in parallel. `whole` may handle a tile lying wholly inside +/// `area` and fully selected (`true`) in one go; otherwise `pixel` edits each pixel of the area +/// with its selection coverage (decoded channel values in and out; `false` = left untouched). +/// A tile is copied only when one of its pixels actually changes (tiles are shared with undo +/// states), and pixels are decoded and re-encoded exactly as a whole-region float round trip +/// would, so the result is the same as one pass over `area`. +fn edit_tiles( + s: &mut Surface, + area: Rect, + selection: Option<&Surface>, + purpose: &str, + whole: impl Fn(bool) -> Option + Sync, + pixel: impl Fn(f32, &mut [f32]) -> bool + Sync, +) -> crate::Result<()> { + use photocraft_raster::Tile; + use rayon::prelude::*; + use std::sync::Arc; + let fmt = s.format(); + let (n, bpp) = (fmt.channels(), fmt.bytes_per_pixel()); + let oom = |e| crate::EngineError::Other(format!("not enough memory while {purpose} ({e}); the document was not changed")); + let mut coords = crate::allocation::capacity(area.tiles().count(), purpose)?; + coords.extend(area.tiles()); + // Untouched pixels read as the default where the surface has no tile. + let blank = if coords.iter().any(|&tc| s.tile(tc).is_none()) { Some(s.try_default_tile().map_err(oom)?) } else { None }; + let surf = &*s; + let edits = coords + .into_par_iter() + .map(|tc| -> crate::Result>)>> { + let tr = tc.rect().intersect(&area); + let mask = selection.map(|m| crate::fill_cmds::Mask::of(m, tc)); + let full = tr == tc.rect() && mask.as_ref().is_none_or(|m| m.full(tr)); + let current = surf.tile(tc); + match whole(full) { + Some(TileEdit::Put(t)) => return Ok(Some((tc, Some(t)))), + Some(TileEdit::Drop) => return Ok(current.map(|_| (tc, None))), + None => {} + } + let Some(src) = current.map(|t| &**t).or(blank.as_ref()) else { return Ok(None) }; + let mut out: Option = None; + let (mut px, mut enc) = ([0.0f32; 8], [0u8; 32]); + let (Some(px), Some(enc)) = (px.get_mut(..n), enc.get_mut(..bpp)) else { return Ok(None) }; + for y in tr.y0..tr.y1 { + let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize; + for x in tr.x0..tr.x1 { + let i = (row + (x - tc.tx * TILE_SIZE) as usize) * bpp; + let Some(old) = src.bytes().get(i..i + bpp) else { continue }; + for (c, v) in px.iter_mut().enumerate() { + *v = photocraft_color::read_sample(old, fmt.sample, c); + } + if !pixel(mask.as_ref().map_or(1.0, |m| m.at(x, y)), px) { + continue; + } + photocraft_raster::encode_pixel(&fmt, px, enc); + if enc == old { + continue; + } + if out.is_none() { + out = Some(src.try_clone().map_err(oom)?); + } + if let Some(dst) = out.as_mut().and_then(|t| t.bytes_mut().get_mut(i..i + bpp)) { + dst.copy_from_slice(enc); + } + } + } + Ok(out.map(|t| (tc, Some(Arc::new(t))))) + }) + .collect::>>()?; + for (tc, tile) in edits.into_iter().flatten() { + match tile { + Some(t) => s.put_tiles([(tc, t)]), + None => drop(s.take_tiles(tc.rect())), } } + Ok(()) } /// Remap a surface through a pixel-coordinate mapping (used for flips/rotations). diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index c7ee89960..0da488010 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -124,3 +124,120 @@ fn an_empty_layer_is_left_alone() { adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb); assert_eq!(s.tile_count(), 0); } + +/// The whole-region float pass that the tiled Clear and background Fill replaced (#3463). +fn reference_fill_or_clear(s: &Surface, area: Rect, selection: Option<&Surface>, fill: Option<([f32; 4], bool)>) -> Surface { + let mut s = s.clone(); + let fmt = s.format(); + let n = fmt.channels(); + if fill.is_none() && !fmt.alpha { + return s; + } + let w = area.width() as usize; + let mut region = s.read_region(area); + for (i, px) in region.chunks_exact_mut(n).enumerate() { + let k = selection.map_or(1.0, |sel| sel.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0)); + match fill { + Some((color, lock)) => { + if k > 0.0 { + fill_pixel(&fmt, px, color, k, lock); + } + } + None => { + px[n - 1] *= 1.0 - k; + if px[n - 1] <= 0.0 { + px.fill(0.0); + } + } + } + } + s.write_region(area, ®ion); + s +} + +/// [`layer`] plus a fully painted tile and a transparent pixel that still carries a colour. +fn clear_layer(fmt: PixelFormat) -> Surface { + let mut s = layer(fmt); + s.fill_rect(Rect::new(0, 0, 256, 256), &from_rgba(&fmt, [0.2, 0.7, 0.4, if fmt.alpha { 0.8 } else { 1.0 }])); + s.write_pixel(260, 250, &from_rgba(&fmt, [0.9, 0.1, 0.1, 0.0])); + s +} + +fn same_pixels(a: &Surface, b: &Surface) -> bool { + let r = Rect::new(-10, -10, 800, 800); + a.read_region(r) == b.read_region(r) +} + +#[test] +fn tiled_clear_and_fill_match_one_pass_over_the_area() { + let mut formats = Vec::new(); + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Grayscale] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + formats.push(PixelFormat { mode, sample, alpha: true }); + } + } + formats.push(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: false }); + formats.push(PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::U16, alpha: false }); + // A soft (feathered) selection: fully selected over the first tile, fading out across the next. + let mut soft = Surface::new(PixelFormat::GRAY8); + let sr = Rect::new(0, 0, 600, 400); + soft.write_region(sr, &(0..sr.height()).flat_map(|_| (0..sr.width()).map(|x| (1.0 - (x as f32 - 256.0).max(0.0) / 300.0).max(0.0))).collect::>()); + let canvas = Rect::new(0, 0, 700, 600); + for fmt in formats { + let s = clear_layer(fmt); + for sel in [None, Some(&soft)] { + let area = sel.map_or(canvas, |m| m.content_bounds()); + let what = format!("{fmt:?} selection {}", sel.is_some()); + let mut got = s.clone(); + try_clear_surface(&mut got, area, sel).unwrap(); + assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}"); + if fmt.alpha { + assert!(!same_pixels(&got, &s), "clear {what} changes the layer"); + } + for color in [[1.0, 1.0, 1.0, 1.0], [0.3, 0.5, 0.9, 1.0], [0.3, 0.5, 0.9, 0.4]] { + for lock in [true, false] { + let mut got = s.clone(); + try_fill_surface(&mut got, area, color, sel, lock).unwrap(); + assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, Some((color, lock)))), "fill {color:?} lock {lock} {what}"); + } + } + } + } +} + +#[test] +fn clear_leaves_unselected_tiles_shared_and_drops_cleared_ones() { + let s = clear_layer(PixelFormat::RGBA8); + let mut sel = Surface::new(PixelFormat::GRAY8); + // Fully selects tile (0, 0) and part of (1, 0); the selection's bounds reach tile (2, 1), + // whose pixels it leaves alone. + sel.fill_rect(Rect::new(0, 0, 280, 256), &[1.0]); + sel.write_pixel(600, 300, &[0.0]); + sel.write_pixel(601, 301, &[0.5]); + let mut got = s.clone(); + try_clear_surface(&mut got, Rect::new(0, 0, 602, 302), Some(&sel)).unwrap(); + let tile = |s: &Surface, tx, ty| s.tile(photocraft_geom::TileCoord::new(tx, ty)).cloned(); + assert!(tile(&got, 0, 0).is_none(), "a fully cleared tile is no tile"); + let (a, b) = (tile(&s, 2, 1).unwrap(), tile(&got, 2, 1).unwrap()); + assert!(std::sync::Arc::ptr_eq(&a, &b), "a tile the selection leaves alone is not copied"); + assert!(!std::sync::Arc::ptr_eq(&tile(&s, 1, 0).unwrap(), &tile(&got, 1, 0).unwrap()), "a partly cleared tile is copied"); +} + +/// Release-only timing for Edit › Clear on a 48 MP layer (#3463): about 2 s with the old +/// whole-region float pass, tens of milliseconds tiled. +#[test] +#[ignore = "benchmark; run with --release -- --ignored --nocapture"] +fn benchmark_clear_large_selection() { + for (background, feather) in [(false, 0), (false, 200), (true, 0), (true, 200)] { + let mut s = crate::Session::new(); + s.execute("file.new", json!({"width": 8000, "height": 6000, "background": "white"})).unwrap(); + if !background { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 7980, "height": 5980, "ellipse": feather > 0, "feather": feather})).unwrap(); + let t = Instant::now(); + s.execute("edit.clear", json!({})).unwrap(); + println!("#3463 clear 8000x6000, background {background}, feather {feather}: {:?}", t.elapsed()); + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 39313e9eb..93a52d2d1 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -160,6 +160,37 @@ impl Unit { Unit::Inches => 3, } } + /// A length as typed in Photoshop's numeric fields: a number with an optional unit suffix + /// ("50%", "2.5 cm", "12pt", "3pc", "1,5 in", "100"). Returns the number and its unit (`None` + /// when there is no suffix, so the caller picks the default), or `None` if the text is not a + /// finite number followed by a known unit. + pub fn parse_length(text: &str) -> Option<(f64, Option)> { + let t = text.trim(); + let end = t.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | ',' | '-' | '+'))).unwrap_or(t.len()); + let (number, suffix) = t.split_at(end); + let v: f64 = number.trim().replace(',', ".").parse().ok()?; + if !v.is_finite() { + return None; + } + let suffix = suffix.trim(); + let unit = if suffix.is_empty() { None } else { Some(Unit::from_suffix(suffix)?) }; + Some((v, unit)) + } + /// The unit a typed suffix names, in any case: `px`, `in` or `"`, `cm`, `mm`, `pt`, `pc` or + /// `pica`, `%`, and their long forms (`pixels`, `inches`, `centimetres`…). The one table every + /// length field reads. + pub fn from_suffix(suffix: &str) -> Option { + Some(match suffix.to_ascii_lowercase().as_str() { + "px" | "pixel" | "pixels" => Unit::Pixels, + "in" | "inch" | "inches" | "\"" => Unit::Inches, + "cm" | "centimeter" | "centimeters" | "centimetre" | "centimetres" => Unit::Centimeters, + "mm" | "millimeter" | "millimeters" | "millimetre" | "millimetres" => Unit::Millimeters, + "pt" | "point" | "points" => Unit::Points, + "pc" | "pica" | "picas" => Unit::Picas, + "%" | "percent" => Unit::Percent, + _ => return None, + }) + } } impl PointSize { @@ -314,7 +345,7 @@ impl Default for Workspace { #[serde(default, rename_all = "camelCase")] pub struct Tools { pub show_tooltips: bool, - /// Tool keys cycle a tool group only with ⇧ held (Photoshop's "Use Shift Key for Tool Switch"). + /// Tool keys cycle a tool group only with ⇧ held (Photoshop's "Use Shift Key for Tool Switch", on by default there too). pub use_shift_key_for_tool_switch: bool, pub zoom_clicked_point_to_center: bool, pub enable_flick_panning: bool, @@ -341,7 +372,7 @@ impl Default for Tools { fn default() -> Self { Self { show_tooltips: true, - use_shift_key_for_tool_switch: false, + use_shift_key_for_tool_switch: true, zoom_clicked_point_to_center: false, enable_flick_panning: true, vary_round_brush_hardness_on_hud: true, @@ -1388,10 +1419,12 @@ fn capitalize(s: &str) -> String { /// Tools › Temporary, `edit.keyboardShortcuts`) but held rather than pressed, so they are not /// commands; the UI reads their keys through [`Preferences::shortcut`]. While a selection or /// shape is being dragged, the Hand key repositions it instead of panning (Photoshop's Space). +/// The erase key keeps the Brush or Pencil and makes its strokes erase (Photoshop's `~`). pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ ("tools.temporary.hand", "Hand Tool (hold)", "Space"), ("tools.temporary.zoomIn", "Zoom In (hold, drag to scrub)", "Cmd+Space"), ("tools.temporary.zoomOut", "Zoom Out (hold)", "Alt+Space"), + ("tools.temporary.erase", "Erase with Brush or Pencil (hold)", "`"), ]; /// Every bindable id with its default: commands, then the temporary tools. diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index a38a1c4ff..6bb34a970 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -346,7 +346,13 @@ fn temporary_tools_and_fill_keys_are_rebindable() { let mut s = session(); let r = s.execute("edit.keyboardShortcuts", json!({"filter": "tools.temporary"})).unwrap(); let ids: Vec<&str> = r["commands"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); - assert!(ids.contains(&"tools.temporary.hand") && ids.contains(&"tools.temporary.zoomIn") && ids.contains(&"tools.temporary.zoomOut"), "{ids:?}"); + assert!( + ids.contains(&"tools.temporary.hand") + && ids.contains(&"tools.temporary.zoomIn") + && ids.contains(&"tools.temporary.zoomOut") + && ids.contains(&"tools.temporary.erase"), + "{ids:?}" + ); assert!(r["commands"].as_array().unwrap().iter().all(|c| c["hold"] == true)); // Rebind the temporary zoom; an unknown id still fails. s.execute("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomIn": "Ctrl+Space"}})).unwrap(); @@ -607,3 +613,13 @@ fn ruler_units_round_to_their_own_decimal_places() { let r = super::UnitsAndRulers { rulers: Unit::Inches, ..Default::default() }; assert_eq!(r.format(100.0, 300.0, 1500.0), "0.333"); } + +/// #3296: like Photoshop, "Use Shift Key for Tool Switch" is on by default; a stored choice wins. +#[test] +fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() { + assert!(Preferences::default().tools.use_shift_key_for_tool_switch); + let missing: Tools = serde_json::from_value(json!({"showTooltips": false})).unwrap(); + assert!(missing.use_shift_key_for_tool_switch, "a settings file without the key gets the new default"); + let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap(); + assert!(!stored.use_shift_key_for_tool_switch); +} diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 8982cc9e1..02138b637 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -538,11 +538,15 @@ pub struct PresetStore { synced_rev: Option, /// Group file name → its last written content. groups: HashMap, + /// Group files that failed to load: left on disk untouched, so a sync never writes them. + skipped: HashSet, /// Tip hash → file size, for every tip file the store owns. tips: HashMap, index: Option, /// [`crate::actions_cmds::ActionState::rev`] last written (or loaded). actions_rev: u64, + /// `actions.json` exists but failed to load: left on disk untouched, so it is never written. + actions_skipped: bool, warnings: Vec, /// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load). cache: Mutex, @@ -597,6 +601,7 @@ pub fn open(backend: Box) -> Opened { // Parse the group files. let mut parsed: Vec<(String, u64, GroupFile)> = Vec::new(); + let mut skipped = HashSet::new(); let mut failed = false; for (name, size) in group_files { let r = backend.read(&name, MAX_GROUP_BYTES).and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())).and_then(|g| { @@ -615,6 +620,7 @@ pub fn open(backend: Box) -> Opened { Err(e) => { failed = true; warnings.push(format!("brush presets: {name} skipped: {e}")); + skipped.insert(name); } } } @@ -632,14 +638,18 @@ pub fn open(backend: Box) -> Opened { } let (tiles, regenerated) = load_tiles(backend.as_ref(), &wanted, &tip_sizes, &info, &mut warnings); - let actions = load_actions(backend.as_ref(), &mut warnings); + let loaded_actions = load_actions(backend.as_ref(), &mut warnings); + let actions_skipped = loaded_actions.is_none(); + let actions = loaded_actions.unwrap_or_default(); let mut store = PresetStore { backend, synced_rev: None, groups: HashMap::new(), + skipped: HashSet::new(), tips: HashMap::new(), index: None, actions_rev: 0, + actions_skipped, warnings: Vec::new(), cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }), }; @@ -668,6 +678,7 @@ pub fn open(backend: Box) -> Opened { // Left on disk untouched (a sync never deletes a file it did not load). failed = true; warnings.push(format!("brush presets: {file} skipped: {missing} preset(s) have missing or unreadable tips")); + skipped.insert(file); continue; } for h in &hashes { @@ -678,6 +689,7 @@ pub fn open(backend: Box) -> Opened { presets.extend(items.iter().cloned()); store.groups.insert(file, Synced { presets: items, tips: hashes, bytes: size, stale }); } + store.skipped = skipped; // Tip files nothing references are garbage (e.g. left by a crash), unless a group failed to // load: its tips must survive so a fixed file still works. if !failed { @@ -692,24 +704,25 @@ pub fn open(backend: Box) -> Opened { } /// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning. -fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> Vec { +/// The saved actions; `None` when `actions.json` exists but could not be loaded. +fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option> { let bytes = match backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES) { Ok(b) => b, - Err(e) if missing_file(&e) => return Vec::new(), + Err(e) if missing_file(&e) => return Some(Vec::new()), Err(e) => { warnings.push(format!("actions: {ACTIONS_FILE} skipped: {e}")); - return Vec::new(); + return None; } }; match serde_json::from_slice::(&bytes) { - Ok(file) if file.version == 1 => file.actions, + Ok(file) if file.version == 1 => Some(file.actions), Ok(file) => { warnings.push(format!("actions: {ACTIONS_FILE} skipped: unsupported version {}", file.version)); - Vec::new() + None } Err(e) => { warnings.push(format!("actions: {ACTIONS_FILE} skipped: {e}")); - Vec::new() + None } } } @@ -873,6 +886,13 @@ impl PresetStore { if unchanged { continue; } + if self.skipped.contains(&file) { + self.warnings.push(format!( + "Couldn't save brush presets `{}`: {file} could not be read, so it was left unchanged", + if group.is_empty() { "(ungrouped)" } else { group } + )); + continue; + } let mut items: Vec<&mut BrushPreset> = presets.iter_mut().filter(|p| !p.builtin && p.group == *group).collect(); if let Err(e) = self.write_group(&file, group, &mut items) { self.warnings.push(format!("Couldn't save brush presets `{}`: {e}", if group.is_empty() { "(ungrouped)" } else { group })); @@ -1013,6 +1033,11 @@ impl PresetStore { if self.actions_rev == rev { return; } + if self.actions_skipped { + self.warnings.push(format!("Couldn't save actions: {ACTIONS_FILE} could not be read, so it was left unchanged")); + self.actions_rev = rev; + return; + } let file = crate::actions_cmds::ActionsFile { version: 1, actions: list.to_vec() }; let bytes = match serde_json::to_vec_pretty(&file) { Ok(b) => b, diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index b6017781a..b7f175867 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -623,3 +623,33 @@ fn imported_abr_folders_persist_and_old_stores_load_flat() { assert_eq!(photocraft_paint::presets::find(&d.tools.presets, "Deep").unwrap().folder.len(), crate::brush_preset_cmds::MAX_FOLDER_DEPTH); assert!(photocraft_paint::presets::find(&d.tools.presets, "Blank").unwrap().folder.is_empty()); } + +#[test] +fn a_group_file_that_failed_to_load_is_not_overwritten() { + // e.g. written by a newer PhotoCraft: skipped on load, so a save must not replace it. + let mem = MemBackend::default(); + let file = group_file_name(""); + let newer = br#"{"format":"photocraft-brush-group","version":2,"group":"","presets":[]}"#.to_vec(); + mem.files.lock().unwrap().insert(file.clone(), newer.clone()); + let mut s = Session::new(); + s.attach_preset_store(open(Box::new(mem.clone()))); + s.tools.brush.tip = TipShape::Sampled(tip8(4, 4, 0)); + s.execute("brush.presets.save", json!({"name": "New"})).unwrap(); + assert_eq!(mem.files.lock().unwrap().get(&file), Some(&newer), "a file the store could not read was overwritten"); + assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty()); +} + +#[test] +fn an_actions_file_that_failed_to_load_is_not_overwritten() { + // e.g. written by a newer PhotoCraft: skipped on load, so recording an action must not replace it. + let mem = MemBackend::default(); + let newer = br#"{"version":2,"actions":[{"name":"Keep","steps":[]}]}"#.to_vec(); + mem.files.lock().unwrap().insert(ACTIONS_FILE.into(), newer.clone()); + let mut s = Session::new(); + s.attach_preset_store(open(Box::new(mem.clone()))); + s.execute("actions.record", json!({"name": "New"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.sync_preset_store(); + assert_eq!(mem.files.lock().unwrap().get(ACTIONS_FILE), Some(&newer), "an actions file the store could not read was overwritten"); + assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty()); +} diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 5c40adfb8..5a72f781f 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -81,6 +81,8 @@ pub struct PresetState { pub tool_presets: Vec, /// File › New: named snapshots, separate from the name of the document being created. pub documents: Vec, + /// File › New › Recent: the settings of documents recently created there, newest first. + pub recent_documents: Vec, pub clone: clone_source::CloneSources, /// The user defaults "Make Default" in the Layer Style dialog saves /// (effect kind → param set); "Reset to Default" restores them. @@ -102,6 +104,7 @@ impl Default for PresetState { pattern: None, tool_presets: tools::builtin(), documents: Vec::new(), + recent_documents: Vec::new(), clone: Default::default(), layer_defaults: BTreeMap::new(), swatches: swatches::builtin(), @@ -130,6 +133,8 @@ struct Persisted { #[serde(default)] documents: Option, #[serde(default)] + recent_documents: Option, + #[serde(default)] custom_shapes: Option>, #[serde(default)] layer_defaults: Option>, @@ -147,6 +152,7 @@ impl PresetState { pattern_groups: Some(self.pattern_groups.clone()), tool_presets: Some(json!(self.tool_presets)), documents: Some(json!(self.documents)), + recent_documents: Some(json!(self.recent_documents)), custom_shapes: Some(s.edit_state.custom_shapes.clone()), layer_defaults: Some(self.layer_defaults.clone()), swatches: Some(self.swatches.clone()), @@ -182,6 +188,9 @@ impl Session { if let Some(d) = p.documents { st.documents = crate::document_preset_cmds::load(d); } + if let Some(r) = p.recent_documents { + st.recent_documents = crate::document_preset_cmds::load_recent(r); + } if let Some(c) = p.custom_shapes { self.edit_state.custom_shapes = c; } diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 37dee42ad..96dfa77fd 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -5,7 +5,8 @@ //! centre crop marks, registration marks, description and label) with a small PDF writer //! (ISO 32000: one page, a Flate-compressed image XObject, vector marks, Helvetica text), then //! hands its bytes to `craft-print` (CUPS on macOS/Linux, Windows PDF printing on Windows). -//! `"dryRun": true` retains a diagnostic PDF and reports the backend command without spooling. +//! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an +//! explicit output path. Shared spooler transport files are private and automatically removed. //! //! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible //! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U` @@ -328,14 +329,19 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { /// Lays out, renders and (unless dry-run) spools a print. Shared by Print and Print One Copy. fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { + let output = p.get("output").and_then(Value::as_str).filter(|v| !v.is_empty()); + let send = p.get("send").and_then(Value::as_bool).unwrap_or(output.is_none()); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + if !dry && !send && output.is_none() { + return Err(bad(cmd, "send: false needs an output path or dryRun: true")); + } let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); let doc_id = doc.id; let params = p.clone(); let worker_params = params.clone(); let command = cmd.to_string(); - let send = p.get("send").and_then(Value::as_bool).unwrap_or(!p.get("output").and_then(Value::as_str).is_some_and(|v| !v.is_empty())); - if send && !p.get("dryRun").and_then(Value::as_bool).unwrap_or(false) { + if send && !dry { // Session::execute remains synchronous; the desktop's Session::start snapshots here // and runs PDF composition and the potentially slow driver on a worker. return crate::jobs::run( @@ -398,7 +404,6 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { write_file(&path, &pdf)?; Some(path) } - None if dry || !send => Some(diagnostic_pdf(&pdf)?), None => None, }; let mut sent = false; @@ -418,25 +423,12 @@ fn remember_print(s: &mut Session, p: &Value) { if let Some(o) = remembered.as_object_mut() { o.remove("output"); o.remove("dryRun"); + // Output and dry-run choices are not remembered, so a send override must not survive them. + o.remove("send"); } s.file_menu.last_print = Some(remembered); } -/// A requested preview/export artifact belongs to the caller, unlike a spooler's temporary -/// transport file. Create it exclusively and keep it so the command's `pdf` path remains usable. -#[cfg(not(target_arch = "wasm32"))] -fn diagnostic_pdf(pdf: &[u8]) -> Result { - let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(other)?; - file.write_all(pdf).map_err(other)?; - file.flush().map_err(other)?; - let (_, path) = file.keep().map_err(other)?; - Ok(path.to_string_lossy().into_owned()) -} -#[cfg(target_arch = "wasm32")] -fn diagnostic_pdf(_: &[u8]) -> Result { - Err(other("printing needs the desktop app; specify an output PDF path instead")) -} - fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } @@ -605,7 +597,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render a diagnostic PDF, report the backend command, don't spool)} → {pdf:path|null (null for spool-only jobs), imageRect, command:diagnostic array|null, sent}"##; + const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), spec!( diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index c58ae86e4..988f11635 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -37,10 +37,11 @@ fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { let dir = tmp("print"); for depth in [8, 16, 32] { let mut s = session("rgb", depth); + let out = format!("{dir}/rgb-{depth}.pdf"); let r = s .execute( "file.print", - json!({"dryRun": true, "printer": "Office", "copies": 2, "paper": "a4", "cornerCropMarks": true, "registrationMarks": true, "labels": true}), + json!({"output": out, "send": true, "dryRun": true, "printer": "Office", "copies": 2, "paper": "a4", "cornerCropMarks": true, "registrationMarks": true, "labels": true}), ) .unwrap(); let cmd: Vec = serde_json::from_value(r["command"].clone()).unwrap(); @@ -48,12 +49,13 @@ fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { { assert_eq!(&cmd[..5], &["lp", "-d", "Office", "-n", "2"]); assert!(cmd.contains(&"fit-to-page=false".into())); - assert!(!cmd.contains(&r["pdf"].as_str().unwrap().to_string()), "PDF is streamed, not passed as a file"); + 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!(r["sent"], false); - let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); + assert_eq!(r["pdf"], out); + let pdf = std::fs::read(&out).unwrap(); assert!(pdf.starts_with(b"%PDF-1.4")); assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 595.28 841.89]")); let (w, h, cs, data) = pdf_image(&pdf); @@ -63,7 +65,6 @@ fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { // 300 px at 72 ppi = 300 pt, centred. let rect: Vec = serde_json::from_value(r["imageRect"].clone()).unwrap(); assert!((rect[2] - 300.0).abs() < 1e-6 && (rect[0] - (595.28 - 300.0) / 2.0).abs() < 1e-6); - let _ = std::fs::remove_file(r["pdf"].as_str().unwrap()); } // Print to PDF: no spooling by default. let mut s = session("gray", 8); @@ -82,14 +83,15 @@ fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { #[test] fn photocraft_manages_colors_converts_to_the_printer_profile() { let mut s = session("rgb", 8); + let out = format!("{}/managed.pdf", tmp("managed")); let r = s .execute( "file.print", - json!({"dryRun": true, "colorHandling": "photocraftManages", "printerProfile": "coated-cmyk", "intent": "perceptual", "bpc": false}), + json!({"output": out, "dryRun": true, "colorHandling": "photocraftManages", "printerProfile": "coated-cmyk", "intent": "perceptual", "bpc": false}), ) .unwrap(); assert_eq!(r["color"]["intent"], "perceptual"); - let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); + let pdf = std::fs::read(&out).unwrap(); let (_, _, cs, data) = pdf_image(&pdf); assert_eq!(cs, "[/ICCBased 7 0 R]"); assert!(String::from_utf8_lossy(&pdf).contains("/N 4 /Alternate /DeviceCMYK")); @@ -98,7 +100,6 @@ fn photocraft_manages_colors_converts_to_the_printer_profile() { assert!(data[0] > 150 && data[2] < 60, "{:?}", &data[..4]); // The document itself is untouched. assert_eq!(s.active().unwrap().doc.mode, ColorMode::Rgb); - let _ = std::fs::remove_file(r["pdf"].as_str().unwrap()); } /// The inflated page content stream (object 4). @@ -115,9 +116,18 @@ fn pdf_content(pdf: &[u8]) -> String { #[test] fn emulsion_down_mirrors_the_whole_page() { let mut s = session("rgb", 8); - let plain = s.execute("file.print", json!({"dryRun": true, "paper": "a4", "center": false, "left": 1.0, "top": 1.0, "labels": true})).unwrap(); + let dir = tmp("emulsion"); + let plain_path = format!("{dir}/plain.pdf"); + let mirrored_path = format!("{dir}/mirrored.pdf"); + let again_path = format!("{dir}/again.pdf"); + let plain = s + .execute("file.print", json!({"output": plain_path, "dryRun": true, "paper": "a4", "center": false, "left": 1.0, "top": 1.0, "labels": true})) + .unwrap(); let mirrored = s - .execute("file.print", json!({"dryRun": true, "paper": "a4", "center": false, "left": 1.0, "top": 1.0, "labels": true, "emulsionDown": true})) + .execute( + "file.print", + json!({"output": mirrored_path, "dryRun": true, "paper": "a4", "center": false, "left": 1.0, "top": 1.0, "labels": true, "emulsionDown": true}), + ) .unwrap(); assert_eq!(plain["emulsionDown"], false); assert_eq!(mirrored["emulsionDown"], true); @@ -133,20 +143,24 @@ fn emulsion_down_mirrors_the_whole_page() { // The pixels themselves are not touched (the transform mirrors them). assert_eq!(pdf_image(&p).3, pdf_image(&m).3); // Print One Copy keeps it. - let again = s.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); + let again = s.execute("file.printOneCopy", json!({"output": again_path, "dryRun": true})).unwrap(); assert_eq!(again["emulsionDown"], true); for r in [plain, mirrored, again] { let _ = std::fs::remove_file(r["pdf"].as_str().unwrap()); } + let _ = std::fs::remove_dir_all(&dir); } #[test] fn printer_profile_can_be_an_installed_icc_file() { let dir = tmp("icc"); let icc = format!("{dir}/My Paper.icc"); + let output = format!("{dir}/managed.pdf"); std::fs::write(&icc, photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes().as_slice()).unwrap(); let mut s = session("rgb", 16); - let r = s.execute("file.print", json!({"dryRun": true, "colorHandling": "photocraftManages", "printerProfile": icc, "emulsionDown": true})).unwrap(); + let r = s + .execute("file.print", json!({"output": output, "dryRun": true, "colorHandling": "photocraftManages", "printerProfile": icc, "emulsionDown": true})) + .unwrap(); assert_eq!(r["color"]["printerProfile"], json!(icc)); let (_, _, cs, data) = pdf_image(&std::fs::read(r["pdf"].as_str().unwrap()).unwrap()); assert_eq!(cs, "[/ICCBased 7 0 R]"); @@ -160,7 +174,7 @@ fn printer_profile_can_be_an_installed_icc_file() { fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); let first = s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50})).unwrap(); - std::fs::remove_file(first["pdf"].as_str().unwrap()).unwrap(); + assert!(first["pdf"].is_null()); let r = s.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); assert_eq!(r["copies"], 1); assert_eq!(r["paper"], json!([419.53, 595.28])); @@ -170,7 +184,7 @@ fn print_one_copy_repeats_the_last_settings() { assert_eq!(&cmd[..3], &["lp", "-n", "1"]); #[cfg(windows)] assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); - let _ = std::fs::remove_file(r["pdf"].as_str().unwrap()); + assert!(r["pdf"].is_null()); } #[test] @@ -236,7 +250,36 @@ fn paths_export_as_illustrator_postscript() { assert!(s.execute("file.export.pathsToIllustrator", json!({"paths": "Nope"})).is_err()); } -/// Launch the command in a subprocess with a fake CUPS binary: the parent process's PATH and +#[test] +fn print_dry_run_without_output_retains_no_pdf() { + let mut s = session("rgb", 8); + let result = s.execute("file.print", json!({"dryRun": true})).unwrap(); + assert!(result["pdf"].is_null()); + assert_eq!(result["sent"], false); + assert!(result["bytes"].as_u64().unwrap() > 100); + assert!(result["command"].is_array()); + assert!(s.file_menu.last_print.as_ref().unwrap().get("dryRun").is_none()); +} + +#[test] +fn print_without_sending_requires_output_unless_dry_run_and_forgets_send_override() { + let mut s = session("rgb", 8); + let before = s.file_menu.last_print.clone(); + let err = s.execute("file.print", json!({"send": false})).unwrap_err(); + assert!(err.to_string().contains("needs an output path"), "{err}"); + assert_eq!(s.file_menu.last_print, before); + let result = s.execute("file.print", json!({"send": false, "dryRun": true})).unwrap(); + assert!(result["pdf"].is_null()); + assert!(result["command"].is_null()); + assert!(s.file_menu.last_print.as_ref().unwrap().get("send").is_none()); + let out = format!("{}/export.pdf", tmp("non-sending-export")); + let result = s.execute("file.print", json!({"output": out, "send": false})).unwrap(); + assert_eq!(result["sent"], false); + assert!(std::fs::read(&out).unwrap().starts_with(b"%PDF-1.4")); + assert!(s.file_menu.last_print.as_ref().unwrap().get("send").is_none()); + s.execute("file.print", json!({"output": out, "send": true, "dryRun": true})).unwrap(); + assert!(s.file_menu.last_print.as_ref().unwrap().get("send").is_none()); +} /// parallel tests are untouched, and the real spool boundary still consumes the PDF bytes. #[cfg(unix)] #[test] @@ -278,6 +321,16 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { assert!(err.to_string().contains("queue refused"), "{err}"); assert!(std::fs::read(&explicit).unwrap().starts_with(b"%PDF-1.4"), "explicit export survives a spool refusal"); assert_eq!(s.file_menu.last_print, remembered, "failed printing does not replace repeat settings"); + // Export-only choices must not disable submission when Print One Copy repeats settings. + let export = format!("{dir}/export-only.pdf"); + s.execute("file.print", json!({"printer": "Office", "output": export, "send": false})).unwrap(); + let remembered = s.file_menu.last_print.as_ref().unwrap(); + assert!(remembered.get("send").is_none() && remembered.get("output").is_none()); + let repeat = s.execute("file.printOneCopy", json!({})).unwrap(); + assert_eq!(repeat["sent"], true); + assert_eq!(repeat["copies"], 1); + assert!(repeat["pdf"].is_null()); + assert!(!std::fs::read_to_string(format!("{dir}/args")).unwrap().contains(".pdf")); return; } use std::os::unix::fs::PermissionsExt; diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index 972004170..e92a8fd5c 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -27,6 +27,24 @@ fn mode(p: &Value) -> SelectionMode { SelectionMode::parse(p.get("mode").and_then(Value::as_str).unwrap_or("replace")) } +/// 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; + } + if let Some(selection) = doc.selection.as_ref() { + let previous = sel::mask_from_surface(Some(selection), area); + for (value, bound) in mask.iter_mut().zip(previous) { + *value = value.min(bound); + } + } +} + /// RGBA pixels the tools sample: the composite, or the active layer. fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -127,7 +145,14 @@ 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]], bad: &impl Fn(String) -> EngineError) -> Result> { +fn eyedropper_samples( + p: &Value, + key: &str, + area: Rect, + px: &[[f32; 4]], + allowed: &impl Fn(i32, i32) -> bool, + 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; @@ -145,6 +170,9 @@ fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &i 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) @@ -215,6 +243,9 @@ 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| { @@ -327,14 +358,17 @@ fn color_range(s: &mut Session, p: &Value) -> Result { let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; let preset = p.get("select").map_or(Some("sampledColors"), Value::as_str).ok_or_else(|| bad("`select` must be a string".into()))?; let invert = b(p, "invert", false); + let selection_mode = mode(p); let fuzz = |d: f32| f(p, "fuzziness", d).max(0.0); let (area, mut mask) = match preset { "sampledColors" => { 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 plus = eyedropper_samples(p, "points", area, &px, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; + 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)?; let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); (area, mask) @@ -393,7 +427,10 @@ fn color_range(s: &mut Session, p: &Value) -> Result { *v = 1.0 - *v; } } - set_selection(s, "Color Range", area, mask, mode(p)) + // Invert the colour result first, then constrain it to the original selection. + // Add mode must still be able to extend an existing selection intentionally. + scope_color_range(&mut mask, &s.active().ok_or(EngineError::NoDocument)?.doc, area, selection_mode); + set_selection(s, "Color Range", area, mask, selection_mode) } fn modify(s: &mut Session, p: &Value, op: &str) -> Result { @@ -649,6 +686,72 @@ mod tests { s.active().unwrap().doc.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0)) } + fn clear_selection(s: &mut Session) { + if s.active().unwrap().doc.selection.is_some() { + s.execute("select.deselect", json!({})).unwrap(); + } + } + + #[test] + fn color_range_replace_is_bounded_by_existing_selection() { + for params in [json!({"color": "#ff0000", "fuzziness": 0}), json!({"select": "reds"})] { + let mut s = session(); + // Both red squares match, but only the left is inside the original selection. + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); + s.execute("select.colorRange", params).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0); + assert_eq!(coverage(&s, 30, 10), 0.0); + assert_eq!(coverage(&s, 2, 2), 0.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0); + assert_eq!(coverage(&s, 30, 10), 0.0); + } + } + + #[test] + fn color_range_invert_and_partial_masks_stay_within_prior_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); + s.execute("select.colorRange", json!({"select": "highlights", "invert": true})).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0, "inverted white excludes the white background"); + assert_eq!(coverage(&s, 20, 20), 0.0, "invert must not select outside the old boundary"); + + let mut s = session(); + s.edit("soft selection", |doc, _| { + let mut mask = vec![0.0; 40 * 30]; + mask[10 * 40 + 10] = 128.0 / 255.0; + doc.selection = Some(sel::mask_to_surface(&mask, doc.bounds())); + Ok(()) + }) + .unwrap(); + s.execute("select.colorRange", json!({"select": "reds"})).unwrap(); + assert!((coverage(&s, 10, 10) - 128.0 / 255.0).abs() < 0.001); + assert_eq!(coverage(&s, 30, 10), 0.0); + } + + #[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); + + // Explicitly adding a matching colour is still allowed to grow the selection. + s.execute("select.colorRange", json!({"points": [[30, 10]], "fuzziness": 0, "mode": "add"})).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0); + assert_eq!(coverage(&s, 30, 10), 1.0); + + clear_selection(&mut s); + s.execute("select.colorRange", json!({"color": "#ff0000", "fuzziness": 0})).unwrap(); + assert_eq!(coverage(&s, 30, 10), 1.0, "an unbounded color range still searches the canvas"); + } + #[test] fn magic_wand_selects_the_visible_alpha_or_mask_not_the_rgb() { let mut s = session(); @@ -719,6 +822,7 @@ mod tests { json!({"points": [[2, 3], [10, 3]], "subtractPoints": [[10, 3]], "order": ["+", "+", "-"], "fuzziness": 40}), json!({"points": [[2, 3]], "localized": true, "range": 50, "fuzziness": 80}), ] { + clear_selection(&mut s); let q = ColorRangeSamples::new(&s, &p).unwrap(); let doc = s.active().unwrap().doc.clone(); let pixels = photocraft_compose::render(&doc, doc.bounds()); @@ -751,6 +855,7 @@ mod tests { }) .unwrap(); for all_layers in [false, true] { + clear_selection(&mut s); let p = json!({"points": [[7, 7]], "sampleAllLayers": all_layers, "fuzziness": 0}); let query = ColorRangeSamples::new(&s, &p).unwrap(); let (area, px) = sample_pixels(&s, all_layers).unwrap(); @@ -822,16 +927,18 @@ mod tests { assert_eq!((coverage(&s, 10, 10), coverage(&s, 30, 10), coverage(&s, 10, 24)), (1.0, 1.0, 1.0)); assert_eq!((coverage(&s, 2, 2), coverage(&s, 22, 24), coverage(&s, 32, 24)), (0.0, 0.0, 0.0)); // Eyedropper point on the blue square; `invert` flips the result. + clear_selection(&mut s); s.execute("select.colorRange", json!({"points": [[7, 22]], "fuzziness": 100, "invert": true})).unwrap(); assert_eq!((coverage(&s, 10, 24), coverage(&s, 10, 10), coverage(&s, 2, 2)), (0.0, 1.0, 1.0)); // Localized clusters: picked on the left red square, a 25% range (10 px) leaves the right one. + clear_selection(&mut s); s.execute("select.colorRange", json!({"points": [[10, 10]], "fuzziness": 100, "localized": true, "range": 25})).unwrap(); assert_eq!(coverage(&s, 10, 10), 1.0); assert!(coverage(&s, 13, 10) > 0.5); assert_eq!(coverage(&s, 30, 10), 0.0); - // One undo step per call. + // Undo restores the empty selection that preceded the most recent Color Range. s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(coverage(&s, 2, 2), 1.0); + assert_eq!(coverage(&s, 2, 2), 0.0); } #[test] @@ -848,6 +955,7 @@ mod tests { }) .unwrap(); let run = |s: &mut Session, extra: Value| { + clear_selection(s); let mut p = json!({"points": [[5, 5], [45, 5]], "fuzziness": 10}); p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); s.execute("select.colorRange", p).unwrap(); @@ -864,6 +972,7 @@ mod tests { let sub_last = run(&mut s, json!({"points": [[5, 5], [45, 5], [5, 5]], "subtractPoints": [[15, 5]], "order": ["+", "+", "+", "-"]})); assert!(sub_last[0] < 0.5, "{sub_last:?}"); // A malformed or inconsistent order is an error, never a panic. + clear_selection(&mut s); for bad in [json!(["+"]), json!(["+", "+", "x"]), json!("+-"), json!(["-", "-", "+"])] { assert!(s.execute("select.colorRange", json!({"points": [[5, 5], [45, 5]], "subtractPoints": [[15, 5]], "order": bad})).is_err(), "{bad}"); } @@ -875,18 +984,24 @@ mod tests { s.execute("select.colorRange", json!({"select": "reds"})).unwrap(); assert_eq!((coverage(&s, 10, 10), coverage(&s, 30, 10)), (1.0, 1.0)); assert_eq!((coverage(&s, 10, 24), coverage(&s, 2, 2), coverage(&s, 32, 24)), (0.0, 0.0, 0.0)); + clear_selection(&mut s); s.execute("select.colorRange", json!({"select": "blues"})).unwrap(); assert_eq!((coverage(&s, 10, 24), coverage(&s, 10, 10)), (1.0, 0.0)); + clear_selection(&mut s); s.execute("select.colorRange", json!({"select": "greens"})).unwrap(); assert!(s.active().unwrap().doc.selection.is_none() || coverage(&s, 10, 10) == 0.0); // Tones: black is a shadow, white a highlight, 50% grey (128) a midtone. + clear_selection(&mut s); s.execute("select.colorRange", json!({"select": "shadows"})).unwrap(); assert_eq!((coverage(&s, 22, 24), coverage(&s, 2, 2), coverage(&s, 32, 24)), (1.0, 0.0, 0.0)); + clear_selection(&mut s); s.execute("select.colorRange", json!({"select": "highlights"})).unwrap(); assert_eq!((coverage(&s, 22, 24), coverage(&s, 2, 2)), (0.0, 1.0)); + clear_selection(&mut s); s.execute("select.colorRange", json!({"select": "midtones", "fuzziness": 0})).unwrap(); assert_eq!((coverage(&s, 32, 24), coverage(&s, 2, 2), coverage(&s, 22, 24)), (1.0, 0.0, 0.0)); // A wider shadow range takes the grey too. + clear_selection(&mut s); s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": 140, "fuzziness": 0})).unwrap(); assert_eq!(coverage(&s, 32, 24), 1.0); // Modes combine as for the other selection commands. diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index aa617ccc4..5678a8b7d 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1057,24 +1057,6 @@ fn move_filter(s: &mut Session, p: &Value) -> Result { // ---------- enablement ---------- -/// Whether smart-filter or contents command `spec` can run with `p`: its precondition is checked on an -/// explicit `"layer"` of the active document (the layer it then edits) rather than on the -/// active layer, which stays active (#466). `None` for other commands or without that target. -pub(crate) fn target_enabled(s: &mut Session, spec: &CommandSpec, p: &Value) -> Option> { - if !spec.id.starts_with("layer.smartFilter.") && !matches!(spec.id, "layer.smartObjects.editContents" | "layer.smartObjects.convertToLayers") { - return None; - } - let target = LayerId(p.get("layer")?.as_u64()?); - let d = s.active_mut()?; - d.doc.layer(target)?; - let active = d.active_layer.replace(target); - let r = (spec.enabled)(s); - if let Some(d) = s.active_mut() { - d.active_layer = active; - } - Some(r) -} - fn active_smart(s: &Session) -> std::result::Result<&SmartObject, String> { let d = s.active().ok_or("no document open")?; let id = d.active_layer.ok_or("no active layer")?; diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index dfb8205aa..00cb58d8c 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -638,8 +638,7 @@ fn rasterizing_a_vector_mask_keeps_what_the_masks_show() { s.execute("layer.rasterize.vectorMask", json!({})).unwrap(); let l = doc(&s).layer(id).unwrap(); assert!(l.vector_mask.is_none() && l.mask.is_some(), "{what}"); - // An integer mask stores the soft values rounded to its depth (a mask made from a - // selection is 8-bit at every document depth). + // An integer mask stores the soft values rounded to its depth. let tol = match l.mask.as_ref().unwrap().surface.format().sample { photocraft_color::SampleType::U8 => 1.5 / 255.0, _ => 1e-3, diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index c8091bf62..49578cbb5 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -606,8 +606,9 @@ mod tests { let before = active_surface(&s); s.execute("edit.transform.warp", json!({"style": "arc", "bend": 50})).unwrap(); let after = active_surface(&s); - assert!(after.content_bounds().y1 > 42, "{:?}", after.content_bounds()); - assert!(after.pixel(11, 11)[3] < 0.5, "top-left corner pulls in"); + // As in Photoshop: the bottom edge keeps its corners, the top rises and spreads. + let c = after.content_bounds(); + assert!(c.y1 <= 41 && c.y0 < 8 && c.x0 < 8 && c.x1 > 62, "{c:?}"); s.undo(); assert_eq!(active_surface(&s), before); assert!(s.execute("edit.transform.warp", json!({"style": "spiral"})).is_err()); diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 7175d452d..a15cb258c 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -626,13 +626,67 @@ fn quantize_rows(px: &mut [[f32; 4]], w: usize, pal: &[[u8; 3]], dither: Dither, /// A file-name-safe slice name. fn slice_file(name: &str) -> String { let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_') { c } else { '_' }).collect(); - if s.is_empty() { "slice".into() } else { s } + let s = if s.is_empty() { "slice".into() } else { s }; + portable_generated_stem(s) +} + +/// Generated filenames should remain usable on Windows even when a document or slice is named +/// after a reserved device. Caller-supplied path components are still rejected below. +fn portable_generated_stem(stem: String) -> String { + if is_windows_device_asset_name(&stem) { format!("{stem}_") } else { stem } } fn html_escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } +/// Imported slice URLs are document data. Only ordinary web/contact links and relative links +/// become anchors; an unsupported scheme leaves the image unlinked. +fn safe_slice_url(url: &str) -> bool { + if url.is_empty() || url.trim() != url || url.chars().any(|c| c.is_control() || c == '\\') { + return false; + } + let delimiter = url.find(['/', '?', '#']).unwrap_or(url.len()); + let Some(colon) = url.find(':').filter(|pos| *pos < delimiter) else { + return true; + }; + let scheme = url.get(..colon).unwrap_or_default().to_ascii_lowercase(); + let rest = url.get(colon + 1..).unwrap_or_default(); + match scheme.as_str() { + "http" | "https" | "ftp" | "ftps" => rest.strip_prefix("//").is_some_and(|host| !host.is_empty() && !host.starts_with('/')), + "mailto" | "tel" => !rest.is_empty(), + _ => false, + } +} + +/// Keep the image folder relative to the selected export directory on every platform. +fn web_images_folder(images: &str, cmd: &str) -> Result { + if images.is_empty() { + return Ok(String::new()); + } + let trimmed = images.trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + return Err(bad(cmd, "imagesFolder must be a relative folder name")); + } + let parts = asset_path_components(trimmed).map_err(|e| bad(cmd, format!("imagesFolder: {e}")))?; + Ok(parts.join("/")) +} + +fn web_output_path(dir: &str, relative: &str) -> Result { + let root = if dir.is_empty() { "." } else { dir }; + asset_output_path(root, relative).map(|(actual, _)| actual) +} + +fn web_derived_path(dir: &str, relative: &str, native_paths: bool) -> Result { + if native_paths { + web_output_path(dir, relative) + } else { + // Browser downloads use logical names, without a host filesystem to inspect. + asset_path_components(relative)?; + Ok(join(dir, relative)) + } +} + fn scale_rect(r: Rect, sx: f64, sy: f64) -> Rect { Rect::new( (f64::from(r.x0) * sx).round() as i32, @@ -690,14 +744,14 @@ fn html_table(title: &str, w: u32, h: u32, cells: &[(Rect, String, String)], spa s.push_str(&td); } if let Some(sp) = spacer { - s.push_str(&format!("\t\t\n\t\t\t\"\"\n", ys[j + 1] - ys[j])); + s.push_str(&format!("\t\t\n\t\t\t\"\"\n", html_escape(sp), ys[j + 1] - ys[j])); } s.push_str("\t\n"); } if let Some(sp) = spacer { s.push_str("\t\n"); for i in 0..nc { - s.push_str(&format!("\t\t\n\t\t\t\"\"\n", xs[i + 1] - xs[i])); + s.push_str(&format!("\t\t\n\t\t\t\"\"\n", html_escape(sp), xs[i + 1] - xs[i])); } s.push_str("\t\t\n\t\n"); } @@ -707,13 +761,18 @@ fn html_table(title: &str, w: u32, h: u32, cells: &[(Rect, String, String)], spa /// File › Export › Save for Web (Legacy). fn save_for_web(s: &mut Session, p: &Value) -> Result { - save_for_web_with_writer(s, p, &mut write_file) + save_for_web_impl(s, p, &mut write_file, true) } /// Save for Web through a platform writer (native files or browser downloads). /// The writer receives every image, spacer and HTML file. A write error stops the export; -/// only a complete export remembers its settings and fires the export event. +/// only a complete export remembers its settings and fires the export event. A custom writer +/// that targets the local filesystem must enforce its own path confinement. pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + save_for_web_impl(s, p, write, false) +} + +fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>, native_paths: bool) -> Result { let cmd = "file.export.saveForWebLegacy"; if let Some(authorize) = s.authorize { authorize(cmd, p)?; @@ -743,6 +802,12 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu .collect(); let single = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()); let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()); + let mut last_web = p.clone(); + // A trusted-markup decision applies to this export only, never to another document opened + // with the dialog's remembered settings. + if let Some(last) = last_web.as_object_mut() { + last.remove("trustedSliceHtml"); + } // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; @@ -751,15 +816,16 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; - s.file_menu.last_web = Some(p.clone()); + s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); } let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); let html = p.get("html").and_then(Value::as_bool).unwrap_or(false); - let images = p.get("imagesFolder").and_then(Value::as_str).unwrap_or("images").to_string(); - let base = single.map(stem).unwrap_or_else(|| slices::base_name(&doc)); + let images = web_images_folder(p.get("imagesFolder").and_then(Value::as_str).unwrap_or("images"), cmd)?; + let trusted_slice_html = p.get("trustedSliceHtml").and_then(Value::as_bool).unwrap_or(false); + let base = portable_generated_stem(single.map(stem).unwrap_or_else(|| slices::base_name(&doc))); let mut files = Vec::new(); let mut cells: Vec<(Rect, String, String)> = Vec::new(); let mut total = 0usize; @@ -801,7 +867,7 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu let text = if k > 0 { String::new() } else { - stored.map(|s| if s.cell_text_is_html { s.cell_text.clone() } else { html_escape(&s.cell_text) }).unwrap_or_default() + stored.map(|s| if trusted_slice_html && s.cell_text_is_html { s.cell_text.clone() } else { html_escape(&s.cell_text) }).unwrap_or_default() }; let bg = stored.and_then(|s| s.background).map(|c| format!(" bgcolor=\"#{:02X}{:02X}{:02X}\"", c[1], c[2], c[3])).unwrap_or_default(); cells.push((piece, bg, text)); @@ -812,12 +878,12 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu let name = unique(&stem, o.ext); let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; let out = join(&dir, &rel); - write(&out, &o.bytes)?; + write(&web_derived_path(&dir, &rel, native_paths)?, &o.bytes)?; total += o.bytes.len(); files.push(out); let alt = stored.map_or(String::new(), |s| html_escape(&s.alt)); let img = format!("\n\t\t\t\"{alt}\"", html_escape(&rel), piece.width(), piece.height()); - let body = match stored.filter(|s| !s.url.is_empty()) { + let body = match stored.filter(|s| safe_slice_url(&s.url)) { Some(s) => { let target = if s.target.is_empty() { String::new() } else { format!(" target=\"{}\"", html_escape(&s.target)) }; format!("\n\t\t\t{img}", html_escape(&s.url)) @@ -833,14 +899,14 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu let spacer = grid.then(|| if images.is_empty() { "spacer.gif".to_string() } else { format!("{images}/spacer.gif") }); if let Some(sp) = &spacer { let gif = photocraft_codecs::web::encode_gif_indexed(1, 1, &[0], &[[0, 0, 0]], Some(0), false).map_err(other)?; - write(&join(&dir, sp), &gif)?; + write(&web_derived_path(&dir, sp, native_paths)?, &gif)?; } let page = html_table(&base, wdoc.size.width, wdoc.size.height, &cells, spacer.as_deref()); let hp = join(&dir, &format!("{base}.html")); - write(&hp, page.as_bytes())?; + write(&web_derived_path(&dir, &format!("{base}.html"), native_paths)?, page.as_bytes())?; html_path = Some(hp); } - s.file_menu.last_web = Some(p.clone()); + s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) } @@ -1091,6 +1157,95 @@ fn asset_settings(spec: &AssetSpec) -> WebSettings { } } +fn is_windows_device_asset_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") + || stem.strip_prefix("COM").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) + || stem.strip_prefix("LPT").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) +} + +/// Layer names and `default` folders are document data, so reject unsafe output components. +fn asset_path_components(relative: &str) -> Result> { + // Split both separator styles even on Unix, where a backslash would otherwise be a legal + // filename. This also catches Windows drive, rooted, and UNC paths on every platform. + let mut parts = Vec::new(); + for part in relative.split(['/', '\\']) { + if part.is_empty() + || part == "." + || part == ".." + || part.ends_with('.') + || part.ends_with(' ') + || part.contains(':') + || part.contains('\0') + || is_windows_device_asset_name(part) + { + return Err(other(format!("asset path {relative:?} has an unsafe component {part:?}"))); + } + parts.push(part); + } + Ok(parts) +} + +/// Resolve a generated asset below the selected output directory. The atomic writer follows an +/// existing file symlink, so reject it and directory symlinks before writing. +fn asset_output_path(dir: &str, relative: &str) -> Result<(String, String)> { + if dir.is_empty() { + return Err(other("image assets need an output directory")); + } + let mut parts = asset_path_components(relative)?; + // Keep the caller's spelling in the result (for example /var on macOS), while writes use + // the canonical directory so a symlink in the selected root cannot move the destination. + let mut reported = std::path::PathBuf::from(dir); + for part in &parts { + reported.push(part); + } + let file = parts.pop().ok_or_else(|| other(format!("asset path {relative:?} is empty")))?; + let chosen_root: std::path::PathBuf = std::path::Path::new(dir).components().collect(); + std::fs::create_dir_all(&chosen_root).map_err(|e| other(format!("asset output directory {dir:?}: {e}")))?; + // The caller chose this root, so a symlink to an existing directory is intentional. All + // document-derived components below it are checked against its canonical target. + let root = std::fs::canonicalize(&chosen_root).map_err(|e| other(format!("asset output directory {dir:?}: {e}")))?; + let mut parent = root.clone(); + for part in parts { + parent.push(part); + match std::fs::symlink_metadata(&parent) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(other(format!("asset path {relative:?} crosses a symlink at {}", parent.display()))); + } + Ok(meta) if !meta.is_dir() => { + return Err(other(format!("asset path {relative:?} has a non-directory at {}", parent.display()))); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir(&parent).map_err(|e| other(format!("asset folder {}: {e}", parent.display())))?; + } + Err(e) => return Err(other(format!("asset folder {}: {e}", parent.display()))), + } + let actual = std::fs::canonicalize(&parent).map_err(|e| other(format!("asset folder {}: {e}", parent.display())))?; + if !actual.starts_with(&root) { + return Err(other(format!("asset path {relative:?} escapes output directory {dir:?}"))); + } + parent = actual; + } + let parent = std::fs::canonicalize(&parent).map_err(|e| other(format!("asset folder {}: {e}", parent.display())))?; + if !parent.starts_with(&root) { + return Err(other(format!("asset path {relative:?} escapes output directory {dir:?}"))); + } + let output = parent.join(file); + match std::fs::symlink_metadata(&output) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(other(format!("asset path {relative:?} points to a symlink"))); + } + Ok(meta) if !meta.is_file() => { + return Err(other(format!("asset path {relative:?} is not a file"))); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(other(format!("asset path {relative:?}: {e}"))), + } + Ok((output.to_string_lossy().into_owned(), reported.to_string_lossy().into_owned())) +} + /// Exports every asset named by `doc`'s layers into `dir`. Returns (files, errors). pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { let dpi = f64::from(doc.resolution_dpi.max(1.0)); @@ -1117,6 +1272,9 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { for (id, spec) in &jobs { for v in &variants { let r = (|| -> Result { + let (name_stem, ext) = spec.file.rsplit_once('.').unwrap_or((spec.file.as_str(), "png")); + let rel = format!("{}{}{}.{ext}", v.folder, name_stem, v.suffix); + asset_path_components(&rel)?; let mut one = crate::layer_menu_cmds::layer_document(doc, *id)?; one.metadata = Default::default(); let (w0, h0) = (f64::from(one.size.width), f64::from(one.size.height)); @@ -1141,17 +1299,16 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { let st = asset_settings(spec); let p = json!({"width": tw.round().max(1.0), "height": th.round().max(1.0)}); let (wdoc, _, _) = web_document(&one, &p, &WebSettings { convert_to_srgb: true, ..st.clone() })?; - let (name_stem, ext) = spec.file.rsplit_once('.').unwrap_or((spec.file.as_str(), "png")); - let rel = format!("{}{}{}.{ext}", v.folder, name_stem, v.suffix); - let out = join(dir, &rel); if spec.format == "webp" { + let (out, reported) = asset_output_path(dir, &rel)?; crate::file_cmds::save_doc(&wdoc, &out, None)?; - return Ok(out); + return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, wdoc.resolution_dpi, false)?; + let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; - Ok(out) + Ok(reported) })(); match r { Ok(f) => files.push(f), @@ -1221,7 +1378,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"html":bool=false,"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors} estimate; else {files, html, bytes}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 2e0cacc03..f64e309e9 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -134,6 +134,22 @@ fn platform_writer_receives_slices_spacer_and_html_with_sibling_references() { assert_eq!(s.file_menu.last_web.as_ref(), Some(¶ms)); } +#[test] +fn reserved_document_and_slice_names_export_with_portable_names() { + let dir = tmp("reserved-slice-names"); + let mut s = session(8); + s.edit("rename", |doc, _| { + doc.name = "aux.psd".into(); + Ok(()) + }) + .unwrap(); + s.execute("slice.new", json!({"rect": [0, 0, 16, 16], "name": "con"})).unwrap(); + let result = s.execute("file.export.saveForWebLegacy", json!({"dir": dir, "format": "png24", "html": true})).unwrap(); + assert!(result["html"].as_str().unwrap().ends_with("/aux_.html")); + assert!(result["files"].as_array().unwrap().iter().any(|f| f.as_str().unwrap().ends_with("/con_.png"))); + assert!(std::path::Path::new(result["html"].as_str().unwrap()).is_file()); +} + #[test] fn platform_writer_failure_stops_export_and_preserves_saved_settings() { let mut s = session(8); @@ -249,6 +265,103 @@ fn slices_export_with_html_table() { assert_eq!(decode(r["files"][0].as_str().unwrap()).dimensions(), (12, 12)); } +#[test] +fn slice_html_escapes_imported_markup_by_default_and_requires_trusted_opt_in() { + let dir = tmp("slice-html-safety"); + let mut s = session(8); + let slice = s + .execute( + "slice.new", + json!({"rect": [0, 0, 24, 20], "kind": "noImage", "name": "text", "cellTextIsHtml": true, + "cellText": "Trusted label"}), + ) + .unwrap(); + let default = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "html": true})).unwrap(); + let page = std::fs::read_to_string(default["html"].as_str().unwrap()).unwrap(); + assert!(page.contains("<script>alert(1)</script>")); + assert!(!page.contains("Trusted label")); + assert!(s.file_menu.last_web.as_ref().unwrap().get("trustedSliceHtml").is_none()); + + let plain_dir = tmp("slice-html-plain"); + s.execute("slice.set", json!({"slice": slice["slice"], "cellTextIsHtml": false})).unwrap(); + let plain = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": plain_dir, "html": true, "trustedSliceHtml": true})).unwrap(); + let page = std::fs::read_to_string(plain["html"].as_str().unwrap()).unwrap(); + assert!(page.contains("<script>alert(1)</script>")); +} + +#[test] +fn slice_links_allow_normal_urls_and_drop_active_schemes() { + let dir = tmp("slice-link-safety"); + let mut s = session(8); + for (x, url) in [ + (0, "javascript:alert(1)"), + (10, "data:text/html,"), + (20, "https://example.org/page?a=1&b=2"), + (30, "../relative/page.html#part"), + (40, "mailto:hello@example.org"), + (50, "JaVa\tscript:alert(1)"), + ] { + s.execute("slice.new", json!({"rect": [x, 0, 10, 10], "name": format!("link{x}"), "url": url})).unwrap(); + } + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "html": true, "slices": "user"})).unwrap(); + let page = std::fs::read_to_string(r["html"].as_str().unwrap()).unwrap(); + assert!(!page.contains("href=\"javascript:")); + assert!(!page.contains("href=\"data:")); + assert!(!page.contains("href=\"JaVa")); + assert!(page.contains("href=\"https://example.org/page?a=1&b=2\"")); + assert!(page.contains("href=\"../relative/page.html#part\"")); + assert!(page.contains("href=\"mailto:hello@example.org\"")); + assert_eq!(page.matches(">(), vec![std::path::Path::new(&dir).join("icon@2x.png")]); assert_eq!(decode(&files[0]).dimensions(), (128, 80)); } +#[test] +fn image_assets_reject_traversal_from_names_and_defaults() { + let base = tmp("asset-path-guard"); + let dir = format!("{base}/output"); + std::fs::create_dir_all(&dir).unwrap(); + let outside = tmp("asset-path-outside"); + let mut d = (*session(8).active().unwrap().doc).clone(); + let id = d.layers[1].id; + d.layer_mut(id).unwrap().name = + format!("nested/ok.png, ../escaped.png, ..\\backslash.png, {outside}/absolute.png, C:\\drive\\escape.png, CON.png, aux/escape.png, foo./trailing.png"); + let (files, errors) = generate_assets(&d, &dir); + assert_eq!(files.iter().map(|p| std::path::PathBuf::from(p.as_str())).collect::>(), vec![std::path::Path::new(&dir).join("nested").join("ok.png")]); + assert_eq!(errors.len(), 7, "{errors:?}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("unsafe component")), "{errors:?}"); + assert!(!std::path::Path::new(&format!("{base}/escaped.png")).exists()); + assert!(!std::path::Path::new(&format!("{outside}/absolute.png")).exists()); + + d.layer_mut(id).unwrap().name = "safe.png".into(); + d.layers.push(Layer::new( + "default ../ + /outside/ + ..\\bad/ + C:\\drive/ + 50% nested/", + LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None }), + )); + let (files, errors) = generate_assets(&d, &dir); + assert_eq!( + files.iter().map(|p| std::path::PathBuf::from(p.as_str())).collect::>(), + vec![std::path::Path::new(&dir).join("nested").join("safe.png")] + ); + assert_eq!(errors.len(), 4, "{errors:?}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("unsafe component")), "{errors:?}"); +} + +#[cfg(unix)] +#[test] +fn image_assets_reject_existing_symlinks_in_folders_and_files() { + let dir = tmp("asset-symlink-guard"); + let outside = tmp("asset-symlink-outside"); + let sentinel = format!("{outside}/sentinel.png"); + std::fs::write(&sentinel, b"unchanged").unwrap(); + let webp_sentinel = format!("{outside}/sentinel.webp"); + std::fs::write(&webp_sentinel, b"also unchanged").unwrap(); + std::os::unix::fs::symlink(&outside, format!("{dir}/linked")).unwrap(); + std::os::unix::fs::symlink(&sentinel, format!("{dir}/leaf.png")).unwrap(); + std::os::unix::fs::symlink(&webp_sentinel, format!("{dir}/leaf.webp")).unwrap(); + let mut d = (*session(8).active().unwrap().doc).clone(); + let id = d.layers[1].id; + d.layer_mut(id).unwrap().name = "linked/new/sub.png, leaf.png, leaf.webp, good/safe.png".into(); + let (files, errors) = generate_assets(&d, &dir); + assert_eq!(files, vec![format!("{dir}/good/safe.png")]); + assert_eq!(errors.len(), 3, "{errors:?}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("symlink")), "{errors:?}"); + assert!(!std::path::Path::new(&format!("{outside}/new")).exists()); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"unchanged"); + assert_eq!(std::fs::read(&webp_sentinel).unwrap(), b"also unchanged"); + + let linked_root = format!("{dir}/rootlink"); + std::os::unix::fs::symlink(&outside, &linked_root).unwrap(); + d.layer_mut(id).unwrap().name = "root.png".into(); + let (files, errors) = generate_assets(&d, &format!("{linked_root}/")); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(files, vec![format!("{linked_root}/root.png")]); + assert!(std::path::Path::new(&format!("{outside}/root.png")).is_file()); + + let mut s = session(8); + let exported = s.execute("file.export.saveForWebLegacy", json!({"dir": linked_root, "format": "png24", "html": true})).unwrap(); + assert!(exported["html"].as_str().unwrap().ends_with("/rootlink/web.html")); + assert!(std::path::Path::new(&format!("{outside}/web.html")).is_file()); +} + #[test] fn web_settings_from_params() { let st = WebSettings::from_params(&json!({"preset": "GIF 32 No Dither", "matte": "none", "webSnap": 50}), "x").unwrap(); diff --git a/crates/engine/tests/explicit_layer_target.rs b/crates/engine/tests/explicit_layer_target.rs new file mode 100644 index 000000000..6479b9c6d --- /dev/null +++ b/crates/engine/tests/explicit_layer_target.rs @@ -0,0 +1,74 @@ +//! A command that documents `"layer":id` checks its precondition on that layer, not on whichever +//! layer happens to be active (#466 for smart filters, generalised): a script, action or agent can +//! Replace Contents or Rasterize a smart object while another layer is selected. + +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{EngineError, Session}; +use photocraft_geom::Rect; +use serde_json::json; + +/// A smart object (blue) under a plain pixel layer, which is active. +fn session() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 6), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + let so = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + let pixels = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + (s, so, pixels) +} + +fn active(s: &Session) -> u64 { + s.active().unwrap().active_layer.unwrap().0 +} + +fn kind(s: &Session, id: u64) -> &'static str { + s.active().unwrap().doc.layer(LayerId(id)).unwrap().content.kind_name() +} + +#[test] +fn smart_object_commands_act_on_a_named_layer_while_another_is_active() { + let (mut s, so, pixels) = session(); + assert_eq!(active(&s), pixels); + // Without "layer" the active pixel layer decides, as the menus show. + assert!(!s.is_enabled("layer.smartObjects.replaceContents")); + // Replace Contents on the named smart object. + let dir = std::env::temp_dir().join(format!("pcraft-explicit-layer-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("red.png"); + let red = photocraft_doc::Document::with_background( + "r", + photocraft_geom::Size::new(8, 6), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), + ); + std::fs::write(&png, photocraft_io::export(&red, "png", &Default::default()).unwrap().bytes).unwrap(); + s.execute("layer.smartObjects.replaceContents", json!({"layer": so, "path": png.to_str().unwrap()})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(LayerId(so)).unwrap().content else { panic!("smart object") }; + assert_eq!(sm.cache.as_ref().unwrap().rgba(2, 2), [1.0, 0.0, 0.0, 1.0], "the named smart object was replaced"); + assert_eq!(active(&s), pixels, "the active layer stays"); + // Rasterize it, still without selecting it. + s.execute("layer.rasterize.smartObject", json!({"layer": so})).unwrap(); + assert_eq!(kind(&s, so), "Pixel"); + assert_eq!(active(&s), pixels); +} + +#[test] +fn a_named_layer_of_the_wrong_kind_or_that_does_not_exist_is_refused() { + let (mut s, _so, pixels) = session(); + let before = s.active().unwrap().doc.clone(); + // The named layer is checked: a pixel layer is not a smart object. + 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": "nope"})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(active(&s), pixels); +} diff --git a/crates/engine/tests/layer_mask_depth.rs b/crates/engine/tests/layer_mask_depth.rs new file mode 100644 index 000000000..082761286 --- /dev/null +++ b/crates/engine/tests/layer_mask_depth.rs @@ -0,0 +1,140 @@ +//! A native document and its PSD must quantize the same later mask edit alike. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, LayerMask}; +use photocraft_engine::Session; +use photocraft_raster::Surface; +use serde_json::json; + +fn session(depth: u64, mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "mode": mode})).unwrap(); + s +} + +fn mask(doc: &Document) -> &LayerMask { + doc.walk().into_iter().find_map(|(_, _, l)| l.mask.as_ref()).unwrap() +} + +#[test] +fn newly_created_layer_masks_follow_document_depth() { + for (depth, sample) in [(8, SampleType::U8), (16, SampleType::U16), (32, SampleType::F32)] { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for (command, selected) in [ + ("layer.newAdjustmentLayer.invert", false), + ("layer.newAdjustmentLayer.invert", true), + ("layer.newFillLayer.solidColor", false), + ("layer.newFillLayer.solidColor", true), + ("layer.layerMask.revealAll", false), + ("layer.layerMask.hideAll", false), + ("layer.layerMask.revealSelection", true), + ] { + let mut s = session(depth, mode); + if selected { + s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.execute(command, json!({"color": "#000000"})).unwrap(); + let d = s.active().unwrap(); + let m = mask(&d.doc); + assert_eq!(m.surface.format(), PixelFormat::new(ColorMode::Grayscale, sample, false), "{command}, {depth}-bit {mode}"); + assert!(m.enabled && m.linked); + assert_eq!((m.density, m.feather), (1.0, 0.0)); + let white = if command.ends_with("hideAll") { 0.0 } else { 1.0 }; + 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"); + } else { + assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse"); + assert_eq!(m.value(-300_000, 300_000), white); + } + assert_eq!(d.history.past_len(), history + 1); + let after = d.doc.clone(); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + s.redo(); + assert_eq!(*s.active().unwrap().doc, *after); + } + } + } +} + +#[test] +fn soft_mask_edits_match_after_native_and_psd_reopen() { + for depth in [8, 16, 32] { + let mut s = session(depth, "rgb"); + s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let native = photocraft_format::save_to_bytes(&before, &Default::default()).unwrap(); + let psd = photocraft_io::export(&before, "psd", &Default::default()).unwrap(); + assert!(psd.warnings.is_empty(), "{:?}", psd.warnings); + let mut branches = Vec::new(); + for reopened in [photocraft_format::load_from_bytes(&native).unwrap(), photocraft_io::import("mask.psd", &psd.bytes).unwrap().document] { + assert_eq!(mask(&reopened).surface.format(), mask(&before).surface.format(), "{depth}-bit mask depth survives reopening"); + assert_eq!(mask(&reopened).surface.read_region(before.bounds()), mask(&before).surface.read_region(before.bounds())); + assert_eq!(photocraft_compose::flatten(&reopened), photocraft_compose::flatten(&before)); + let layer = reopened.walk().into_iter().find_map(|(_, _, l)| l.mask.as_ref().map(|_| l.id.0)).unwrap(); + let mut edited = Session::new(); + edited.add_document(reopened, None); + let undo_doc = edited.active().unwrap().doc.clone(); + edited + .execute( + "paint.stroke", + json!({"layer": layer, "target": "mask", "color": "#000000", "points": [[20,18,1], [36,30,1]], "size": 18, "hardness": 0.4, "seed": 17}), + ) + .unwrap(); + let changed = edited.active().unwrap().doc.clone(); + let values = mask(&changed).surface.read_region(changed.bounds()); + assert!(values.iter().any(|v| *v > 0.0 && *v < 1.0), "a soft mask was painted"); + edited.undo(); + assert_eq!(*edited.active().unwrap().doc, *undo_doc); + edited.redo(); + assert_eq!(*edited.active().unwrap().doc, *changed); + branches.push(changed); + } + let a = mask(&branches[0]).surface.read_region(before.bounds()); + let b = mask(&branches[1]).surface.read_region(before.bounds()); + let changed = a.iter().zip(&b).filter(|(a, b)| a != b).count(); + let worst = a.iter().zip(&b).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert_eq!(changed, 0, "{depth}-bit mask edit depends on the save format (worst coverage difference {worst})"); + assert_eq!(photocraft_compose::flatten(&branches[0]), photocraft_compose::flatten(&branches[1]), "{depth}-bit composite depends on the save format"); + if depth > 8 { + assert!(a.iter().any(|v| (v * 255.0 - (v * 255.0).round()).abs() > 0.01), "{depth}-bit mask edits retain sub-8-bit coverage"); + } + } +} + +#[test] +fn mask_creation_preserves_selection_coverage_and_existing_masks() { + for depth in [8, 16, 32] { + for command in ["layer.newAdjustmentLayer.invert", "layer.layerMask.revealSelection", "gradient.fill.create"] { + let mut s = session(depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("synthetic coverage", |doc, _| { + let mut selection = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + selection.write_region(photocraft_geom::Rect::new(0, 0, 16, 16), &(0..256).map(|i| i as f32 / 255.0).collect::>()); + doc.selection = Some(selection); + let mut existing = LayerMask::reveal_all(); + existing.surface = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[0.123456]); + doc.layers[0].mask = Some(existing); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + s.execute(command, json!({"from": [0, 0], "to": [64, 48]})).unwrap(); + let after = &s.active().unwrap().doc; + let added = after.layer(s.active().unwrap().active_layer.unwrap()).unwrap().mask.as_ref().unwrap(); + assert_eq!( + added.surface.read_region(after.bounds()), + before.selection.as_ref().unwrap().read_region(before.bounds()), + "{depth}-bit coverage is unchanged" + ); + assert_eq!(after.selection, before.selection); + assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask"); + } + } +} diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 25d433db2..bf39fe276 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -32,6 +32,12 @@ use std::sync::atomic::{AtomicU64, Ordering}; pub trait AtomicFs { /// Create `tmp` (it must not exist), write all of `bytes` and flush it ([`sync_file`]). fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>; + /// [`write_new`](Self::write_new), reporting the fraction of `bytes` written so far to + /// `progress` (a save's progress bar, #3389). By default it reports nothing. + fn write_new_progress(&self, tmp: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { + let _ = progress; + self.write_new(tmp, bytes) + } /// Rename `from` over `to`, replacing it. fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; /// Remove a (temporary) file. @@ -47,10 +53,21 @@ pub trait AtomicFs { #[derive(Debug, Clone, Copy, Default)] pub struct RealFs; +/// How much [`RealFs::write_new_progress`] writes between progress reports. +const WRITE_CHUNK: usize = 8 << 20; + impl AtomicFs for RealFs { fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + self.write_new_progress(tmp, bytes, &|_| {}) + } + fn write_new_progress(&self, tmp: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; - f.write_all(bytes)?; + let mut written = 0usize; + for chunk in bytes.chunks(WRITE_CHUNK) { + f.write_all(chunk)?; + written = written.saturating_add(chunk.len()); + progress(written as f32 / bytes.len().max(1) as f32); + } sync_file(&f) } fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { @@ -132,8 +149,19 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { atomic_write_with(&RealFs, RenameRetry::platform(), path, bytes) } +/// [`atomic_write`] reporting the fraction of `bytes` written to `progress` as it goes (a save's +/// progress bar, #3389); the flush and rename follow the last report. +pub fn atomic_write_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { + atomic_write_with_progress(&RealFs, RenameRetry::platform(), path, bytes, progress) +} + /// [`atomic_write`] through an explicit file system and retry policy (the failure-injection seam). pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> { + atomic_write_with_progress(fs, retry, path, bytes, &|_| {}) +} + +/// [`atomic_write_with`] reporting write progress (see [`atomic_write_progress`]). +pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { let target = resolve_symlink(path); let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; let dir = match target.parent() { @@ -147,7 +175,7 @@ pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, byt let mut tmp = None; for _ in 0..8 { let candidate = dir.join(temp_name(&name.to_string_lossy())); - match fs.write_new(&candidate, bytes) { + match fs.write_new_progress(&candidate, bytes, progress) { Ok(()) => { tmp = Some(candidate); break; diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index c03c71cc2..8e71bc7ce 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -87,6 +87,24 @@ fn replaces_and_creates() { assert_no_temp(&d.0); } +/// #3389: a large write reports how far it has got, chunk by chunk, up to 1.0, and writes the +/// same bytes as a plain atomic write. +#[test] +fn reports_write_progress() { + let d = TempDir::new("progress"); + let p = d.0.join("big.psd"); + let bytes: Vec = (0..WRITE_CHUNK * 2 + 123).map(|i| (i % 251) as u8).collect(); + let seen = std::sync::Mutex::new(Vec::new()); + atomic_write_progress(&p, &bytes, &|f| seen.lock().unwrap().push(f)).unwrap(); + let seen = seen.into_inner().unwrap(); + assert_eq!(seen.len(), 3, "one report per chunk: {seen:?}"); + assert!(seen.windows(2).all(|w| w[0] < w[1]), "monotonic: {seen:?}"); + assert!(seen[0] > 0.0 && seen[0] < 0.6, "{seen:?}"); + assert_eq!(seen.last().copied(), Some(1.0)); + assert!(std::fs::read(&p).unwrap() == bytes, "the file holds the bytes"); + assert_no_temp(&d.0); +} + #[test] fn creates_missing_parent_folders() { let d = TempDir::new("parent"); diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 114cdf2c5..476741361 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -238,7 +238,9 @@ impl RecoveryStore { static STORES: AtomicU64 = AtomicU64::new(0); let t = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); let session = format!("{t:x}_{:x}_{:x}", std::process::id(), STORES.fetch_add(1, Ordering::Relaxed)); - RecoveryStore { dir: recovery_dir.into(), session, savers: HashMap::new(), adopted: HashMap::new() } + let dir = recovery_dir.into(); + sweep_orphans(&dir, ORPHAN_AGE); + RecoveryStore { dir, session, savers: HashMap::new(), adopted: HashMap::new() } } /// Load every recoverable document, newest first, with the entry it came from. Nothing is @@ -283,6 +285,81 @@ impl RecoveryStore { } } +/// How long recovery data must have been left untouched before [`sweep_orphans`] may remove +/// it. Far longer than any save takes, so another running instance's in-progress first +/// autosave (bundle written, sidecar not yet) is never mistaken for an orphan. +#[cfg(not(target_arch = "wasm32"))] +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); +/// - `*.pcsave.tmp` files left by an interrupted atomic write (see [`crate::atomic::temp_name`]) +/// that another process created. +/// +/// Only entries untouched for at least `min_age` are removed. Bundles with a sidecar, symlinks, +/// and anything outside `recovery_dir` are never touched; errors are ignored. +#[cfg(not(target_arch = "wasm32"))] +fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) { + let Ok(rd) = std::fs::read_dir(recovery_dir) else { return }; + let mut bundles = Vec::new(); + for e in rd.flatten() { + let Ok(ft) = e.file_type() else { continue }; + let path = e.path(); + if ft.is_file() { + sweep_temp_file(&path, min_age); + continue; + } + if !ft.is_dir() { + continue; + } + let name = e.file_name(); + let Some(key) = name.to_str().and_then(|n| n.strip_suffix(".pcraft")) else { continue }; + if key.is_empty() || sanitize(key) != key { + continue; + } + // 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)) { + let _ = std::fs::remove_dir_all(&path); + } else { + bundles.extend(parts); + } + } + for dir in bundles { + let Ok(rd) = std::fs::read_dir(&dir) else { continue }; + for e in rd.flatten() { + if e.file_type().is_ok_and(|ft| ft.is_file()) { + sweep_temp_file(&e.path(), min_age); + } + } + } +} + +/// Remove `path` if it is an atomic-write temp file of another process, untouched for `min_age`. +#[cfg(not(target_arch = "wasm32"))] +fn sweep_temp_file(path: &Path, min_age: std::time::Duration) { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return }; + // `.{stem}.{pid}-{n}.pcsave.tmp` + let Some(rest) = name.strip_prefix('.').and_then(|n| n.strip_suffix(".pcsave.tmp")) else { return }; + let Some((_, owner)) = rest.rsplit_once('.') else { return }; + let Some(pid) = owner.split_once('-').and_then(|(pid, _)| pid.parse::().ok()) else { return }; + if pid != std::process::id() && untouched_for(path, min_age, false) { + let _ = std::fs::remove_file(path); + } +} + +/// Whether `path` was last modified at least `min_age` ago. Unreadable times count as recent; +/// a missing path counts as untouched only when `missing_ok`. +#[cfg(not(target_arch = "wasm32"))] +fn untouched_for(path: &Path, min_age: std::time::Duration, missing_ok: bool) -> bool { + match std::fs::symlink_metadata(path) { + Ok(m) => m.modified().ok().and_then(|t| SystemTime::now().duration_since(t).ok()).is_some_and(|age| age >= min_age), + Err(e) => missing_ok && e.kind() == std::io::ErrorKind::NotFound, + } +} + fn remove_entry(dir: &Path, key: &str) -> Result<()> { let bundle = dir.join(format!("{key}.pcraft")); if bundle.exists() { @@ -318,4 +395,46 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("could not start"), "{msg}"); } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn sweep_removes_orphaned_bundles_and_foreign_temp_files_only() { + let dir = std::env::temp_dir().join(format!( + "pcraft-sweep-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) + )); + let doc = + Arc::new(Document::new("Kept", photocraft_doc::Size { width: 4, height: 4 }, photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8)); + let saver = Autosaver::new(&dir, "kept"); + saver.request(doc.clone(), 1, None, SaveOptions::default()); + assert!(saver.flush().is_some_and(|r| r.is_ok())); + // A first autosave that crashed before its sidecar was written. + let orphan = dir.join("doc-crashed-1.pcraft"); + std::fs::create_dir_all(orphan.join("tiles")).unwrap(); + std::fs::write(orphan.join("tiles/abc.zst"), b"partial").unwrap(); + // Stale temp files: another process's (swept) and this process's (kept, may be in flight). + let foreign = [dir.join(".kept.json.4294967294-3.pcsave.tmp"), dir.join("kept.pcraft/.manifest.json.4294967294-0.pcsave.tmp")]; + let own = dir.join(format!(".kept.json.{}-9.pcsave.tmp", std::process::id())); + for p in foreign.iter().chain([&own]) { + std::fs::write(p, b"tmp").unwrap(); + } + // Not ours to judge: unparsable temp names, unrelated files. + let other = dir.join("notes.txt"); + std::fs::write(&other, b"user file").unwrap(); + + // Fresh data is never swept. + sweep_orphans(&dir, std::time::Duration::from_secs(3600)); + assert!(orphan.is_dir() && foreign.iter().all(|p| p.is_file())); + + sweep_orphans(&dir, std::time::Duration::ZERO); + assert!(!orphan.exists(), "a bundle without a sidecar is swept"); + assert!(foreign.iter().all(|p| !p.exists()), "another process's temp files are swept"); + assert!(own.is_file(), "this process's temp files are kept"); + assert!(other.is_file()); + let entries = list_recovery(&dir); + assert_eq!(entries.len(), 1, "the complete entry survives"); + assert_eq!(recover(&entries[0]).unwrap(), *doc); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 4ee79f36b..d319c85a9 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -306,6 +306,11 @@ pub(crate) struct Loader<'a> { pub id_map: std::collections::HashMap, pub contents_id_map: std::collections::HashMap, pub legacy_linked_contents: std::collections::HashMap, + /// Decoded tiles by content hash and sample type. Saves store a tile once however often it + /// is referenced (a solid fill shares one tile), so a load decodes and charges the byte + /// budget once per distinct tile and hands every reference the same copy-on-write `Arc` + /// (#3315). Grouped by sample type: in-memory byte order depends on it. + pub tiles: std::collections::HashMap>>, } impl Loader<'_> { @@ -338,9 +343,21 @@ impl Loader<'_> { if s.tile(c).is_some() { return Err(FormatError::corrupt(format!("duplicate tile ({}, {})", t.tx, t.ty))); } + if let Some(tile) = self.tiles.get(&f.sample).and_then(|m| m.get(&t.hash)) { + // One hash names one byte string, so a cached tile of another length means + // the same hash was referenced from formats of different pixel sizes. + if tile.bytes().len() != len { + return Err(FormatError::corrupt(format!("tile {} is referenced with different sizes", t.hash))); + } + s.put_tiles([(c, tile.clone())]); + continue; + } let mut bytes = self.fetch.tile(&t.hash, len)?; swap_to_le(&mut bytes, f.sample); s.tile_mut(c).bytes_mut().copy_from_slice(&bytes); + if let Some(tile) = s.tile(c) { + self.tiles.entry(f.sample).or_default().insert(t.hash.clone(), tile.clone()); + } } Ok(s) } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 961ec37f1..0c8772021 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -37,7 +37,7 @@ use std::path::Path; use photocraft_doc::Document; use photocraft_raster::Rgba8Image; -pub use atomic::atomic_write; +pub use atomic::{atomic_write, atomic_write_progress}; #[cfg(not(target_arch = "wasm32"))] pub use autosave::RecoveryStore; pub use autosave::{Autosaver, RecoveryEntry, discard_recovery, list_recovery, recover}; diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index d7f6d4596..674c99f21 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -1,7 +1,7 @@ //! Object storage (ZIP or directory), incremental writer and loader. use std::collections::{BTreeMap, HashMap, HashSet}; -use std::io::Read; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Weak}; use std::time::SystemTime; @@ -269,6 +269,7 @@ pub(crate) fn load(src: &dyn ByteSource, opts: &LoadOptions) -> Result id_map: HashMap::new(), contents_id_map: HashMap::new(), legacy_linked_contents: HashMap::new(), + tiles: HashMap::new(), }; loader.document(&m.document) } @@ -443,14 +444,137 @@ fn file_signature(metadata: &std::fs::Metadata) -> Option { } fn path_signature(path: &Path) -> Result> { - match std::fs::metadata(path) { + match std::fs::symlink_metadata(path) { Ok(metadata) if metadata.is_file() => Ok(file_signature(&metadata)), - Ok(_) => Ok(None), + Ok(_) => Err(unsafe_bundle_entry(path)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } +fn unsafe_bundle_entry(path: &Path) -> FormatError { + FormatError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("refusing directory bundle save: {} is a symlink or not a regular file", path.display()), + )) +} + +/// The generic atomic writer intentionally follows a destination symlink. Directory bundles +/// must reject one instead: all of their managed entries stay below the bundle root. +fn plain_file_or_missing(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) => Err(unsafe_bundle_entry(path)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + +fn plain_directory_or_missing(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => Ok(()), + Ok(_) => Err(FormatError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("refusing directory bundle save: {} is a symlink or not a directory", path.display()), + ))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// Use the canonical root after creation so a later replacement of the caller's path cannot +/// redirect subsequent bundle operations. The caller may choose a symlinked root, but managed +/// subdirectories and files below its resolved target must not be symlinks. +fn bundle_root(dir: &Path) -> Result { + let dir: PathBuf = dir.components().collect(); + std::fs::create_dir_all(&dir)?; + let root = std::fs::canonicalize(&dir)?; + plain_directory_or_missing(&root)?; + for sub in ["tiles", "blobs", "composite"] { + let path = root.join(sub); + plain_directory_or_missing(&path)?; + if !path.exists() { + match std::fs::create_dir(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + plain_directory_or_missing(&path)?; + if std::fs::canonicalize(&path)? != path { + return Err(unsafe_bundle_entry(&path)); + } + } + Ok(root) +} + +fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> { + let parent = path.parent().ok_or_else(|| unsafe_bundle_entry(path))?; + if parent != root && parent != root.join("tiles") && parent != root.join("blobs") && parent != root.join("composite") { + return Err(unsafe_bundle_entry(path)); + } + plain_directory_or_missing(parent)?; + if std::fs::canonicalize(parent)? != parent { + return Err(unsafe_bundle_entry(parent)); + } + plain_file_or_missing(path) +} + +fn write_bundle_file(root: &Path, path: &Path, data: &[u8]) -> Result<()> { + validate_bundle_file(root, path)?; + let parent = path.parent().ok_or_else(|| unsafe_bundle_entry(path))?; + let name = path.file_name().ok_or_else(|| unsafe_bundle_entry(path))?; + let mut temporary = None; + for _ in 0..8 { + let candidate = parent.join(crate::atomic::temp_name(&name.to_string_lossy())); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) { + Ok(file) => { + temporary = Some((candidate, file)); + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + let (tmp, mut file) = temporary.ok_or_else(|| { + FormatError::Io(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("could not create a temporary file beside {}", path.display()))) + })?; + let result = (|| -> Result<()> { + // The parent may have changed between validation and create_new. Check the new file's + // resolved location before putting document bytes into it. + if std::fs::canonicalize(&tmp)? != tmp { + return Err(unsafe_bundle_entry(&tmp)); + } + validate_bundle_file(root, path)?; + file.write_all(data)?; + file.sync_all()?; + drop(file); + // Preserve the existing mode where possible, as the generic atomic writer does. + if let Ok(permissions) = std::fs::metadata(path).map(|m| m.permissions()) { + let _ = std::fs::set_permissions(&tmp, permissions); + } + // Recheck the parent and target just before publishing. rename replaces a symlink + // itself, never its referent, even if one appears after this check. + validate_bundle_file(root, path)?; + crate::atomic::retry_rename(crate::atomic::RenameRetry::platform(), || std::fs::rename(&tmp, path))?; + #[cfg(unix)] + { + let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); + } + Ok(()) + })(); + if result.is_err() && validate_bundle_file(root, &tmp).is_ok() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +fn remove_bundle_file(root: &Path, path: &Path) -> Result<()> { + validate_bundle_file(root, path)?; + std::fs::remove_file(path)?; + Ok(()) +} + fn existing_object_is_valid(path: &Path, object: &Object, what: &str) -> Result<(bool, Option)> { let mut file = match std::fs::File::open(path) { Ok(file) => file, @@ -608,16 +732,19 @@ impl PcraftWriter { /// rewrites missing or damaged objects, then writes the manifest /// atomically and removes unreferenced objects. pub fn save_dir(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions) -> Result { + let canonical_dir = bundle_root(dir)?; + let dir = canonical_dir.as_path(); + let existing = list_objects(dir)?; + // Reject a hostile existing entry before writing any object or manifest. This also + // covers entries that this save would otherwise leave untouched or garbage-collect. + for path in [MANIFEST, THUMB, COMPOSITE] { + validate_bundle_file(dir, &dir.join(path))?; + } let p = self.prepare(doc, opts)?; let mut stats = p.stats; - for sub in ["tiles", "blobs", "composite"] { - std::fs::create_dir_all(dir.join(sub))?; - } - let canonical_dir = std::fs::canonicalize(dir)?; if self.verified_directory.as_ref().is_none_or(|cache| cache.directory != canonical_dir) { - self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir, ..Default::default() }); + self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir.clone(), ..Default::default() }); } - let existing = list_objects(dir)?; let generation = match &mut self.verified_directory { Some(cache) => { cache.generation = cache.generation.saturating_add(1); @@ -628,6 +755,9 @@ impl PcraftWriter { let recheck = self.rolling_recheck(&p.objects); for (path, obj) in &p.objects { let object_path = dir.join(path); + // bundle_root and list_objects validated the fixed parent directories once. Check + // each leaf here; write_bundle_file rechecks its parent around every mutation. + plain_file_or_missing(&object_path)?; let signature = path_signature(&object_path)?; let already_verified = !recheck.contains(path) && signature.is_some_and(|signature| { @@ -657,7 +787,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_atomic(&object_path, &obj.compressed())?; + write_bundle_file(dir, &object_path, &obj.compressed())?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -671,18 +801,27 @@ impl PcraftWriter { } } for (name, data) in &p.previews { - write_atomic(&dir.join(name), data)?; + write_bundle_file(dir, &dir.join(name), data)?; } for stale in [THUMB, COMPOSITE] { if !p.previews.iter().any(|(n, _)| *n == stale) { - let _ = std::fs::remove_file(dir.join(stale)); + let path = dir.join(stale); + validate_bundle_file(dir, &path)?; + match remove_bundle_file(dir, &path) { + Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {} + other => other?, + } } } - write_atomic(&dir.join(MANIFEST), &p.manifest)?; + write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?; + // The manifest is committed: the save has succeeded. Removing objects it no longer + // references is best-effort housekeeping (a vanished or locked file must not turn a + // complete save into a reported failure); a leftover is retried on the next save. for path in existing { if !p.objects.contains_key(&path) { - std::fs::remove_file(dir.join(&path))?; - stats.objects_removed += 1; + if remove_bundle_file(dir, &dir.join(&path)).is_ok() { + stats.objects_removed += 1; + } if let Some(cache) = &mut self.verified_directory { cache.objects.remove(&path); } @@ -731,12 +870,28 @@ impl PcraftWriter { fn list_objects(dir: &Path) -> Result> { let mut out = HashSet::new(); for sub in ["tiles", "blobs"] { - let rd = std::fs::read_dir(dir.join(sub))?; + let object_dir = dir.join(sub); + plain_directory_or_missing(&object_dir)?; + if std::fs::canonicalize(&object_dir)? != object_dir { + return Err(unsafe_bundle_entry(&object_dir)); + } + let rd = std::fs::read_dir(&object_dir)?; for e in rd { let e = e?; let name = e.file_name().to_string_lossy().into_owned(); if name.ends_with(".zst") { - out.insert(format!("{sub}/{name}")); + let path = e.path(); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => return Err(unsafe_bundle_entry(&path)), + Ok(metadata) if metadata.is_file() => { + out.insert(format!("{sub}/{name}")); + } + // Stale directories cannot be removed with remove_file; leave them for later + // cleanup without failing a save of otherwise valid content. + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } } } } diff --git a/crates/format/tests/autosave.rs b/crates/format/tests/autosave.rs index 95e4e9daf..29905d7ac 100644 --- a/crates/format/tests/autosave.rs +++ b/crates/format/tests/autosave.rs @@ -236,3 +236,21 @@ fn sidecars_cannot_point_at_other_bundles() { assert_eq!(entries.iter().map(|e| e.info.key.as_str()).collect::>(), ["k"]); std::fs::remove_dir_all(dir).unwrap(); } + +/// Once the manifest is committed the save has succeeded: an unreferenced object that cannot be +/// removed (here a folder squatting on a tile name, like a locked or vanished file) must not turn +/// the directory save (autosave's path) into a reported failure. +#[test] +fn failed_cleanup_after_commit_does_not_fail_the_save() { + let dir = temp_dir("cleanup-best-effort").join("doc.pcraft"); + std::fs::create_dir_all(&dir).unwrap(); + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let mut w = PcraftWriter::new(); + w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + let stuck = dir.join("tiles").join("0000000000000000000000000000000000000000000000000000000000000000.zst"); + std::fs::create_dir_all(stuck.join("inner")).unwrap(); + let stats = w.save_dir(&doc, &dir, &SaveOptions::default()).expect("the committed save reports success"); + assert_eq!(stats.objects_removed, 0, "nothing was actually removed"); + assert_eq!(load_path(&dir).unwrap(), doc); + std::fs::remove_dir_all(dir.parent().unwrap()).unwrap(); +} diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/common/mod.rs index b4940ecd1..ef840cfc8 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/common/mod.rs @@ -228,7 +228,8 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3]), photocraft_doc::DuotoneInk::new("PANTONE 151 C", [1.0, 0.5, 0.0])], psd_raw: Some(vec![1, 2, 3]), }); - d.guides = Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25] }; + d.guides = + Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25], colors: vec![GuideColor { vertical: true, position: 100.25, rgb: [0x33, 0x66, 0xff] }] }; let mut sel = Surface::new(PixelFormat::GRAY8); scribble(&mut sel, 14, false); d.selection = Some(sel); diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index 9215e7cd1..2f47203b3 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -154,6 +154,37 @@ fn total_size_limit() { assert!(matches!(load_from_bytes_with(&b, &opts), Err(FormatError::LimitExceeded(_)))); } +/// A hostile manifest may name one stored tile at very many coordinates. The load decodes and +/// charges it once, each reference only clones the shared tile, and the budget still bounds the +/// distinct bytes (#3315). +#[test] +fn many_references_to_one_tile_load_within_the_budget() { + let mut doc = photocraft_doc::Document::new("d", photocraft_doc::Size::new(256, 256), ColorMode::Rgb, SampleType::U8); + let mut l = photocraft_doc::Layer::raster("solid", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 256, 256), &[1.0, 0.0, 0.0, 1.0]); + doc.layers.push(l); + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let hostile = rebuild(&bytes, |name, data| { + if name != "manifest.json" { + return Some(data); + } + let mut m: serde_json::Value = serde_json::from_slice(&data).unwrap(); + let tiles = m["document"]["layers"][0]["content"]["surface"]["tiles"].as_array_mut().unwrap(); + let hash = tiles[0]["hash"].clone(); + *tiles = (0..256 * 256).map(|i| serde_json::json!({ "tx": i % 256, "ty": i / 256, "hash": hash })).collect(); + Some(serde_json::to_vec(&m).unwrap()) + }); + let one_tile = 256 * 256 * 4; + let back = load_from_bytes_with(&hostile, &LoadOptions { max_total_bytes: one_tile, ..Default::default() }).unwrap(); + let s = back.layers[0].surface().unwrap(); + assert_eq!(s.tile_count(), 256 * 256); + let first = s.tiles().next().unwrap().1; + assert!(s.tiles().all(|(_, t)| std::sync::Arc::ptr_eq(t, first))); + // The one distinct tile is still charged against the budget. + let opts = LoadOptions { max_total_bytes: one_tile - 1, ..Default::default() }; + assert!(matches!(load_from_bytes_with(&hostile, &opts), Err(FormatError::LimitExceeded(_)))); +} + #[test] fn directory_bundle_resave_repairs_tampered_objects() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 537515cff..42dbfae5c 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -257,6 +257,103 @@ fn directory_incremental_and_gc() { std::fs::remove_dir_all(dir).unwrap(); } +#[cfg(unix)] +#[test] +fn directory_save_rejects_object_directory_symlink_without_deleting_external_objects() { + use std::os::unix::fs::symlink; + + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("symlink-object-dir"); + let outside = temp_dir("symlink-object-dir-outside"); + let external_object = outside.join("unrelated.zst"); + std::fs::write(&external_object, b"keep this object").unwrap(); + symlink(&outside, dir.join("tiles")).unwrap(); + + assert!(PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).is_err()); + assert_eq!(std::fs::read(&external_object).unwrap(), b"keep this object"); + assert!(!dir.join("manifest.json").exists()); + + std::fs::remove_file(dir.join("tiles")).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); +} + +#[cfg(unix)] +#[test] +fn directory_save_rejects_object_symlink_without_overwriting_target() { + use std::os::unix::fs::symlink; + + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("symlink-object"); + let outside = temp_dir("symlink-object-outside"); + let mut writer = PcraftWriter::new(); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + let object = std::fs::read_dir(dir.join("tiles")).unwrap().next().unwrap().unwrap().path(); + std::fs::remove_file(&object).unwrap(); + let external_object = outside.join("external.zst"); + std::fs::write(&external_object, b"do not overwrite").unwrap(); + symlink(&external_object, &object).unwrap(); + + assert!(writer.save_dir(&doc, &dir, &SaveOptions::default()).is_err()); + assert_eq!(std::fs::read(&external_object).unwrap(), b"do not overwrite"); + assert!(std::fs::symlink_metadata(&object).unwrap().file_type().is_symlink()); + + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); +} + +#[cfg(unix)] +#[test] +fn directory_save_accepts_chosen_root_symlink_but_rejects_preview_symlink() { + use std::os::unix::fs::symlink; + + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let outside = temp_dir("symlink-preview-outside"); + let root_link_parent = temp_dir("symlink-root-parent"); + let root_link = root_link_parent.join("linked.pcraft"); + symlink(&outside, &root_link).unwrap(); + PcraftWriter::new().save_dir(&doc, &root_link, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&root_link).unwrap(), doc); + let trailing_separator = std::path::PathBuf::from(format!("{}/", root_link.display())); + PcraftWriter::new().save_dir(&doc, &trailing_separator, &SaveOptions::default()).unwrap(); + assert!(outside.join("manifest.json").is_file()); + + let dir = temp_dir("symlink-preview"); + let external_preview = outside.join("preview.png"); + std::fs::write(&external_preview, b"keep preview").unwrap(); + std::fs::create_dir(dir.join("composite")).unwrap(); + symlink(&external_preview, dir.join("composite/preview.png")).unwrap(); + let opts = SaveOptions { composite: Some(Rgba8Image::new(2, 2)), ..Default::default() }; + assert!(PcraftWriter::new().save_dir(&doc, &dir, &opts).is_err()); + assert_eq!(std::fs::read(&external_preview).unwrap(), b"keep preview"); + assert!(!dir.join("manifest.json").exists()); + + std::fs::remove_file(root_link).unwrap(); + std::fs::remove_dir_all(root_link_parent).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); +} + +#[cfg(unix)] +#[test] +fn directory_save_rejects_manifest_symlink_without_overwriting_target() { + use std::os::unix::fs::symlink; + + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("symlink-manifest"); + let outside = temp_dir("symlink-manifest-outside"); + let external_manifest = outside.join("external.json"); + std::fs::write(&external_manifest, b"keep manifest").unwrap(); + symlink(&external_manifest, dir.join("manifest.json")).unwrap(); + + assert!(PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).is_err()); + assert_eq!(std::fs::read(&external_manifest).unwrap(), b"keep manifest"); + assert_eq!(std::fs::read_dir(dir.join("tiles")).unwrap().count(), 0); + + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); +} + #[test] fn identical_tiles_are_stored_once() { let mut doc = Document::new("d", photocraft_doc::Size::new(10, 10), ColorMode::Rgb, SampleType::U8); @@ -270,6 +367,28 @@ fn identical_tiles_are_stored_once() { assert_eq!(s.tiles_total, per_layer); } +/// A solid fill saves one tile however many coordinates share it; reopening must decode it once, +/// charge the byte budget once and keep the sharing (#3315). The budget here holds one tile, +/// standing in for a canvas too large to expand tile by tile. +#[test] +fn solid_fill_reopens_with_one_shared_tile() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let size = photocraft_doc::Size::new(2048, 2048); + let mut doc = Document::new("d", size, ColorMode::Rgb, depth); + let mut l = Layer::raster("solid", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::from_size(size), &[1.0, 0.5, 0.25, 1.0]); + doc.layers.push(l); + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let one_tile = (256 * 256 * doc.pixel_format().bytes_per_pixel()) as u64; + let back = load_from_bytes_with(&bytes, &LoadOptions { max_total_bytes: one_tile, ..Default::default() }).unwrap(); + assert_eq!(back, doc, "{depth:?}"); + let s = back.layers[0].surface().unwrap(); + assert_eq!(s.tile_count(), 64, "{depth:?}"); + let first = s.tiles().next().unwrap().1; + assert!(s.tiles().all(|(_, t)| std::sync::Arc::ptr_eq(t, first)), "{depth:?}: tiles not shared after reopening"); + } +} + #[test] fn ids_preserved_and_counter_advanced() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 488b9a2f7..9c8f13874 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -2,15 +2,18 @@ //! //! Two kinds of warp, both forward maps from a source box to document space: //! - **Styles** ([`WarpStyle`], Photoshop's fifteen presets plus bend and horizontal/vertical -//! distortion, the PSD `warp` descriptor's `warpStyle`/`warpValue`/`warpPerspective*`). The -//! shapes are our own closed-form approximations of each style's look (observed behaviour, not -//! Adobe's formulas). Every style is the identity at bend 0 and continuous in the bend. +//! distortion, the PSD `warp` descriptor's `warpStyle`/`warpValue`/`warpPerspective*`). Each +//! is a single bicubic Bezier patch whose control grid follows the bend: measured from +//! Photoshop's renders (see [`preset_grid`]), not taken from Adobe's code. Every style is the +//! identity at bend 0 and continuous in the bend. //! - **Custom meshes** ([`BezierMesh`]): a grid of bicubic Bezier patches. The default is one //! patch (a 4×4 control grid); split warps add patch rows/columns by exact de Casteljau //! subdivision, so splitting never changes the shape. //! //! Pure geometry: resampling lives in `photocraft-algo::warp`. +use std::f64::consts::PI; + use serde::{Deserialize, Serialize}; /// Warp style: none, a custom mesh, or one of Photoshop's presets (menu order). @@ -144,21 +147,19 @@ impl WarpStyle { } } -/// A preset style prepared for one box: strengths and the box it bends. +/// A preset style prepared for one box: its control grid and the box it bends. #[derive(Clone, Copy, Debug)] pub struct StyleWarp { - style: WarpStyle, - /// Bend, -1..1. - b: f64, - /// Horizontal / vertical distortion, -1..1. - hd: f64, - vd: f64, - /// Warp along the vertical axis (Photoshop's "Vertical" orientation). - vertical: bool, - c: (f64, f64), - h: (f64, f64), + /// The 4 × 4 Bezier control grid in box units (0..1, y down), row-major. + grid: Grid, + /// Box origin and size. + o: (f64, f64), + size: (f64, f64), } +/// A single bicubic Bezier patch's control points, `[row][column]`, in box units. +type Grid = [[[f64; 2]; 4]; 4]; + impl StyleWarp { /// Prepares `style` with bend / distortions in percent (-100..100) for `[x0, y0, x1, y1]`. /// `None` for non-presets, an all-zero warp or an empty box. @@ -176,98 +177,219 @@ impl StyleWarp { if b == 0.0 && hd == 0.0 && vd == 0.0 { return None; } - Some(StyleWarp { style, b, hd, vd, vertical, c: ((x0 + x1) / 2.0, (y0 + y1) / 2.0), h: (hw, hh) }) + let grid = preset_grid(style, b, hd, vd, vertical, hh / hw); + Some(StyleWarp { grid, o: (x0, y0), size: (2.0 * hw, 2.0 * hh) }) } /// Longest segment (px) worth sending through [`StyleWarp::apply`] unsplit. pub fn max_segment(&self) -> f64 { - (self.h.0.min(self.h.1) / 16.0).max(0.5) + (self.size.0.min(self.size.1) / 32.0).max(0.5) } /// Maps a point. pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { - // Normalised coordinates: u along the warp axis, v across it, both -1..1 over the box. - let (dx, dy) = (x - self.c.0, y - self.c.1); - let (u, v, hu, hv) = - if self.vertical { (dy / self.h.1, dx / self.h.0, self.h.1, self.h.0) } else { (dx / self.h.0, dy / self.h.1, self.h.0, self.h.1) }; - let (mut u2, mut v2) = self.bend(u, v, hu / hv); - // Distortions: a perspective-like taper along each axis. - if self.hd != 0.0 { - v2 *= (1.0 + self.hd * u2).max(0.05); + let (u, v) = ((x - self.o.0) / self.size.0, (y - self.o.1) / self.size.1); + let (bu, bv) = (bernstein(u), bernstein(v)); + let (mut px, mut py) = (0.0, 0.0); + for (row, wv) in self.grid.iter().zip(bv) { + for (p, wu) in row.iter().zip(bu) { + px += wu * wv * p[0]; + py += wu * wv * p[1]; + } } - if self.vd != 0.0 { - u2 *= (1.0 + self.vd * v2).max(0.05); - } - let (ox, oy) = if self.vertical { (v2 * self.h.0, u2 * self.h.1) } else { (u2 * self.h.0, v2 * self.h.1) }; - (self.c.0 + ox, self.c.1 + oy) + (self.o.0 + px * self.size.0, self.o.1 + py * self.size.1) } - /// The style's mapping in normalised space; `aspect` = half-length along / across the axis. - fn bend(&self, u: f64, v: f64, aspect: f64) -> (f64, f64) { - use std::f64::consts::{FRAC_PI_2, PI}; - let b = self.b; - let bell = (1.0 - u * u).max(0.0); - match self.style { - WarpStyle::Arc => arc(u, v, b, aspect), - WarpStyle::ArcLower => lerp2((u, v), arc(u, v, b, aspect), (v + 1.0) / 2.0), - WarpStyle::ArcUpper => lerp2((u, v), arc(u, v, b, aspect), (1.0 - v) / 2.0), - // Every column moves up (b > 0) by the same parabola; verticals stay vertical. - WarpStyle::Arch => (u, v - b * bell), - WarpStyle::Bulge => (u, v * (1.0 + b * bell)), - // The bottom (top) edge bows out while the opposite edge stays straight. - WarpStyle::ShellLower => (u, v + b * bell * (v + 1.0) / 2.0), - WarpStyle::ShellUpper => (u, v - b * bell * (1.0 - v) / 2.0), - WarpStyle::Flag => (u, v - b * 0.5 * (PI * u).sin()), - WarpStyle::Wave => (u, v - b * 0.5 * (PI * u + v * FRAC_PI_2).sin()), - // A body swelling towards the head (left) and a narrow tail, curving up. - WarpStyle::Fish => (u, v * (1.0 + b * 0.6 * (1.0 - u) * bell.sqrt()) - b * 0.35 * bell), - WarpStyle::Rise => (u, v - b * 0.5 * (FRAC_PI_2 * u).sin()), - WarpStyle::Fisheye => { - let r2 = (u * u + v * v).min(1.0); - let k = 1.0 + b * (1.0 - r2); - (u * k, v * k) - } - WarpStyle::Inflate => { - let (bu, bv) = ((1.0 - v * v).max(0.0), bell); - (u * (1.0 + b * 0.5 * bu), v * (1.0 + b * 0.5 * bv)) - } - WarpStyle::Squeeze => (u * (1.0 + b * 0.5 * (1.0 - v * v).max(0.0)), v * (1.0 - b * 0.5 * bell)), - WarpStyle::Twist => { - let r = (u * u + v * v).sqrt(); - if r >= 1.0 { - return (u, v); - } - // Rotate in an isotropic frame so the twist stays circular on wide boxes. - let a = b * FRAC_PI_2 * (1.0 - r) * (1.0 - r); - let (s, c) = a.sin_cos(); - let (x, y) = (u * aspect, v); - ((x * c - y * s) / aspect, x * s + y * c) - } - WarpStyle::None | WarpStyle::Custom => (u, v), - } + /// The style as one Bezier patch over its box (every preset is one exactly). + pub fn mesh(&self) -> BezierMesh { + let points = self.grid.iter().flatten().map(|p| [self.o.0 + p[0] * self.size.0, self.o.1 + p[1] * self.size.1]).collect(); + BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points } } } -/// Along a circular arc: the box centre line becomes an arc spanning `b × 180°`. -fn arc(u: f64, v: f64, b: f64, aspect: f64) -> (f64, f64) { - if b.abs() < 1e-6 { - return (u, v); - } - // Work in units of the half-height so the circle is round; arc length of the centre line - // equals the box half-length at the ends. - let half = aspect; - let theta_max = b * std::f64::consts::FRAC_PI_2; - let r = half / theta_max; - let theta = u * theta_max; - let rr = r - v; - let x = rr * theta.sin(); - let y = r - rr * theta.cos(); - (x / half, y) +/// The rest grid (no warp). +fn rest_grid() -> Grid { + std::array::from_fn(|j| std::array::from_fn(|i| [i as f64 / 3.0, j as f64 / 3.0])) } -fn lerp2(a: (f64, f64), b: (f64, f64), t: f64) -> (f64, f64) { - let t = t.clamp(0.0, 1.0); - (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) +/// A horizontal edge at height `y` (box units, 0..1 across) bent into a circular arc of `|b|·π` +/// that keeps its chord, bowing up when `up == (b > 0)`. `h` is the box's height over its width: +/// the arc is circular in pixels. +fn edge_arc(y: f64, b: f64, up: bool, h: f64) -> [[f64; 2]; 4] { + if b.abs() < 1e-9 { + return std::array::from_fn(|i| [i as f64 / 3.0, y]); + } + let th = b.abs() * PI; + let k = 4.0 / 3.0 * (th / 4.0).tan() * 0.5 / (th / 2.0).sin(); + let s = if (b > 0.0) == up { -1.0 } else { 1.0 }; + let (c, n) = ((th / 2.0).cos(), (th / 2.0).sin()); + let dy = s * k * n / h; + [[0.0, y], [k * c, y + dy], [1.0 - k * c, y + dy], [1.0, y]] +} + +/// Arc: every row is a circular arc of `|b|·π` around one centre; the edge on the inside of the +/// bend keeps its chord, and the rows' radii grow by the box height towards the other edge. +/// Worked in units of the box width (`h` = height / width) so the arcs are circular in pixels. +fn arc_grid(b: f64, h: f64) -> Grid { + if b.abs() < 1e-9 { + return rest_grid(); + } + let up = b > 0.0; + let th = b.abs() * PI; + let r = 0.5 / (th / 2.0).sin(); + let k = 4.0 / 3.0 * (th / 4.0).tan(); + let s = if up { -1.0 } else { 1.0 }; + let cy = if up { h + r * (th / 2.0).cos() } else { -r * (th / 2.0).cos() }; + std::array::from_fn(|j| { + let v = j as f64 / 3.0; + let big = r + h * if up { 1.0 - v } else { v }; + let at = |a: f64| [0.5 + big * a.sin(), cy + s * big * a.cos()]; + let tangent = |a: f64| [a.cos(), -s * a.sin()]; + let (p0, p3, t0, t1) = (at(-th / 2.0), at(th / 2.0), tangent(-th / 2.0), tangent(th / 2.0)); + let l = k * big; + let pts = [p0, [p0[0] + l * t0[0], p0[1] + l * t0[1]], [p3[0] - l * t1[0], p3[1] - l * t1[1]], p3]; + pts.map(|p| [p[0], p[1] / h]) + }) +} + +/// Rows blended from `top` to `bottom`. +fn blend_rows(top: [[f64; 2]; 4], bottom: [[f64; 2]; 4]) -> Grid { + std::array::from_fn(|j| { + let t = j as f64 / 3.0; + std::array::from_fn(|i| [top[i][0] + (bottom[i][0] - top[i][0]) * t, top[i][1] + (bottom[i][1] - top[i][1]) * t]) + }) +} + +/// Photoshop's warp presets as Bezier control grids, measured on Photoshop 27.11: every preset at +/// bend ±50 and ±100, Arc and Bulge with ±50 horizontal and vertical distortion, and the vertical +/// orientation, each fitted from scripted renders of a coordinate-coded grid image (every preset +/// is a single bicubic patch, within 0.5 px). These grids put Photoshop's pixels back within +/// 1 px (median) on all 70 cases, and on 32 more on 256 × 96 and 96 × 256 boxes, vertical, and +/// with both distortions: the arc family stays circular in pixels, the other offsets scale with +/// the box, and a vertical warp is the horizontal one with the axes (and distortions) swapped. +/// `b`, `hd`, `vd` are -1..1; `aspect` is the box's height over its width. +fn preset_grid(style: WarpStyle, b: f64, hd: f64, vd: f64, vertical: bool, aspect: f64) -> Grid { + // Height over width of the box the bend runs along (the transposed box when vertical); in + // that frame the two distortions trade places too. + let h = if vertical { 1.0 / aspect } else { aspect }; + let (hd, vd) = if vertical { (vd, hd) } else { (hd, vd) }; + let mut g = rest_grid(); + match style { + WarpStyle::Arc => g = arc_grid(b, h), + WarpStyle::Arch => g = std::array::from_fn(|j| edge_arc(j as f64 / 3.0, b, true, h)), + WarpStyle::Bulge => g = blend_rows(edge_arc(0.0, b, true, h), edge_arc(1.0, b, false, h)), + WarpStyle::ArcLower => g = blend_rows(g[0], edge_arc(1.0, b, false, h)), + WarpStyle::ArcUpper => g = blend_rows(edge_arc(0.0, b, true, h), g[3]), + // The bulging edge and the outer points of the row next to it come from Arc (bent the + // other way for Shell Lower); the rest stays put. + WarpStyle::ShellLower => { + let a = arc_grid(-b, h); + g[3] = a[3]; + g[2][0] = a[2][0]; + g[2][3] = a[2][3]; + } + WarpStyle::ShellUpper => { + let a = arc_grid(b, h); + g[0] = a[0]; + g[1][0] = a[1][0]; + g[1][3] = a[1][3]; + } + WarpStyle::Flag => { + for row in &mut g { + row[1][1] -= 2.0 * b; + row[2][1] += 2.0 * b; + } + } + WarpStyle::Wave => { + for row in &mut g[1..3] { + row[1][1] += 4.0 / 3.0 * b; + row[2][1] -= 4.0 / 3.0 * b; + } + } + WarpStyle::Fish => { + for (row, f) in g.iter_mut().zip([-2.0, -2.0 / 3.0, 2.0 / 3.0, 2.0]) { + row[1][1] += f * b; + row[2][1] -= f * b; + } + } + WarpStyle::Rise => { + for row in &mut g { + row[0][1] += 2.0 * b; + row[1][1] += 2.0 * b; + } + } + WarpStyle::Fisheye => { + for (j, i, dx, dy) in [(1, 1, -1.0, -1.0), (1, 2, 1.0, -1.0), (2, 1, -1.0, 1.0), (2, 2, 1.0, 1.0)] { + g[j][i][0] += dx * 2.0 / 3.0 * b; + g[j][i][1] += dy * 2.0 / 3.0 * b; + } + } + // Edge midpoints move out (Inflate) or the sides in (Squeeze) by b/3, the inner points by b/9. + WarpStyle::Inflate | WarpStyle::Squeeze => { + let s = if style == WarpStyle::Inflate { 1.0 } else { -1.0 }; + for p in &mut g[0][1..3] { + p[1] -= b / 3.0; + } + for p in &mut g[3][1..3] { + p[1] += b / 3.0; + } + for row in &mut g[1..3] { + row[0][0] -= s * b / 3.0; + row[3][0] += s * b / 3.0; + } + for (j, i, dx, dy) in [(1, 1, -1.0, -1.0), (1, 2, 1.0, -1.0), (2, 1, -1.0, 1.0), (2, 2, 1.0, 1.0)] { + g[j][i][0] += s * dx * b / 9.0; + g[j][i][1] += dy * b / 9.0; + } + } + // The inner points turn round the centre (the other way for a negative bend). + WarpStyle::Twist => { + let a = b.abs(); + if b >= 0.0 { + g[1][1][0] += a; + g[1][2][1] += a; + g[2][1][1] -= a; + g[2][2][0] -= a; + } else { + g[1][1][1] += a; + g[1][2][0] -= a; + g[2][1][0] += a; + g[2][2][1] -= a; + } + } + WarpStyle::None | WarpStyle::Custom => {} + } + // Vertical distortion: the top and bottom rows scale about their end points' midpoint (by + // 1 ∓ vd) and the middle rows follow by the blend of those two moves. + if vd != 0.0 { + let moved = |row: [[f64; 2]; 4], s: f64| -> [[f64; 2]; 4] { + let m = [(row[0][0] + row[3][0]) / 2.0, (row[0][1] + row[3][1]) / 2.0]; + std::array::from_fn(|i| [m[0] + (row[i][0] - m[0]) * s - row[i][0], m[1] + (row[i][1] - m[1]) * s - row[i][1]]) + }; + let (d0, d3) = (moved(g[0], 1.0 - vd), moved(g[3], 1.0 + vd)); + for (j, row) in g.iter_mut().enumerate() { + let t = j as f64 / 3.0; + for (i, p) in row.iter_mut().enumerate() { + p[0] += d0[i][0] + (d3[i][0] - d0[i][0]) * t; + p[1] += d0[i][1] + (d3[i][1] - d0[i][1]) * t; + } + } + } + // Horizontal distortion: each column scales about its end points' midpoint by 1 + hd·(2i/3 − 1). + if hd != 0.0 { + for i in 0..4 { + let s = 1.0 + hd * (2.0 * i as f64 / 3.0 - 1.0); + let m = [(g[0][i][0] + g[3][i][0]) / 2.0, (g[0][i][1] + g[3][i][1]) / 2.0]; + for row in &mut g { + row[i] = [m[0] + (row[i][0] - m[0]) * s, m[1] + (row[i][1] - m[1]) * s]; + } + } + } + // Vertical orientation: the same grid with the axes swapped. + if vertical { + g = std::array::from_fn(|j| std::array::from_fn(|i| [g[i][j][1], g[i][j][0]])); + } + g } fn bernstein(t: f64) -> [f64; 4] { @@ -863,13 +985,24 @@ impl Warp { } } - /// The warp as a control mesh (a preset is fitted with `cols × rows` patches; custom warps - /// return their own mesh). + /// The warp as a control mesh (a preset is its own Bezier patch split into `cols × rows`; + /// custom warps return their own mesh). pub fn to_mesh(&self, cols: usize, rows: usize) -> BezierMesh { if let (WarpStyle::Custom, Some(m)) = (self.style, &self.mesh) { return m.clone(); } let (cols, rows) = (cols.clamp(1, 64), rows.clamp(1, 64)); + // A preset is one Bezier patch: split it exactly. + if let Some(sw) = self.style_warp() { + let mut m = sw.mesh(); + for i in 1..cols { + m.split_u(i as f64 / cols as f64); + } + for j in 1..rows { + m.split_v(j as f64 / rows as f64); + } + return m; + } let us: Vec = (0..=cols).map(|i| i as f64 / cols as f64).collect(); let vs: Vec = (0..=rows).map(|i| i as f64 / rows as f64).collect(); BezierMesh::fit( @@ -1168,18 +1301,55 @@ mod tests { } } + const SQUARE: [f64; 4] = [128.0, 128.0, 384.0, 384.0]; + const WIDE: [f64; 4] = [128.0, 208.0, 384.0, 304.0]; + const TALL: [f64; 4] = [208.0, 128.0, 304.0, 384.0]; + + /// Photoshop 27.11, measured: a 256 px grid image whose colours encode each pixel's own + /// position, warped in a 512 px document over the box (128, 128)–(384, 384). Each pair is a + /// source point and where Photoshop put it. #[test] - fn arc_maps_known_points() { - // Arc, bend 50 %: the centre column stays, the ends drop and pull in along a circle. - let w = Warp::preset(WarpStyle::Arc, 50.0, [0.0, -40.0, 200.0, 10.0]); - let mid = w.map(100.0, -15.0); - assert!(close(mid, (100.0, -15.0), 1e-9)); - // Centre line: radius r = half / θmax with half = 100/25 = 4 (in half-heights), θmax = π/4. - let (theta, r) = (std::f64::consts::FRAC_PI_4, 4.0 / std::f64::consts::FRAC_PI_4); - let ex = 100.0 - r * theta.sin() * 100.0 / 4.0; - let ey = -15.0 + (r - r * theta.cos()) * 25.0; - let end = w.map(0.0, -15.0); - assert!(close(end, (ex, ey), 1e-6), "{end:?} vs {:?}", (ex, ey)); + fn presets_match_photoshop() { + type Case = (WarpStyle, f64, f64, f64, bool, [([f64; 2], [f64; 2]); 3]); + let cases: [Case; 9] = [ + (WarpStyle::Arc, 50.0, 0.0, 0.0, false, [([272.5, 155.5], [296.5, 104.5]), ([331.5, 182.5], [424.5, 168.5]), ([259.5, 157.5], [264.5, 104.5])]), + (WarpStyle::Arc, -100.0, 0.0, 0.0, false, [([277.5, 354.5], [344.5, 472.5]), ([140.5, 218.5], [40.5, 168.5]), ([172.5, 237.5], [56.5, 264.5])]), + (WarpStyle::Flag, 50.0, 0.0, 0.0, false, [([136.5, 143.5], [136.5, 120.5]), ([136.5, 159.5], [136.5, 136.5]), ([136.5, 175.5], [136.5, 152.5])]), + (WarpStyle::Twist, -50.0, 0.0, 0.0, false, [([148.5, 205.5], [152.5, 216.5]), ([197.5, 212.5], [200.5, 232.5]), ([134.5, 212.5], [136.5, 216.5])]), + ( + WarpStyle::ShellLower, + 50.0, + 0.0, + 0.0, + false, + [([248.5, 136.5], [248.5, 136.5]), ([264.5, 136.5], [264.5, 136.5]), ([232.5, 136.5], [232.5, 136.5])], + ), + (WarpStyle::Fish, -100.0, 0.0, 0.0, false, [([296.5, 355.5], [296.5, 440.5]), ([344.5, 298.5], [344.5, 344.5]), ([328.5, 356.5], [328.5, 472.5])]), + (WarpStyle::Bulge, 50.0, 50.0, 0.0, false, [([293.5, 147.5], [296.5, 88.5]), ([293.5, 375.5], [296.5, 440.5]), ([340.5, 281.5], [344.5, 296.5])]), + (WarpStyle::Arc, 50.0, 0.0, -50.0, false, [([181.5, 350.5], [184.5, 328.5]), ([291.5, 372.5], [280.5, 344.5]), ([232.5, 349.5], [232.5, 312.5])]), + (WarpStyle::Arc, 50.0, 0.0, 0.0, true, [([155.5, 272.5], [104.5, 296.5]), ([182.5, 331.5], [168.5, 424.5]), ([157.5, 259.5], [104.5, 264.5])]), + ]; + // Wide (256 × 96 at 128, 208) and tall (96 × 256 at 208, 128) boxes, and a vertical warp + // with distortion. + type Boxed = (WarpStyle, f64, f64, f64, bool, [f64; 4], [([f64; 2], [f64; 2]); 2]); + let boxed: [Boxed; 6] = [ + (WarpStyle::Arc, 50.0, 0.0, 0.0, false, WIDE, [([144.5, 269.6], [120.5, 264.5]), ([271.5, 220.42], [280.5, 168.5])]), + (WarpStyle::Bulge, 50.0, 0.0, 0.0, false, TALL, [([249.11, 374.5], [248.5, 392.5]), ([249.11, 360.5], [248.5, 376.5])]), + (WarpStyle::Flag, 50.0, 0.0, 0.0, true, WIDE, [([137.5, 296.42], [184.5, 296.5]), ([153.5, 296.42], [200.5, 296.5])]), + (WarpStyle::Rise, 50.0, 0.0, 0.0, true, TALL, [([250.97, 152.5], [344.5, 152.5]), ([301.26, 264.5], [344.5, 264.5])]), + (WarpStyle::Bulge, 60.0, 20.0, -10.0, false, WIDE, [([208.5, 224.15], [200.5, 184.5]), ([219.5, 277.42], [216.5, 296.5])]), + (WarpStyle::Arc, 50.0, 30.0, 0.0, true, SQUARE, [([331.5, 327.5], [296.5, 360.5]), ([318.5, 240.5], [264.5, 232.5])]), + ]; + let all = + cases.iter().map(|c| (c.0, c.1, c.2, c.3, c.4, SQUARE, c.5.to_vec())).chain(boxed.iter().map(|c| (c.0, c.1, c.2, c.3, c.4, c.5, c.6.to_vec()))); + for (style, bend, hd, vd, vertical, bounds, pairs) in all { + let w = Warp { h_distort: hd, v_distort: vd, vertical, ..Warp::preset(style, bend, bounds) }; + for (src, want) in pairs { + let got = w.map(src[0], src[1]); + let d = (got.0 - want[0]).hypot(got.1 - want[1]); + assert!(d < 1.5, "{style:?} {bend} h{hd} v{vd} vertical {vertical} {bounds:?}: {src:?} -> {got:?}, Photoshop {want:?}"); + } + } } #[test] diff --git a/crates/gpu/src/bounds.rs b/crates/gpu/src/bounds.rs index 2a833846d..0dbdf4aa3 100644 --- a/crates/gpu/src/bounds.rs +++ b/crates/gpu/src/bounds.rs @@ -14,14 +14,15 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { photocraft_compose::layer_bounds(layer, canvas) } -/// The region a layer's effect maps cover: its bounds grown by the effect reach, within the +/// The region a layer's effect maps cover: its effect source bounds (for a group, its children +/// with their effects: `photocraft_compose::effect_source_bounds`) grown by the effect reach, within the /// canvas grown likewise (`photocraft_compose::effect_maps`) when the layer reaches far beyond /// it. A layer about the canvas's size keeps its whole region even where it overhangs the edge: /// then the region keeps its size while the layer moves (a Move tool drag), so its maps move with /// it instead of being rebuilt every frame (#761). The maps are exact either way. pub fn effect_region(layer: &Layer, canvas: Rect) -> Rect { let m = photocraft_compose::effects::margin(layer); - let (whole, window) = (layer_bounds(layer, canvas).inflate(m), canvas.inflate(m)); + let (whole, window) = (photocraft_compose::effect_source_bounds(layer, canvas).inflate(m), canvas.inflate(m)); let area = |r: Rect| u64::from(r.width()) * u64::from(r.height()); if area(whole) <= area(window).saturating_add(area(window) / 4) { whole } else { whole.intersect(&window) } } diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index b7ade5d1d..c6a0edbd5 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -697,18 +697,6 @@ fn adjust(c: vec3) -> vec3 { if (p0.y > 0.5) { o = clamp(o + bayer4(adj_px) / 255.0, vec3(0.0), vec3(1.0)); } return o; } - case 14: { // Color balance - let l = gray(c); - let ws = clamp(1.0 - l * 2.0, 0.0, 1.0); - let wh = clamp(l * 2.0 - 1.0, 0.0, 1.0); - let wm = 1.0 - ws - wh; - let o = clamp(c + (p0.rgb * ws + p1.rgb * wm + p2.rgb * wh) / 100.0 * 0.5, vec3(0.0), vec3(1.0)); - if (p3.x > 0.5) { - let l1 = max(gray(o), 1e-6); - return clamp(o * l / l1, vec3(0.0), vec3(1.0)); - } - return o; - } case 15: { return selective_color(c, p0.x > 0.5); } // Selective color case 16: { // Color lookup (3D LUT) var o = color_lookup(c, i32(p0.x), p0.y > 0.5); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 6c6f0f13d..a5218f826 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1626,11 +1626,9 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf (13, p, Some(rows)) } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { - p[0] = [shadows[0], shadows[1], shadows[2], 0.0]; - p[1] = [midtones[0], midtones[1], midtones[2], 0.0]; - p[2] = [highlights[0], highlights[1], highlights[2], 0.0]; - p[3][0] = if *preserve_luminosity { 1.0 } else { 0.0 }; - (14, p, None) + // Per-channel curves (compose::adjust::color_balance_channel), through the Levels kernel. + let tones = [*shadows, *midtones, *highlights]; + (7, p, Some(lut_rows(|ch, v| adjust::color_balance_channel(tones, *preserve_luminosity, ch, v), 3))) } Adjustment::SelectiveColor { relative, adjustments } => { // 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact). diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 9681a0756..fca32a02c 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1466,6 +1466,14 @@ fn layer_effects_on_groups_clipping_and_fills() { d.layers.push(grp); fx_check(&mut g, &d, &format!("group {blend:?}")); } + // A child's stroke reaching past the group's own effect reach (#3274). + let mut d = fx_doc(100, 80, SampleType::U8); + let mut a = blob("a", d.pixel_format(), 45.0, 40.0, 16.0, [0.6, 0.6, 0.6]); + a.effects.items = vec![Effect::Stroke(stroke(8.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0))))]; + let mut grp = Layer::group("g", vec![a]); + grp.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 3.0, 2.0, 0.0))]; + d.layers.push(grp); + fx_check(&mut g, &d, "group effects around a child's stroke"); // An effect layer as a clipping base, and a clipped layer with effects. let mut d = fx_doc(100, 80, SampleType::U8); let mut base = blob("base", d.pixel_format(), 45.0, 40.0, 24.0, [0.3, 0.8, 0.4]); diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 967ad4207..329ee35fb 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -214,6 +214,13 @@ pub(crate) fn enum_of<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> { } } +/// A gradient's interpolation method code. Photoshop (27.11, checked by saving gradient fill +/// layers) stores it as the enum `gradientsInterpolationMethod` (`Perc`, `Lnr `, `Smoo`, `Gcls`); +/// `gs99` is kept as a fallback. +pub(crate) fn gradient_method(d: &Descriptor) -> Option<&[u8]> { + enum_of(d, "gradientsInterpolationMethod").or_else(|| enum_of(d, "gs99")) +} + pub(crate) fn bool_of(d: &Descriptor, key: &str) -> bool { matches!(d.get(key), Some(Value::Boolean(true))) } @@ -245,7 +252,7 @@ pub(crate) fn gradient_style_value(s: GradientStyle) -> Value { pub(crate) type Stops = (Vec<(f32, Color)>, Vec<(f32, f32)>); /// Stops of a `Grdn` descriptor, baked to Photoshop's interpolation (smoothness `Intr`, stop -/// midpoints, and the parent's `gs99` interpolation method when given). +/// midpoints, and the parent's interpolation method when given, see [`gradient_method`]). pub(crate) fn gradient_stops_with(grad: &Descriptor, method: Option<&[u8]>) -> Stops { let raw = gradient_stops_raw(grad); let stops = if raw.stops.len() >= 2 { @@ -406,7 +413,7 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { b"GdFl" => { let angle = num(d.get("Angl")).unwrap_or(90.0) as f32; let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); - let raw = get_desc(d, "Grad").map(|g| gradient_stops_editable(g, enum_of(d, "gs99"))); + let raw = get_desc(d, "Grad").map(|g| gradient_stops_editable(g, gradient_method(d))); let (mut stops, midpoints) = raw.map(|r| (r.stops, r.mids)).unwrap_or_default(); // Opacity stops with Photoshop's smoothness and opacity midpoints applied (raw when // the interpolation is plain); none when fully opaque. @@ -656,6 +663,31 @@ mod tests { assert!(opacity_stops.is_empty()); } + /// Photoshop 27.11 stores a gradient's method as the enum `gradientsInterpolationMethod` + /// (seen in gradient fill layers saved by scripted Photoshop): a black→white Linear gradient + /// is at 188 of 255 halfway (measured), where Classic gives 128. The key used to be read as + /// `gs99`, which Photoshop doesn't write, so every gradient opened as Classic. + #[test] + fn gradient_fill_reads_the_interpolation_method() { + use photocraft_color::Color; + use photocraft_doc::{Fill, GradientStyle}; + use photocraft_psd::descriptor::{Id, Value}; + let f = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false); + let (k, data) = super::write_fill(&f); + let d = super::parse_prefix_versioned(&data).unwrap(); + let mid = |code: &str| { + let d = + d.clone().with("gradientsInterpolationMethod", Value::Enumerated { type_id: Id::new("gradientInterpolationMethodType"), value: Id::new(code) }); + let back = super::fill_from_desc(&k, &d).unwrap(); + let ramp = photocraft_compose::gradient_fill::Ramp::new(&back).unwrap(); + (ramp.sample(0.5)[0] * 255.0).round() + }; + assert_eq!(mid("Gcls"), 128.0); + assert!((mid("Lnr ") - 188.0).abs() <= 1.0, "Linear: {}", mid("Lnr ")); + assert!((mid("Perc") - 99.0).abs() <= 2.0, "Perceptual: {}", mid("Perc")); + assert!((mid("Smoo") - 99.0).abs() <= 2.0, "Smooth: {}", mid("Smoo")); + } + #[test] fn lab_descriptor_colours_round_trip() { use photocraft_color::ColorMode; diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 4dfa42ffb..00815e2c8 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -144,7 +144,7 @@ fn common(d: &Descriptor, blend: BlendMode, opacity: f32) -> FxCommon { } fn gradient_from(d: &Descriptor) -> Gradient { - let (stops, opacity_stops) = get_desc(d, "Grad").map(|g| crate::blocks::gradient_stops_with(g, enum_of(d, "gs99"))).unwrap_or_default(); + let (stops, opacity_stops) = get_desc(d, "Grad").map(|g| crate::blocks::gradient_stops_with(g, crate::blocks::gradient_method(d))).unwrap_or_default(); let off = get_desc(d, "Ofst"); Gradient { stops: if stops.is_empty() { Gradient::default().stops } else { stops }, diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 99765a0d2..c2632d22b 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -377,14 +377,33 @@ fn opaque_surface(s: &Surface, r: Rect) -> bool { }) } +/// The part of a flat export that rendering the composite takes, the encoder taking the rest. +/// Measured on a 24 MP RGB/8 document: ~1 s to composite, then TIFF ~2.3 s, JPEG ~0.9 s, +/// PNG ~0.4 s, WebP ~0.25 s to encode. +fn composite_share(format: Format) -> f32 { + match format { + Format::Tiff => 0.3, + Format::Jpeg => 0.55, + _ => 0.7, + } +} + /// Flattens and encodes as `format`. pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Result { + export_flat_with(doc, format, opts, &photocraft_raster::Interrupt::NONE) +} + +/// [`export_flat`] reporting a coarse step through `ctl` once the composite is rendered; the +/// encoder does the rest (#3389). +pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { use photocraft_cms::{Builtin, Intent}; if let Some(r) = export_mode_specific(doc, format, opts)? { return Ok(r); } let mut warnings = Vec::new(); let mut img = document_to_image(doc, &mut warnings)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(composite_share(format)); if opts.xmp == XmpEmbed::None { // Export As's Metadata: None: the packet lists the text of every type layer and one id // per placed document (#647). diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index ecf3d3ee0..c9265a910 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -1,12 +1,16 @@ //! Photoshop gradient appearance → dense linear stops. //! //! Photoshop interpolates gradient colours with a *Smoothness* setting (0–100 %) and, since the -//! 2023 releases, an interpolation *method* (Perceptual / Linear / Classic). The compositors +//! 2023 releases, an interpolation *method* (Smooth / Perceptual / Linear / Classic). The compositors //! interpolate stops linearly in sRGB, so on import we sample Photoshop's curve into dense stops. //! The model was fitted to Photoshop's own composites in the test corpus (clean-room, from //! pixels): Smoothness blends linear interpolation with a Catmull-Rom spline through the stop //! colours (end tangents at half the chord slope); Perceptual interpolates in Oklab, Linear in //! linear light, Classic in sRGB. Stop midpoints remap each segment piecewise-linearly. +//! Smooth (Photoshop's default since 2024) goes straight across each segment in Oklab with its own +//! easing, measured on Photoshop 27.11 (scripted gradients, sampled every 8 px on three colour +//! pairs: the same curve for each pair, and Smoothness doesn't change it). The curve fits no simple +//! formula, so it is kept as the measured table [`SMOOTH_EASE`]. use photocraft_color::{Color, ColorMode}; @@ -15,6 +19,7 @@ pub(crate) enum Method { Classic, Perceptual, Linear, + Smooth, } impl Method { @@ -22,6 +27,7 @@ impl Method { match code { Some(b"Perc") => Method::Perceptual, Some(b"Lnr ") => Method::Linear, + Some(b"Smoo") => Method::Smooth, _ => Method::Classic, } } @@ -63,7 +69,7 @@ fn into_space(m: Method, rgb: [f32; 3]) -> [f32; 3] { match m { Method::Classic => rgb, Method::Linear => rgb.map(lin), - Method::Perceptual => to_oklab(rgb), + Method::Perceptual | Method::Smooth => to_oklab(rgb), } } @@ -71,12 +77,23 @@ fn out_of_space(m: Method, p: [f32; 3]) -> [f32; 3] { match m { Method::Classic => p.map(|v| v.clamp(0.0, 1.0)), Method::Linear => p.map(enc), - Method::Perceptual => from_oklab(p), + Method::Perceptual | Method::Smooth => from_oklab(p), } } +/// Smooth's progress across a segment at u = 0, 1/16, …, 1 (Photoshop 27.11, measured as the +/// position along the Oklab line between the two stop colours). +const SMOOTH_EASE: [f32; 17] = [0.0, 0.0438, 0.099, 0.1644, 0.2303, 0.2988, 0.3667, 0.433, 0.4974, 0.5642, 0.6302, 0.6985, 0.7656, 0.8333, 0.8945, 0.9517, 1.0]; + +fn smooth_ease(u: f32) -> f32 { + let x = u.clamp(0.0, 1.0) * 16.0; + let i = (x.floor() as usize).min(15); + let (a, b) = (SMOOTH_EASE.get(i).copied().unwrap_or(0.0), SMOOTH_EASE.get(i + 1).copied().unwrap_or(1.0)); + a + (b - a) * (x - i as 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) -> [f32; 3] { +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; @@ -92,6 +109,10 @@ fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32) -> [f32; 3 let mid = mids.get(i).copied().unwrap_or(0.5).clamp(0.05, 0.95); u = if u <= mid { 0.5 * u / mid } else { 0.5 + 0.5 * (u - mid) / (1.0 - mid) }; let (p0, p1) = (stops[i].1, stops[i + 1].1); + if m == Method::Smooth { + let e = smooth_ease(u); + return std::array::from_fn(|c| p0[c] + (p1[c] - p0[c]) * e); + } // Catmull-Rom tangents per unit t; the ends use half the chord slope. let tangent = |k: usize| -> [f32; 3] { let (a, b) = match k { @@ -138,7 +159,7 @@ pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, let mut out: Vec<(f32, Color)> = (0..=N) .map(|k| { let t = k as f32 / N as f32; - let v = eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t); + let v = eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t, method); if lab { return (t, Color { mode: ColorMode::Lab, c: [v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0), 0.0], alpha: alpha(t) }); } @@ -205,6 +226,31 @@ mod tests { } } + #[test] + fn smooth_matches_photoshop() { + // Photoshop 27.11, Gradient tool, Method Smooth, dither off, 256 px wide, every 32 px. + type Row = (&'static str, [f32; 3], [f32; 3], [[u8; 3]; 8]); + let measured: [Row; 2] = [ + ("black→white", [0.0; 3], [1.0; 3], [[0; 3], [4; 3], [29; 3], [63; 3], [99; 3], [137; 3], [179; 3], [221; 3]]), + ( + "red→blue", + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + [[255, 0, 0], [232, 51, 67], [202, 71, 105], [171, 81, 136], [140, 83, 162], [109, 80, 188], [76, 69, 213], [41, 49, 236]], + ), + ]; + for (name, a, b, want) in measured { + let s = bake(vec![(0.0, Color::rgb(a[0], a[1], a[2])), (1.0, Color::rgb(b[0], b[1], b[2]))], &[0.5], 1.0, Method::Smooth); + for (k, w) in want.iter().enumerate() { + let x = k * 32; + let got = at(&s, (x as f32 + 0.5) / 256.0).map(|v| (v * 255.0).round() as i32); + for c in 0..3 { + assert!((got[c] - i32::from(w[c])).abs() <= 2, "{name} x {x}: {got:?} vs Photoshop {w:?}"); + } + } + } + } + #[test] fn classic_lab_stops_interpolate_in_lab() { // Lab documents blend Classic gradients in L*a*b* (psd-tools 4x4_16bit_lab): the half-way diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a2b30383d..2caf02797 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -98,7 +98,7 @@ pub enum IoError { /// An SVG that does not parse (or is too large to rasterise). #[error("SVG: {0}")] Svg(String), - /// A background import was cancelled ([`import_with`]). + /// A background import or save was cancelled ([`import_with`], [`export_with`]). #[error("cancelled")] Cancelled, } @@ -300,6 +300,37 @@ fn has_extension(name: &str, expected: &str) -> bool { /// Exports `doc` to the format named by `name_or_ext` (a file name, path or /// bare extension). pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result { + export_with(doc, name_or_ext, opts, &photocraft_raster::Interrupt::NONE) +} + +/// [`export`] for a background save (#3389): reports how far encoding has got through `ctl` +/// (monotonic, `1.0` once the bytes are ready; per layer and per composite band for PSD/PSB and +/// layered TIFF, in coarse steps for the other formats) and checks it between stages. A cancelled +/// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. +pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; + let r = export_stages(doc, name_or_ext, opts, ctl)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(1.0); + Ok(r) +} + +/// Run `f` with an interrupt whose progress `0..=1` fills `lo..=hi` of `ctl`'s (same cancellation). +pub(crate) fn with_span(ctl: &photocraft_raster::Interrupt, lo: f32, hi: f32, f: impl FnOnce(&photocraft_raster::Interrupt) -> R) -> R { + let cancelled = || ctl.cancelled(); + let progress = |p: f32| ctl.progress(lo + (hi - lo) * p.clamp(0.0, 1.0)); + f(&photocraft_raster::Interrupt::new(&cancelled, &progress)) +} + +/// Where a .pcraft save's progress stands once its thumbnail, then its 1024 px composite preview, +/// are rendered (the bundle itself is written after them). +const PCRAFT_THUMBNAIL_DONE: f32 = 0.15; +const PCRAFT_PREVIEWS_DONE: f32 = 0.4; +/// The share of a PSD/PSB export spent converting the document (layers, then the composite) +/// before its bytes are assembled: about 2.7 s of 3.0 s for a 24 MP, 5-layer RGB/8 document. +const PSD_MODEL_DONE: f32 = 0.9; + +fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { let ext = extension(name_or_ext); if ext == "pdn" { return Err(IoError::Unsupported("PDN is import-only; save as .pcraft to preserve all layers and blend modes".into())); @@ -311,15 +342,20 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result return ora::export(doc); } if ext == photocraft_format::EXTENSION { - let previews = photocraft_format::SaveOptions { - thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), - composite: Some(photocraft_compose::thumbnail(doc, 1024)), - }; + let thumbnail = Some(photocraft_compose::thumbnail(doc, 256)); + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(PCRAFT_THUMBNAIL_DONE); + let composite = Some(photocraft_compose::thumbnail(doc, 1024)); + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(PCRAFT_PREVIEWS_DONE); + let previews = photocraft_format::SaveOptions { thumbnail, composite }; return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc, &previews)?, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() }; - let (mut file, mut warnings) = document_to_psd_with(doc, &o); + let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); + // A cancelled conversion stops early with an incomplete model: never write it. + ctl.check().map_err(|_| IoError::Cancelled)?; if !doc.metadata.text.is_empty() { warnings.push("free-form text metadata is not supported by PSD/PSB; it will be dropped".into()); } @@ -341,9 +377,9 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result } let format = photocraft_codecs::from_extension(&ext).ok_or_else(|| IoError::UnknownFormat(name_or_ext.to_string()))?; if format == photocraft_codecs::Format::Tiff && opts.tiff_layers && tiff_layers::would_write_layers(doc) { - return tiff_layers::export_layered(doc, opts); + return tiff_layers::export_layered(doc, opts, ctl); } - flat::export_flat(doc, format, opts) + flat::export_flat_with(doc, format, opts, ctl) } /// The PSD's merged composite as straight RGBA floats (row-major), converted diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index b872019c9..771ebcb78 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -8,7 +8,7 @@ use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info use photocraft_psd::{ BlendMode as PsdBlend, ColorMode as PsdMode, Compression, Header, ImageData, LayerRecord, PsdFile, Rect as PsdRect, SectionType, TaggedBlock, Version, }; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use crate::adjust_map; use crate::blocks; @@ -873,7 +873,8 @@ impl Ex { return Err(format!("smart objects nest more than {MAX_NESTING} deep")); } let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; - let (file, warnings) = document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1); + let (file, warnings) = + document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE); let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); @@ -919,7 +920,9 @@ impl Ex { s } - fn emit(&mut self, layers: &[Layer]) { + /// Write the records of `layers` (and their groups' children). `tick` runs after each one: + /// it reports progress and returns `false` to stop early (a cancelled save, #3389). + fn emit(&mut self, layers: &[Layer], tick: &mut dyn FnMut() -> bool) { for l in layers { let extra = self.content_blocks(l); match &l.content { @@ -936,7 +939,7 @@ impl Ex { ], ..Default::default() }); - self.emit(&g.children); + self.emit(&g.children, tick); let kind = if g.expanded { SectionType::OpenFolder } else { SectionType::ClosedFolder }; let lsct = TaggedBlock::section_divider(kind, Some(PsdBlend::from_key(l.blend.psd_key())), None); let r = self.record(l, std::iter::once(lsct).chain(extra).collect(), None); @@ -968,6 +971,9 @@ impl Ex { self.records.push(r); } } + if !tick() { + return; + } } } } @@ -1075,7 +1081,9 @@ fn cmyk_of(fmt: &PixelFormat) -> bool { /// The merged image's planes (colour channels, then alpha), big-endian, matted against white /// when `matte`; with whether any pixel needs the alpha channel and whether any is below 1. -fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> (Vec, bool, bool) { +/// `ctl` gets the fraction of rows done after each band; once it is cancelled the rest of the +/// image is left blank (the caller discards it). +fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl: &Interrupt) -> (Vec, bool, bool) { let sample = fmt.sample; let cc = fmt.mode.color_channels(); let bps = sample.bytes(); @@ -1088,7 +1096,11 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> let w = canvas.width() as usize; let space = photocraft_compose::cmyk_space(doc); let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16; + let rows = canvas.height().max(1) as f32; let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { + if ctl.cancelled() { + return Err(()); + } // Retain alpha whenever it differs from opaque at the stored precision. has_alpha |= band.px.iter().any(|p| match sample { SampleType::U8 => q255(p[3]) < 255, @@ -1123,6 +1135,7 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> planes[o..o + src.len()].copy_from_slice(src); } } + ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows); Ok(()) }); (planes, has_alpha, translucent) @@ -1137,9 +1150,23 @@ pub fn document_to_psd(doc: &Document) -> PsdFile { /// anything that could not be represented. The merged composite is rendered /// with `photocraft_compose::flatten`. pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0) + document_to_psd_nested(doc, opts, 0, &Interrupt::NONE) } +/// [`document_to_psd_with`] for a background save (#3389): reports progress through `ctl` after +/// each layer record and each band of the merged composite. Once `ctl` is cancelled it stops +/// early and returns an **incomplete** model: callers check `ctl` and discard it. +pub(crate) fn document_to_psd_interruptible(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt) -> (PsdFile, Vec) { + document_to_psd_nested(doc, opts, 0, ctl) +} + +/// How much of a PSD conversion the merged composite weighs, in layer records: rendering it +/// (all layers composited) and encoding its planes cost about two full-canvas layers. +const MERGED_WEIGHT: f32 = 2.0; +/// The part of the merged composite's share filled once its bands are rendered and converted; the +/// rest is compressing its planes. +const MERGED_RENDERED: f32 = 0.8; + /// Adds headroom for compressed channel streams and their per-row length tables. fn estimate_encoded_size(raw_bytes: u64, rows: u64, structural_bytes: u64) -> Option { raw_bytes.checked_add(raw_bytes.checked_add(63)?.checked_div(64)?)?.checked_add(rows.checked_mul(8)?)?.checked_add(structural_bytes) @@ -1253,7 +1280,7 @@ pub(crate) fn psd_size_exceeds_limit(doc: &Document) -> bool { } /// [`document_to_psd_with`] for a document embedded `depth` smart objects deep. -fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) -> (PsdFile, Vec) { +fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt) -> (PsdFile, Vec) { if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } @@ -1337,15 +1364,27 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) - ex.text_index.insert(l.id, index); } } + // Progress: one unit per layer record, then the merged composite (#3389). + let records = doc.walk().len() as f32; + let units = records + MERGED_WEIGHT; + let layers_share = records / units; + let mut done = 0.0f32; + let mut tick = || { + done += 1.0; + ctl.progress(done / units); + !ctl.cancelled() + }; let space = photocraft_compose::cmyk_space(doc); - photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers)); + photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers, &mut tick)); // Merged composite, rendered and encoded in bands (no full-size float composite). Matting // against white only changes pixels with alpha < 1; if some are slightly translucent but all // round to opaque (so no alpha channel is written), encode once more without the matte. - let (mut planes, has_alpha, translucent) = merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte); + let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; + let (mut planes, has_alpha, translucent) = + crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); if !has_alpha && translucent && opts.merged_matte { - planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false).0; + planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; } let n = doc.size.area() as usize; if !has_alpha { @@ -1381,6 +1420,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) - let image_data = ImageData::encode(mcomp, &planes, &header) .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); + ctl.progress(1.0); // Resources. let mut resources = vec![ @@ -1470,7 +1510,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) - }) .collect(); let previous = (!text_layers.is_empty()).then(|| preserved.iter().find(|(_, key, _)| key == b"Txt2").map(|(_, _, data)| data.to_vec())).flatten(); - let txt2 = (!text_layers.is_empty()).then(|| photocraft_text::psd::build_txt2(&text_layers, previous.as_deref())); + // A fresh document of ordinary (Metrics) text has nothing to put in `Txt2`, and Photoshop never + // writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the + // block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the + // one mode EngineData can't carry; #1348). + let txt2 = + (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)).then(|| photocraft_text::psd::build_txt2(&text_layers, previous.as_deref())); for (sig, key, data) in preserved { if txt2.is_some() && key == *b"Txt2" { continue; diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 71e8c8a0b..6ff2f5904 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -171,6 +171,7 @@ fn develop_fitted(bytes: &[u8], opts: &DevelopOptions) -> Result Result { +/// codec (Lab) are saved flat, with a warning. Reports progress through `ctl` (see +/// [`crate::export_with`]). +pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { if !layered_mode(doc) { - let mut r = crate::flat::export_flat(doc, Format::Tiff, opts)?; + let mut r = crate::flat::export_flat_with(doc, Format::Tiff, opts, ctl)?; if !doc.layers.is_empty() { r.warnings.push(format!("{:?} documents are saved as a flat TIFF: their layers are not kept", doc.pixel_format().mode)); } @@ -194,10 +202,14 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions) -> Result Result Document { g.artboard = Some(Artboard { rect, background: ArtboardBackground::White, preset: String::new() }); } d.layers = vec![g1]; - d.guides = Guides { horizontal: vec![90.0], vertical: vec![20.0] }; + d.guides = Guides { horizontal: vec![90.0], vertical: vec![20.0], ..Default::default() }; d } diff --git a/crates/io/tests/export_progress.rs b/crates/io/tests/export_progress.rs new file mode 100644 index 000000000..8f372db4b --- /dev/null +++ b/crates/io/tests/export_progress.rs @@ -0,0 +1,93 @@ +//! #3389: a background save's export reports its progress (so the status bar and the tab title +//! can read "Saving NN%") and can be cancelled, without changing a byte of what it writes. + +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, IoError}; +use photocraft_raster::{Interrupt, Surface}; + +/// Three pixel layers (two in a group) of noise-like pixels at `depth`. +fn layered(depth: SampleType) -> Document { + let mut d = Document::new("progress", Size::new(300, 200), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + let layer = |name: &str, seed: usize, r: Rect| { + let n = r.width() as usize * r.height() as usize * fmt.bytes_per_pixel(); + let px: Vec = (0..n).map(|i| ((i * 31 + seed * 17 + i / 7) % 251) as u8).collect(); + Layer::new(name, LayerContent::Raster(Surface::from_interleaved(fmt, r, &px))) + }; + d.layers.push(layer("Back", 1, d.bounds())); + d.layers.push(Layer::group("Group", vec![layer("A", 2, Rect::new(10, 10, 200, 150)), layer("B", 3, Rect::new(50, 40, 290, 190))])); + d +} + +/// Exports `doc` as `name` with progress recorded; the bytes and every fraction reported. +fn export_recorded(doc: &Document, name: &str, opts: &ExportOptions) -> (Vec, Vec) { + let seen = Mutex::new(Vec::new()); + let cancel = || false; + let progress = |f: f32| seen.lock().unwrap().push(f); + let r = photocraft_io::export_with(doc, name, opts, &Interrupt::new(&cancel, &progress)).unwrap(); + (r.bytes, seen.into_inner().unwrap()) +} + +#[test] +fn export_reports_monotonic_progress_and_writes_the_same_bytes() { + let layered_tiff = ExportOptions { tiff_layers: true, ..Default::default() }; + let cases: [(&str, ExportOptions, usize); 6] = [ + // Per layer record (3 layers + the group) and per composite band, then the bytes. + ("a.psd", ExportOptions::default(), 6), + ("a.psb", ExportOptions::default(), 6), + ("a.tif", layered_tiff, 6), + ("a.pcraft", ExportOptions::default(), 3), + ("a.png", ExportOptions::default(), 2), + ("a.jpg", ExportOptions::default(), 2), + ]; + for depth in [SampleType::U8, SampleType::U16] { + let doc = layered(depth); + for (name, opts, min_reports) in &cases { + if depth == SampleType::U16 && name.ends_with(".jpg") { + continue; + } + let (bytes, seen) = export_recorded(&doc, name, opts); + let plain = photocraft_io::export(&doc, name, opts).unwrap().bytes; + // .pcraft bundles carry a save time, so only the formats without one compare exactly. + if !name.ends_with(".pcraft") { + assert!(bytes == plain, "{name} {depth:?}: progress reporting changed the bytes"); + } + assert!(seen.len() >= *min_reports, "{name} {depth:?}: only {} reports: {seen:?}", seen.len()); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "{name} {depth:?}: progress went backwards: {seen:?}"); + assert!(seen.iter().all(|f| (0.0..=1.0).contains(f)), "{name} {depth:?}: {seen:?}"); + assert_eq!(seen.last().copied(), Some(1.0), "{name} {depth:?}: ends at 100%"); + assert!(seen.iter().any(|f| *f > 0.0 && *f < 1.0), "{name} {depth:?}: something between 0 and 100%: {seen:?}"); + } + } +} + +#[test] +fn psd_progress_moves_per_layer_before_the_composite() { + let (_, seen) = export_recorded(&layered(SampleType::U8), "a.psd", &ExportOptions::default()); + // 4 layer records + the composite (weighing 2) = 6 units, the bytes after 90 %. + let first = seen.first().copied().unwrap_or(0.0); + assert!((first - 0.9 / 6.0).abs() < 1e-3, "the first layer is one sixth of the conversion: {seen:?}"); + let distinct = seen.iter().filter(|f| **f < 1.0).count(); + assert!(distinct >= 5, "{seen:?}"); +} + +#[test] +fn a_cancelled_export_stops_with_cancelled() { + let doc = layered(SampleType::U8); + for name in ["a.psd", "a.tif", "a.pcraft", "a.png"] { + // Cancelled from the start, and cancelled after the first progress report. + let cancelled = || true; + let r = photocraft_io::export_with(&doc, name, &ExportOptions { tiff_layers: true, ..Default::default() }, &Interrupt::cancel_only(&cancelled)); + assert!(matches!(r, Err(IoError::Cancelled)), "{name}: {:?}", r.map(|r| r.bytes.len())); + let stop = AtomicBool::new(false); + let cancel = || stop.load(Ordering::Relaxed); + let progress = |_: f32| stop.store(true, Ordering::Relaxed); + let r = photocraft_io::export_with(&doc, name, &ExportOptions { tiff_layers: true, ..Default::default() }, &Interrupt::new(&cancel, &progress)); + assert!(matches!(r, Err(IoError::Cancelled)), "{name} midway: {:?}", r.map(|r| r.bytes.len())); + } +} diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/psd_kerning.rs index c3c4a09f3..b75a6542a 100644 --- a/crates/io/tests/psd_kerning.rs +++ b/crates/io/tests/psd_kerning.rs @@ -101,6 +101,25 @@ fn optical_kerning_round_trips_through_psd() { } } +/// #3514: a fresh document of ordinary Metrics type writes no document `Txt2` block — Photoshop +/// never writes its empty `<< /0 << >> >>` stub, and readers such as Photopea reject it — while an +/// Optical layer still does, because its mode lives only in `Txt2`. +#[test] +fn fresh_metrics_text_writes_no_txt2_block() { + use photocraft_doc::text::Kerning; + let saves_txt2 = |kerning: Kerning| { + let t = layer("AV", &[(2, kerning, 0.0)]); + let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut r = t.clone(); + photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format()); + doc.layers.push(Layer::new("t", LayerContent::Text(r))); + let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().global_blocks.iter().any(|b| &b.key == b"Txt2") + }; + assert!(!saves_txt2(Kerning::Metrics), "a plain Metrics layer must not write the empty Txt2 stub"); + assert!(saves_txt2(Kerning::Optical), "Optical lives only in Txt2"); +} + /// Two imported layers can name the same Txt2 object; export must update the second /// preserved TySh pointer so each layer restores its own automatic kerning mode. #[test] diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index b9c62270e..d0bc370db 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -22,6 +22,18 @@ fn dng_opens_as_16_bit_prophoto() { assert!(r.warnings.iter().any(|w| w.contains("DNG") && w.contains("ProPhoto")), "{:?}", r.warnings); } +/// #3497: a monochrome (one-sample LinearRaw) DNG, as VueScan writes film scans, opens as a +/// 16-bit RGB document (neutral grey) instead of failing as unsupported. +#[test] +fn monochrome_dng_opens() { + let (w, h) = (40, 24); + let data: Vec = (0..w * h).map(|i| (i % w * 1600) as u16).collect(); + let r = import("S261009_90_67.dng", &DngSpec::monochrome(w, h, data).build()).unwrap(); + let d = &r.document; + assert_eq!((d.size.width, d.size.height, d.mode, d.depth), (40, 24, ColorMode::Rgb, SampleType::U16)); + assert!(r.warnings.iter().all(|w| !w.contains("colour matrix") && !w.contains("preview")), "{:?}", r.warnings); +} + #[test] fn cr2_opens() { let (w, h) = (48, 16); diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 829d2360f..61772e4ea 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -656,6 +656,25 @@ fn smoothing_modes() { assert!(out.last().unwrap().x < 70.0, "{}", out.last().unwrap().x); } +#[test] +fn smoothing_string_runs_from_the_lagging_brush_to_the_pointer() { + // #3479: the canvas draws the line between the brush and the pointer. + let cfg = Smoothing { amount: 0.8, ..Default::default() }; + let mut s = Smoother::new(&cfg, 1.0); + let mut out = Vec::new(); + assert_eq!(s.string(), None, "no stroke yet"); + s.push(StrokePoint::new(0.0, 0.0, 1.0), &mut out); + assert_eq!(s.string(), None, "the first point is the brush"); + s.push(StrokePoint::new(100.0, 0.0, 1.0), &mut out); + let (brush, pointer) = s.string().unwrap(); + assert!(brush[0] > 0.0 && brush[0] < 100.0, "the brush lags: {brush:?}"); + assert_eq!(pointer, [100.0, 0.0]); + let mut off = Smoother::new(&Smoothing { amount: 0.0, ..cfg }, 1.0); + off.push(StrokePoint::new(0.0, 0.0, 1.0), &mut out); + off.push(StrokePoint::new(100.0, 0.0, 1.0), &mut out); + assert_eq!(off.string(), None, "smoothing off: no string"); +} + #[test] fn smoothing_pulls_by_time_not_by_how_often_the_device_reports() { // The pointer jumps 100 px and is held there for 32 ms, reported at 1000 Hz or about 60 Hz. diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 5e8b22591..6fd444026 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -128,6 +128,14 @@ impl Smoother { } } + /// The string between the brush and the pointer: `(brush, pointer)` document positions while + /// smoothing holds the brush back from the last input point, `None` when they coincide (or + /// smoothing is off). The canvas draws it, as Photoshop does for Pulled String Mode. + pub fn string(&self) -> Option<([f64; 2], [f64; 2])> { + let (pos, last) = (self.pos?, self.last_input?); + (self.active() && (pos.x != last.x || pos.y != last.y)).then_some(([pos.x, pos.y], [last.x, last.y])) + } + /// End of stroke: with Catch-Up On Stroke End the brush finishes at the last pointer position. pub fn finish(&mut self, out: &mut Vec) { if !self.active() || !self.cfg.catch_up_on_end { @@ -668,6 +676,11 @@ impl DabGenerator { self.walker.interval.is_some() || self.smoother.lagging() } + /// See [`Smoother::string`]. + pub fn smoothing_string(&self) -> Option<([f64; 2], [f64; 2])> { + self.smoother.string() + } + pub fn new(brush: &BrushSettings, zoom: f32) -> Self { let brush = brush.bounded_for_render(); let interval = brush.build_up.then(|| 1000.0 / f64::from(brush.build_up_rate.clamp(0.1, 1000.0))); diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index b81a2ae85..74e4389f1 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -22,6 +22,7 @@ pub mod bg_erase; pub mod brush; pub mod dynamics; pub mod mixer; +pub mod outline; pub mod presets; pub mod procedural; pub mod render; diff --git a/crates/paint/src/outline.rs b/crates/paint/src/outline.rs new file mode 100644 index 000000000..52ba178e0 --- /dev/null +++ b/crates/paint/src/outline.rs @@ -0,0 +1,324 @@ +//! Brush tip outlines for the painting cursor: closed rings in units of the brush radius +//! (1 = size / 2), screen-oriented (x right, y down), ready for the UI to scale by the radius. + +use std::collections::HashMap; + +use crate::brush::{BrushSettings, TipShape}; +use crate::tile::GrayTile; + +/// Segments in the outline of a computed round tip. +const ELLIPSE_SEGMENTS: usize = 64; +/// Budget for all traced ring vertices; the smallest rings are dropped first. +const MAX_POINTS: usize = 512; +/// A traced ring smaller than this many bitmap pixels squared is noise. +const MIN_AREA: f32 = 0.9; +/// "Full size" outlines everything above 1/255; the normal cursor outlines the 50 % contour. +const FULL_THRESHOLD: f32 = 1.0 / 255.0; +const HALF_THRESHOLD: f32 = 0.5; + +/// The tip outline as closed rings, or `None` when the tip has no usable shape. `full` outlines +/// the whole tip; otherwise the 50 % contour (the Photoshop normal brush tip cursor). +pub fn tip_outline(b: &BrushSettings, full: bool) -> Option>> { + if !b.size.is_finite() || b.size <= 0.0 || !b.angle.is_finite() || !b.roundness.is_finite() { + return None; + } + let ro = b.roundness.clamp(0.0, 1.0).max(0.5 / (b.size / 2.0)).min(1.0); + let (sn, cs) = b.angle.to_radians().sin_cos(); + let (fx, fy) = (if b.flip_x { -1.0 } else { 1.0 }, if b.flip_y { -1.0 } else { 1.0 }); + match &b.tip { + TipShape::Round => { + // The normal cursor is the 50 % contour, matching the plain-circle path (#2744): + // the soft tip falls off as (1 - t²)⁴, not the old smoothstep midpoint. + let f = if full { 1.0 } else { crate::half_coverage_radius(1.0, b.hardness) }; + let ring = (0..ELLIPSE_SEGMENTS) + .map(|i| { + let t = std::f32::consts::TAU * i as f32 / ELLIPSE_SEGMENTS as f32; + // Tip-local (y up), rotated CCW by the angle, then flipped to screen y down. + let (lx, ly) = (t.cos() * f, t.sin() * f * ro); + [lx * cs - ly * sn, -(lx * sn + ly * cs)] + }) + .collect(); + Some(vec![ring]) + } + TipShape::Sampled(_) | TipShape::Stored(_) => { + let t = b.tip.bitmap()?; + if !t.is_valid() { + return None; + } + trace(t, if full { FULL_THRESHOLD } else { HALF_THRESHOLD }, (sn, cs, fx, fy, ro)) + } + } +} + +/// Trace the contour(s) above `thr` (resampled ~2x, capped at 160 px a side) and map them with +/// the same tip transform as `render::BrushContext::rasterize`. +fn trace(t: &GrayTile, thr: f32, xf: (f32, f32, f32, f32, f32)) -> Option>> { + let (sn, cs, fx, fy, ro) = xf; + let (tw, th) = (t.width as f32, t.height as f32); + let big = tw.max(th); + let f = if big <= 160.0 { 2.0 } else { 160.0 / big }; + let (gw, gh) = (((tw * f).ceil() as usize).max(1), ((th * f).ceil() as usize).max(1)); + let mut vals = vec![0.0f32; gw * gh]; + for gy in 0..gh { + for gx in 0..gw { + let (px, py) = ((gx as f32 + 0.5) / f - 0.5, (gy as f32 + 0.5) / f - 0.5); + vals[gy * gw + gx] = sample(t, px, py); + } + } + let mut rings: Vec> = march(&vals, gw, gh, thr).into_iter().filter(|r| area(r) >= MIN_AREA * f * f).collect(); + if rings.is_empty() { + return None; + } + rings.sort_by(|a, b| area(b).total_cmp(&area(a))); + let mut tol = 0.5; + let mut thin: Vec>; + loop { + thin = rings.iter().map(|r| simplify(r, tol)).collect(); + if thin.iter().map(Vec::len).sum::() <= MAX_POINTS || tol >= 8.0 { + break; + } + tol *= 1.5; + } + let mut budget = MAX_POINTS; + thin.retain(|r| { + let keep = r.len() >= 3 && r.len() <= budget; + if keep { + budget -= r.len(); + } + keep + }); + if thin.is_empty() { + return None; + } + let s = 2.0 / big; + let map = |[gx, gy]: [f32; 2]| { + let (px, py) = ((gx + 0.5) / f - 0.5, (gy + 0.5) / f - 0.5); + let (u, v) = ((px - tw / 2.0) * s * fx, -(py - th / 2.0) * s * ro * fy); + let (ux, uy) = (u * cs - v * sn, u * sn + v * cs); + [ux, -uy] + }; + let rings: Vec> = thin.into_iter().map(|r| r.into_iter().map(map).collect()).collect(); + rings.iter().flatten().all(|p| p[0].is_finite() && p[1].is_finite()).then_some(rings) +} + +/// Bilinear sample with zero outside (tips fade to nothing at their edges). +fn sample(t: &GrayTile, x: f32, y: f32) -> f32 { + let (x0, y0) = (x.floor(), y.floor()); + let (tx, ty) = (x - x0, y - y0); + let (x0, y0) = (x0 as i64, y0 as i64); + let get = |x: i64, y: i64| if x < 0 || y < 0 || x >= t.width as i64 || y >= t.height as i64 { 0.0 } else { t.get(x as u32, y as u32) }; + let a = get(x0, y0) + (get(x0 + 1, y0) - get(x0, y0)) * tx; + let b = get(x0, y0 + 1) + (get(x0 + 1, y0 + 1) - get(x0, y0 + 1)) * tx; + a + (b - a) * ty +} + +/// Shoelace area of a ring. +fn area(r: &[[f32; 2]]) -> f32 { + if r.len() < 3 { + return 0.0; + } + let mut s = 0.0; + for i in 0..r.len() { + let (a, b) = (r[i], r[(i + 1) % r.len()]); + s += a[0] * b[1] - b[0] * a[1]; + } + (s * 0.5).abs() +} + +/// Link segments end-to-end into closed rings (quantised endpoint keys absorb float noise). +fn stitch(segs: Vec<([f32; 2], [f32; 2])>) -> Vec> { + let key = |p: [f32; 2]| ((p[0] * 4096.0).round() as i64, (p[1] * 4096.0).round() as i64); + let mut ends: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, (a, b)) in segs.iter().enumerate() { + ends.entry(key(*a)).or_default().push(i); + ends.entry(key(*b)).or_default().push(i); + } + let mut used = vec![false; segs.len()]; + let mut rings = Vec::new(); + for s in 0..segs.len() { + if used[s] { + continue; + } + used[s] = true; + let mut ring = vec![segs[s].0, segs[s].1]; + while let Some(tail) = ring.last().copied() { + let Some(&next) = ends.get(&key(tail)).and_then(|c| c.iter().find(|&&i| !used[i])) else { break }; + used[next] = true; + let (a, b) = segs[next]; + let p = if key(a) == key(tail) { b } else { a }; + if key(p) == key(ring[0]) { + break; + } + ring.push(p); + } + if ring.len() >= 3 { + rings.push(ring); + } + } + rings +} + +/// Marching squares over `v` (w by h samples at lattice nodes), outside treated as 0 so every +/// contour closes; returns rings in node coordinates. +fn march(v: &[f32], w: usize, h: usize, thr: f32) -> Vec> { + let val = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0.0 } else { v[y as usize * w + x as usize] } }; + let lerp = |v0: f32, v1: f32, a: (f32, f32), b: (f32, f32)| -> [f32; 2] { + let t = if (v1 - v0).abs() < 1e-9 { 0.5 } else { ((thr - v0) / (v1 - v0)).clamp(0.0, 1.0) }; + [a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t] + }; + let mut segs: Vec<([f32; 2], [f32; 2])> = Vec::new(); + for y in -1..h as i64 { + for x in -1..w as i64 { + let (va, vb, vc, vd) = (val(x, y), val(x + 1, y), val(x + 1, y + 1), val(x, y + 1)); + let idx = u8::from(va >= thr) | (u8::from(vb >= thr) << 1) | (u8::from(vc >= thr) << 2) | (u8::from(vd >= thr) << 3); + if idx == 0 || idx == 15 { + continue; + } + let (fx, fy) = (x as f32, y as f32); + let top = lerp(va, vb, (fx, fy), (fx + 1.0, fy)); + let right = lerp(vb, vc, (fx + 1.0, fy), (fx + 1.0, fy + 1.0)); + let bottom = lerp(vd, vc, (fx, fy + 1.0), (fx + 1.0, fy + 1.0)); + let left = lerp(va, vd, (fx, fy), (fx, fy + 1.0)); + let mid = (va + vb + vc + vd) * 0.25 >= thr; + match idx { + 1 | 14 => segs.push((top, left)), + 2 | 13 => segs.push((top, right)), + 3 | 12 => segs.push((right, left)), + 4 | 11 => segs.push((right, bottom)), + 6 | 9 => segs.push((top, bottom)), + 7 | 8 => segs.push((left, bottom)), + 5 | 10 => { + let opposite = (idx == 5) != mid; + if opposite { + segs.push((top, right)); + segs.push((left, bottom)); + } else { + segs.push((top, left)); + segs.push((right, bottom)); + } + } + _ => {} + } + } + } + stitch(segs) +} +/// Douglas-Peucker on a closed ring (split at the point farthest from the first). +fn simplify(ring: &[[f32; 2]], tol: f32) -> Vec<[f32; 2]> { + if ring.len() <= 4 { + return ring.to_vec(); + } + let (mut far, mut best) = (0, 0.0); + for (i, p) in ring.iter().enumerate().skip(1) { + let d = (p[0] - ring[0][0]).powi(2) + (p[1] - ring[0][1]).powi(2); + if d > best { + best = d; + far = i; + } + } + let mut open: Vec<[f32; 2]> = ring[far..].to_vec(); + open.push(ring[0]); + let mut out = Vec::new(); + dp(&ring[..=far], tol, &mut out); + out.pop(); + dp(&open, tol, &mut out); + out.pop(); + out +} + +/// Iterative Douglas-Peucker: keep the points of `pts` farther than `tol` from the chord. +fn dp(pts: &[[f32; 2]], tol: f32, out: &mut Vec<[f32; 2]>) { + let n = pts.len(); + if n == 0 { + return; + } + if n < 3 { + out.extend_from_slice(pts); + return; + } + let mut keep = vec![false; n]; + keep[0] = true; + keep[n - 1] = true; + let mut stack = vec![(0usize, n - 1)]; + while let Some((i, j)) = stack.pop() { + if j <= i + 1 { + continue; + } + let (mut dmax, mut k) = (0.0f32, i); + for m in i + 1..j { + let d = seg_dist2(pts[m], pts[i], pts[j]); + if d > dmax { + dmax = d; + k = m; + } + } + if dmax > tol * tol { + keep[k] = true; + stack.push((i, k)); + stack.push((k, j)); + } + } + for (i, p) in pts.iter().enumerate() { + if keep[i] { + out.push(*p); + } + } +} + +/// Squared distance from `p` to the segment `a`-`b`. +fn seg_dist2(p: [f32; 2], a: [f32; 2], b: [f32; 2]) -> f32 { + let (abx, aby) = (b[0] - a[0], b[1] - a[1]); + let t = (((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / (abx * abx + aby * aby).max(1e-12)).clamp(0.0, 1.0); + (p[0] - a[0] - abx * t).powi(2) + (p[1] - a[1] - aby * t).powi(2) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn max_radius(rings: &[Vec<[f32; 2]>]) -> f32 { + rings.iter().flatten().fold(0.0f32, |m, p| m.max(p[0].hypot(p[1]))) + } + + /// The normal cursor of a rotated or flattened round tip is the same 50 % contour as the + /// plain-circle path (#2744), and the full cursor reaches the whole radius. + #[test] + fn round_outlines_sit_on_the_half_coverage_contour() { + for hardness in [0.0, 0.25, 0.5, 0.75, 1.0] { + let b = BrushSettings { size: 40.0, angle: 33.0, roundness: 0.4, hardness, ..Default::default() }; + let normal = tip_outline(&b, false).expect("a rotated round tip traces"); + let full = tip_outline(&b, true).expect("a rotated round tip traces"); + assert!((max_radius(&normal) - crate::half_coverage_radius(1.0, hardness)).abs() < 1e-4, "hardness {hardness}"); + assert!((max_radius(&full) - 1.0).abs() < 1e-4, "hardness {hardness}"); + } + } + + /// A sampled tip traces its 50 % alpha contour, inside the tip's square; a blank tip has none. + #[test] + fn sampled_outlines_stay_inside_the_tip_and_blank_tips_have_none() { + let disc = GrayTile::from_fn(32, 32, |x, y| { + let (dx, dy) = (x as f32 - 15.5, y as f32 - 15.5); + (1.0 - (dx * dx + dy * dy).sqrt() / 16.0).clamp(0.0, 1.0) + }); + let b = BrushSettings { size: 20.0, tip: TipShape::Sampled(disc), ..Default::default() }; + let rings = tip_outline(&b, false).expect("a sampled disc traces"); + assert!(rings.iter().all(|r| r.len() >= 3)); + assert!(rings.iter().flatten().all(|p| p[0].is_finite() && p[1].is_finite() && p[0].hypot(p[1]) <= 1.001)); + let blank = GrayTile::from_fn(8, 8, |_, _| 0.0); + let b = BrushSettings { size: 20.0, tip: TipShape::Sampled(blank), ..Default::default() }; + assert!(tip_outline(&b, false).is_none()); + } + + /// Hostile brush data yields no outline instead of NaNs or a panic. + #[test] + fn hostile_settings_get_no_outline() { + for size in [f32::NAN, f32::INFINITY, 0.0, -1.0] { + let b = BrushSettings { size, angle: 33.0, roundness: 0.5, ..Default::default() }; + assert!(tip_outline(&b, false).is_none(), "size {size}"); + } + for angle in [f32::NAN, f32::INFINITY] { + let b = BrushSettings { size: 40.0, angle, roundness: 0.5, ..Default::default() }; + assert!(tip_outline(&b, false).is_none(), "angle {angle}"); + } + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 95f90f072..aa6bd0b96 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -247,12 +247,7 @@ impl BrushContext { let small_reach2 = (rm + 0.75) * (rm + 0.75); // Keep ordinary computed tips separate from the feature loop, so their dense rows // can be vectorised without the per-pixel sampled-mask and stroke-depth branches. - if mips.is_none() - && !small - && !(aliased && b.square) - && !noise - && !tex_tip - && !wet + if !(mips.is_some() || small || (aliased && b.square) || noise || tex_tip || wet) && depth.is_none() && proj.is_none() && d.angle == 0.0 @@ -654,6 +649,12 @@ impl StrokeRenderer { self.generator.wants_time() } + /// The string between the brush and the pointer while smoothing lags (see + /// [`Smoother::string`](crate::dynamics::Smoother::string)). + pub fn smoothing_string(&self) -> Option<([f64; 2], [f64; 2])> { + self.generator.smoothing_string() + } + /// Feed input points (any chunking gives the same result). pub fn push(&mut self, pts: &[StrokePoint]) { self.generator.push(pts, &mut self.dab_buf, &mut self.dual_buf); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 3315f0bff..c80dce82c 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -594,6 +594,11 @@ impl Surface { } } + /// A tile filled with the default pixel (what a missing tile reads as), allocated fallibly. + pub fn try_default_tile(&self) -> Result { + Tile::try_filled(&self.format, &self.default_pixel) + } + /// A tile of this surface's format filled with `px` (normalised channel values). Sharing one /// across many coordinates fills them without copying (tiles are copy-on-write). pub fn solid_tile(&self, px: &[f32]) -> Arc { diff --git a/crates/raw/README.md b/crates/raw/README.md index 7fd1152fb..7bcfdcfe5 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -63,7 +63,7 @@ camera colour tables were copied. | Format | Status | |---|---| -| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw; LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) | +| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) | | DNG (lossy JPEG, floating point; opcodes other than GainMap; ProfileGainTableMap) | Unsupported / not applied (reported) | | CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border. CR2 has no CFA tag and the row phase varies by model, so it is measured from the data (the green diagonal), with a Canon model-ID table as the fallback (see `src/cr2.rs`) | | CR2 sRAW / mRAW | Unsupported | diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index 3c562dc28..8b69cc291 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -159,6 +159,10 @@ fn normalize(n: [f64; 3]) -> Option<[f64; 3]> { /// The camera-space neutral (max component 1) for `opts.white_balance`, as /// [`develop_sensor`] uses it (the same white balance [`crate::fit_look`] balances with). pub(crate) fn resolve_neutral(s: &Sensor, opts: &DevelopOptions, sc: &Scale, warnings: &mut Vec) -> Result<[f64; 3]> { + // Monochrome data has no colour to balance. + if s.is_monochrome() { + return Ok([1.0; 3]); + } // White balance: camera-space neutral, max component 1. let as_shot = s .color @@ -202,8 +206,10 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { // Balanced camera → XYZ D50: a look fitted to the camera's JPEG, the file's calibration, // or the neutral fallback. - let look = opts.camera_look.as_ref().filter(|l| l.is_usable()); - if opts.camera_look.is_some() && look.is_none() { + // Monochrome data stays neutral grey: no camera look and no colour calibration. + let mono = s.is_monochrome(); + let look = opts.camera_look.as_ref().filter(|l| l.is_usable() && !mono); + if opts.camera_look.is_some() && look.is_none() && !mono { warnings.push("the supplied camera look is not usable (non-finite or singular); ignored".into()); } let to_xyz: Mat3 = match (look, s.color.balanced_to_xyz_d50(neutral)) { @@ -213,7 +219,7 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { } (None, Some(m)) => m, (None, None) => { - if s.format != RawFormat::Dng { + if s.format != RawFormat::Dng && !mono { warnings.push("no colour calibration for this camera; camera colours are treated as sRGB primaries".into()); } color::srgb_to_xyz_d50() @@ -223,6 +229,9 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { let out_m = color::scale(&color::mul(&color::xyz_d50_to_prophoto(), &to_xyz), gain); let out_m: [[f32; 3]; 3] = out_m.map(|r| r.map(|v| v as f32)); + // Neutral grey through `out_m`: the gain alone (the matrix maps camera white to ProPhoto + // white, up to rounding), so the three output channels stay exactly equal. + let grey_gain = gain as f32; let (w, h) = (c.width, c.height); let band = par::band_rows(w); let mut rgb: Vec; @@ -304,10 +313,12 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { let y = c.y + b * band + r; for i in 0..w { for k in 0..3 { - row[i * 3 + k] = (sc.value(c.x + i, y, k) * mult[k]).clamp(0.0, 1.0); + // Monochrome: the one sample in all three channels. + let sample = k.min(s.samples.saturating_sub(1)); + row[i * 3 + k] = (sc.value(c.x + i, y, sample) * mult[k]).clamp(0.0, 1.0); } } - for k in 0..3 { + for k in 0..s.samples.min(3) { if let Some(g) = gain_row(s, y, c.x, w, k) { for (i, g) in g.into_iter().enumerate() { row[i * 3 + k] = (row[i * 3 + k] * g).clamp(0.0, 1.0); @@ -329,6 +340,10 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { let start = b * band * w * 3; let src = &rgb[start..start + chunk.len()]; for (o, p) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0) { + if mono { + *o = [lut.encode(grey_gain * p[0]); 3]; + continue; + } for k in 0..3 { o[k] = lut.encode(out_m[k][0] * p[0] + out_m[k][1] * p[1] + out_m[k][2] * p[2]); } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 0cf3bb75d..5ff9fd14d 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -138,7 +138,9 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { if is_cfa && samples != 1 { return Err(RawError::unsupported(format!("CFA data with {samples} samples per pixel"))); } - if !is_cfa && samples != 3 { + // LinearRaw: three colour samples, or one for a monochrome camera or scanner (DNG 1.7, + // ColorPlanes 1: Leica Monochrom, VueScan film scans). + if !is_cfa && samples != 1 && samples != 3 { return Err(RawError::unsupported(format!("LinearRaw data with {samples} samples per pixel"))); } let active = rect_from(t, &raw, plane.width, plane.height); @@ -242,7 +244,8 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { } let color = color_info(t, &ifd0); - if color.calibrations.is_empty() { + // A monochrome image has no colour to calibrate: it develops as neutral grey. + if color.calibrations.is_empty() && (is_cfa || samples != 1) { warnings.push("the DNG has no colour matrix; colours are approximate".to_string()); } let baseline_exposure = t.tag_floats(&ifd0, tag::BASELINE_EXPOSURE).first().copied().unwrap_or(0.0) diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 14d4ba7a2..a775586de 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -129,7 +129,7 @@ pub struct Sensor { /// Data width and height in pixels. pub width: usize, pub height: usize, - /// Samples per pixel: 1 for CFA data, 3 for LinearRaw. + /// Samples per pixel: 1 for CFA data, 3 for LinearRaw (1 for monochrome LinearRaw). pub samples: usize, /// Row-major interleaved samples, `width * height * samples`. pub data: Vec, @@ -160,6 +160,14 @@ pub struct Sensor { pub warnings: Vec, } +impl Sensor { + /// `true` for monochrome data (one-sample LinearRaw: monochrome cameras, scanner DNGs), which + /// develops to neutral grey with no white balance or colour calibration. + pub fn is_monochrome(&self) -> bool { + self.samples == 1 && self.cfa.is_none() + } +} + /// Raw samples read from one IFD. pub(crate) struct Plane { pub width: usize, diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 1ba316d0e..fdacc1982 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -391,8 +391,10 @@ pub enum DngStorage { pub struct DngSpec { pub width: usize, pub height: usize, - /// 1 (CFA) or 3 (LinearRaw). + /// 1 (CFA, or monochrome LinearRaw with `monochrome`) or 3 (LinearRaw). pub samples: usize, + /// With `samples` 1: a monochrome LinearRaw image (no CFA tags) instead of CFA data. + pub monochrome: bool, /// Row-major samples (`width * height * samples`). pub data: Vec, pub bits: u16, @@ -427,6 +429,8 @@ pub struct DngSpec { pub orientation: u16, pub make: String, pub model: String, + /// Extra IFD0 entries, written after the generated ones (e.g. malformed colour tags). + pub ifd0_extra: Vec<(u16, Val)>, } impl DngSpec { @@ -436,6 +440,7 @@ impl DngSpec { width, height, samples: 1, + monochrome: false, data, bits: 16, cfa: [0, 1, 1, 2], @@ -461,9 +466,20 @@ impl DngSpec { profile_gain_table_map: None, make: "Photocraft".into(), model: "Synthetic".into(), + ifd0_extra: Vec::new(), } } + /// A monochrome (one-sample LinearRaw) DNG around `data`, as monochrome cameras and film + /// scanners write them: no CFA tags and no colour matrices. + pub fn monochrome(width: usize, height: usize, data: Vec) -> Self { + DngSpec { monochrome: true, ..Self::cfa(width, height, data) } + } + + fn is_cfa(&self) -> bool { + self.samples == 1 && !self.monochrome + } + fn pack(&self, rows: std::ops::Range) -> Vec { let n = self.width * self.samples; let vals = &self.data[rows.start * n..rows.end * n]; @@ -500,14 +516,14 @@ impl DngSpec { (256, Val::Long(vec![self.width as u32])), (257, Val::Long(vec![self.height as u32])), (258, Val::Short(vec![self.bits; self.samples])), - (262, Val::Short(vec![if self.samples == 1 { 32803 } else { 34892 }])), + (262, Val::Short(vec![if self.is_cfa() { 32803 } else { 34892 }])), (277, Val::Short(vec![self.samples as u16])), (284, Val::Short(vec![1])), (50717, Val::Long(vec![self.white; self.samples])), (50713, Val::Short(vec![self.black_repeat.0, self.black_repeat.1])), (50714, Val::Long(self.black.clone())), ]; - if self.samples == 1 { + if self.is_cfa() { raw.push((33421, Val::Short(vec![2, 2]))); raw.push((33422, Val::Byte(self.cfa.to_vec()))); raw.push((50710, Val::Byte(vec![0, 1, 2]))); @@ -725,6 +741,7 @@ impl DngSpec { if let Some(m) = &self.profile_gain_table_map { ifd0.push((52525, Val::Undefined(m.clone()))); } + ifd0.extend(self.ifd0_extra.iter().cloned()); let i0 = t.ifd(ifd0); t.chain = vec![i0]; t.build() diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 9b19d31b4..9dfab5dfd 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -1,7 +1,7 @@ //! Synthetic DNG files: every storage layout decodes to the exact sensor //! samples, and development reproduces known colours. -use photocraft_raw::testgen::{DngSpec, DngStorage, mosaic, scene}; +use photocraft_raw::testgen::{DngSpec, DngStorage, Val, mosaic, scene}; use photocraft_raw::*; fn sensor(bytes: &[u8]) -> Sensor { @@ -71,6 +71,132 @@ fn linear_raw_decodes_and_develops() { assert!(d.info.cfa.is_none()); } +/// A horizontal ramp from `lo` to `hi` (inclusive), the same in every row. +fn ramp(w: usize, h: usize, lo: u16, hi: u16) -> Vec { + (0..w * h).map(|i| lo + ((u32::from(hi - lo) * (i % w) as u32) / (w as u32 - 1)) as u16).collect() +} + +/// Developed RGB of a monochrome DNG: every pixel has R = G = B, and the first row's grey level +/// rises with the ramp. Returns the first row's grey levels. +fn assert_neutral_ramp(d: &Developed, name: &str) -> Vec { + let px = d.rgb.as_chunks::<3>().0; + assert!(px.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{name}: channels differ"); + let row: Vec = px[..d.width as usize].iter().map(|p| p[0]).collect(); + assert!(row.windows(2).all(|w| w[1] >= w[0]), "{name}: not monotonic: {row:?}"); + assert!(row[row.len() - 1] > row[0] + 10_000, "{name}: flat: {row:?}"); + row +} + +/// #3497: a one-sample LinearRaw DNG (VueScan film scans, Leica Monochrom) was rejected as +/// "LinearRaw data with 1 samples per pixel". It opens as neutral grey from every storage. +#[test] +fn monochrome_linear_raw_develops_to_neutral_grey() { + let (w, h) = (37, 23); + let cases: Vec<(&str, u16, DngStorage)> = vec![ + ("16-bit strips", 16, DngStorage::Strips { rows: 5 }), + ("12-bit packed", 12, DngStorage::Strips { rows: 4 }), + ("8-bit", 8, DngStorage::Strips { rows: 23 }), + ("LJ92 strips", 14, DngStorage::Lj92Strips { rows: 6 }), + ("LJ92 tiles", 16, DngStorage::Lj92Tiles { width: 16, height: 8 }), + ("deflate predictor 2", 16, DngStorage::DeflateStrips { rows: 7, predictor: 2 }), + // One whole tile: a partial edge tile is a separate decoder limitation. + ("lossy JPEG tile", 16, DngStorage::LossyJpegTiles { width: 37, height: 23 }), + ]; + for (name, bits, storage) in cases { + let white = ((1u32 << bits) - 1) as u16; + let data = ramp(w, h, 0, white); + let mut spec = DngSpec::monochrome(w, h, data.clone()); + spec.bits = bits; + spec.white = u32::from(white); + spec.storage = storage; + let lossy = matches!(storage, DngStorage::LossyJpegTiles { .. }); + let s = sensor(&spec.build()); + assert_eq!((s.samples, s.cfa.is_none(), s.is_monochrome()), (1, true, true), "{name}"); + if !lossy { + assert_eq!(s.data, data, "{name}"); + } + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((d.width, d.height, d.rgb.len()), (37, 23, 37 * 23 * 3), "{name}"); + assert!(d.info.cfa.is_none() && d.info.wb_multipliers == [1.0; 3], "{name}: {:?}", d.info); + assert!(d.warnings.iter().all(|w| !w.contains("colour") && !w.contains("white balance")), "{name}: {:?}", d.warnings); + let row = assert_neutral_ramp(&d, name); + if !lossy { + assert_eq!((row[0], row[row.len() - 1]), (0, 65535), "{name}"); + } + } +} + +/// Black and white levels of a monochrome DNG scale its one sample; the white-balance +/// settings do not tint it. +#[test] +fn monochrome_linear_raw_levels_and_white_balance() { + let (w, h) = (32, 4); + let mut spec = DngSpec::monochrome(w, h, ramp(w, h, 1000, 60000)); + spec.black = vec![1000]; + spec.white = 60000; + let s = sensor(&spec.build()); + for wb in [WhiteBalance::AsShot, WhiteBalance::Auto, WhiteBalance::Multipliers([2.0, 1.0, 1.5])] { + let d = develop_sensor(&s, &DevelopOptions { white_balance: wb, ..Default::default() }).unwrap(); + let row = assert_neutral_ramp(&d, &format!("{wb:?}")); + assert_eq!((row[0], row[w - 1]), (0, 65535), "{wb:?}"); + } + // Develop exposure still applies. + let dark = develop_sensor(&s, &DevelopOptions { exposure: -1.0, ..Default::default() }).unwrap(); + let row = assert_neutral_ramp(&dark, "exposure -1"); + assert!(row[w - 1] < 60000, "{row:?}"); +} + +/// Colour metadata a monochrome DNG may carry, well-formed or not (a 1×3 ColorMatrix, a +/// one-value ColorMatrix or AsShotNeutral, BlackLevel counts that fit no repeat), is ignored +/// or falls back; it never errors or indexes out of bounds. +#[test] +fn monochrome_linear_raw_odd_metadata_develops() { + let (w, h) = (16, 6); + let base = DngSpec::monochrome(w, h, ramp(w, h, 100, 4000)); + let rat = |v: &[f64]| Val::SRational(v.iter().map(|x| ((x * 10000.0) as i32, 10000)).collect()); + let variants: Vec<(&str, DngSpec)> = vec![ + ("1x3 ColorMatrix", DngSpec { ifd0_extra: vec![(50721, rat(&[0.3, 0.6, 0.1])), (50778, Val::Short(vec![21]))], ..base.clone() }), + ("one-value ColorMatrix", DngSpec { ifd0_extra: vec![(50721, rat(&[1.0]))], ..base.clone() }), + ("one-value AsShotNeutral", DngSpec { ifd0_extra: vec![(50728, Val::Rational(vec![(1, 1)]))], ..base.clone() }), + ("two BlackLevels", DngSpec { black: vec![100, 120], ..base.clone() }), + ("three BlackLevels", DngSpec { black: vec![100, 110, 120], ..base.clone() }), + ("2x2 BlackLevel repeat", DngSpec { black: vec![100, 101, 102, 103], black_repeat: (2, 2), ..base.clone() }), + ("2x2 repeat, one value", DngSpec { black: vec![100], black_repeat: (2, 2), ..base.clone() }), + ("3x3 ColorMatrix", DngSpec { color_matrix1: Some((21, [3.2, -1.5, -0.5, -1.0, 1.9, 0.04, 0.06, -0.2, 1.06])), ..base.clone() }), + ("3-value AsShotNeutral", DngSpec { as_shot_neutral: Some([0.5, 1.0, 0.7]), ..base.clone() }), + ]; + for (name, spec) in variants { + let d = develop(&spec.build(), &DevelopOptions::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_neutral_ramp(&d, name); + } +} + +/// Broken monochrome DNGs fail with an error, not a panic. +#[test] +fn broken_monochrome_linear_raw_errors() { + let (w, h) = (16, 6); + let spec = DngSpec::monochrome(w, h, ramp(w, h, 100, 4000)); + // Every truncation decodes to an error or develops; none panics, and a cut into the + // strip data is an error. + let b = spec.build(); + for n in 0..b.len() { + if let Ok(s) = decode(&b[..n], &Limits::default()) { + let _ = develop_sensor(&s, &DevelopOptions::default()); + } + } + let first: Vec = spec.data[..4].iter().flat_map(|v| v.to_le_bytes()).collect(); + let strip = b.windows(first.len()).position(|win| win == first.as_slice()).expect("strip data"); + assert!(decode(&b[..strip + w], &Limits::default()).is_err(), "truncated strip"); + // Two samples per pixel is no LinearRaw layout. + let two = DngSpec { samples: 2, data: ramp(2 * w, h, 100, 4000), ..spec.clone() }; + let err = decode(&two.build(), &Limits::default()).unwrap_err().to_string(); + assert!(err.contains("2 samples per pixel"), "{err}"); + // A monochrome sensor holding less data than its size. + let mut s = sensor(&spec.build()); + s.data.truncate(10); + assert!(develop_sensor(&s, &DevelopOptions::default()).is_err()); +} + /// `spec`'s file with its Orientation entry (a SHORT `from`) rewritten as a LONG `to`. fn with_long_orientation(spec: &DngSpec, from: u16, to: u32) -> Vec { let mut b = spec.build(); diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/hostile.rs index 53e54015d..dabe9ffe9 100644 --- a/crates/raw/tests/hostile.rs +++ b/crates/raw/tests/hostile.rs @@ -39,6 +39,16 @@ fn samples() -> Vec> { j.jxl_segments = vec![jxl_rgb16(&rgb, w, h)]; j.profile_gain_table_map = Some(vec![0; 32]); out.push(j.build()); + // Monochrome LinearRaw (one sample, no CFA, no colour matrix), as film scanners write it (#3497). + let grey: Vec = scene(w, h).iter().map(|p| 64 + (p[1] * 3900.0) as u16).collect(); + let mut m = DngSpec::monochrome(w, h, grey); + m.bits = 12; + m.white = 4000; + m.black = vec![64]; + m.storage = DngStorage::Lj92Tiles { width: 16, height: 8 }; + out.push(m.build()); + m.storage = DngStorage::DeflateStrips { rows: 5, predictor: 2 }; + out.push(m.build()); let mut data = vec![600u16; 40 * 10]; for (i, v) in data.iter_mut().enumerate() { *v = 600 + (i as u16 * 7) % 3000; diff --git a/crates/text/src/cjk.rs b/crates/text/src/cjk.rs index a87ea18ea..f0e3193ba 100644 --- a/crates/text/src/cjk.rs +++ b/crates/text/src/cjk.rs @@ -220,7 +220,147 @@ pub fn families(script: CjkScript) -> &'static [&'static str] { #[derive(Clone, Debug, PartialEq, Eq)] pub struct FontFile { pub path: std::path::PathBuf, - pub family: &'static str, + pub family: String, +} + +#[cfg(target_os = "linux")] +struct FontconfigCjkScan { + started: std::sync::atomic::AtomicBool, + result: std::sync::Mutex; 4]>>, +} + +#[cfg(target_os = "linux")] +fn fontconfig_cjk_scan() -> &'static FontconfigCjkScan { + static SCAN: std::sync::OnceLock = std::sync::OnceLock::new(); + SCAN.get_or_init(|| FontconfigCjkScan { started: std::sync::atomic::AtomicBool::new(false), result: std::sync::Mutex::new(None) }) +} + +#[cfg(target_os = "linux")] +/// Start the optional Fontconfig CJK-family lookup in the background. +/// +/// The scan uses `fc-match` when available and leaves the built-in path candidates unchanged +/// when it is missing or cannot resolve a font. +pub fn start_fontconfig_cjk_scan() { + use std::sync::atomic::Ordering; + + let scan = fontconfig_cjk_scan(); + if scan.started.swap(true, Ordering::Relaxed) { + return; + } + let scan = fontconfig_cjk_scan(); + if std::thread::Builder::new() + .name("photocraft-fontconfig-cjk".into()) + .spawn(move || { + let result = [CjkScript::Japanese, CjkScript::SimplifiedChinese, CjkScript::TraditionalChinese, CjkScript::Korean].map(fontconfig_match); + *scan.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(result); + }) + .is_err() + { + *fontconfig_cjk_scan().result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(std::array::from_fn(|_| None)); + } +} + +#[cfg(not(target_os = "linux"))] +/// No-op outside Linux, where the Fontconfig lookup is unavailable. +pub fn start_fontconfig_cjk_scan() {} + +#[cfg(target_os = "linux")] +/// Whether the optional background Fontconfig CJK lookup is still running. +pub fn fontconfig_cjk_scan_pending() -> bool { + let scan = fontconfig_cjk_scan(); + scan.started.load(std::sync::atomic::Ordering::Relaxed) && scan.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none() +} + +#[cfg(not(target_os = "linux"))] +/// Fontconfig lookup is unavailable outside Linux. +pub fn fontconfig_cjk_scan_pending() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn fontconfig_match(script: CjkScript) -> Option { + use std::ffi::OsString; + use std::io::Read as _; + use std::os::unix::ffi::OsStringExt; + + const MAX_FONT_BYTES: u64 = 128 << 20; + + let language = match script { + CjkScript::Japanese => "ja", + CjkScript::SimplifiedChinese => "zh-cn", + CjkScript::TraditionalChinese => "zh-tw", + CjkScript::Korean => "ko", + }; + let output = std::process::Command::new("fc-match").args(["--format=%{file}\\t%{family[0]}\\n", &format!(":lang={language}")]).output().ok()?; + if !output.status.success() { + return None; + } + let line = output.stdout.split(|byte| *byte == b'\n').find(|line| !line.is_empty())?; + let separator = line.iter().position(|byte| *byte == b'\t')?; + let path = line.get(..separator)?; + let family = line.get(separator.checked_add(1)?..)?; + let path = std::path::PathBuf::from(OsString::from_vec(path.to_vec())); + if !path.is_absolute() + || !path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) + || !path.is_file() + { + return None; + } + let family = String::from_utf8_lossy(family).trim().to_string(); + if family.is_empty() { + return None; + } + + let file = std::fs::File::open(&path).ok()?; + let length = file.metadata().ok()?.len(); + if length == 0 || length > MAX_FONT_BYTES { + return None; + } + let mut bytes = Vec::new(); + bytes.try_reserve_exact(usize::try_from(length).ok()?).ok()?; + file.take(MAX_FONT_BYTES + 1).read_to_end(&mut bytes).ok()?; + if u64::try_from(bytes.len()).ok()? > MAX_FONT_BYTES || !fontconfig_font_covers_sample(&bytes, &family, script) { + return None; + } + + Some(FontFile { path: path.canonicalize().unwrap_or(path), family }) +} + +#[cfg(target_os = "linux")] +fn fontconfig_font_covers_sample(bytes: &[u8], family: &str, script: CjkScript) -> bool { + use skrifa::MetadataProvider; + + let index = face_index_for_family(bytes, family); + let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { return false }; + let sample = match script { + CjkScript::Japanese => "レイヤー図", + CjkScript::SimplifiedChinese => "图层", + CjkScript::TraditionalChinese => "圖層", + CjkScript::Korean => "카드 배경", + }; + sample.chars().all(|character| font.charmap().map(character).is_some()) +} + +#[cfg(target_os = "linux")] +fn fontconfig_cjk_font(script: CjkScript) -> Option { + use std::sync::atomic::Ordering; + + let scan = fontconfig_cjk_scan(); + if !scan.started.load(Ordering::Relaxed) { + return None; + } + let index = match script { + CjkScript::Japanese => 0, + CjkScript::SimplifiedChinese => 1, + CjkScript::TraditionalChinese => 2, + CjkScript::Korean => 3, + }; + scan.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref()?.get(index)?.clone() +} + +#[cfg(all(not(target_os = "linux"), not(target_arch = "wasm32")))] +fn fontconfig_cjk_font(_: CjkScript) -> Option { + None } /// System font files for `script`, most preferred first. Paths that don't exist are included; @@ -228,21 +368,21 @@ pub struct FontFile { #[cfg(not(target_arch = "wasm32"))] pub fn font_files(script: CjkScript) -> Vec { use CjkScript::*; - let f = |p: &str, family: &'static str| FontFile { path: p.into(), family }; + let f = |p: &str, family: &'static str| FontFile { path: p.into(), family: family.to_string() }; if cfg!(target_os = "macos") { let pingfang = |family| mac_pingfang().map(|path| FontFile { path, family }); match script { Japanese => vec![f("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", ""), f("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", "")], - SimplifiedChinese => pingfang("PingFang SC") + SimplifiedChinese => pingfang("PingFang SC".to_string()) .into_iter() .chain([f("/System/Library/Fonts/Hiragino Sans GB.ttc", ""), f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti SC")]) .collect(), - TraditionalChinese => pingfang("PingFang TC").into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(), + TraditionalChinese => pingfang("PingFang TC".to_string()).into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(), Korean => vec![f("/System/Library/Fonts/AppleSDGothicNeo.ttc", "Apple SD Gothic Neo")], } } else if cfg!(target_os = "windows") { let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into()); - let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family }; + let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family: family.to_string() }; match script { Japanese => vec![w("YuGothR.ttc", "Yu Gothic"), w("YuGothM.ttc", "Yu Gothic"), w("meiryo.ttc", "Meiryo"), w("msgothic.ttc", "")], SimplifiedChinese => vec![w("msyh.ttc", "Microsoft YaHei"), w("msyh.ttf", ""), w("simsun.ttc", "")], @@ -292,7 +432,13 @@ pub fn font_files(script: CjkScript) -> Vec { f("/usr/share/fonts/un-core/UnDotum.ttf", ""), ], }); - with_flatpak_host_fonts(v) + let mut files = with_flatpak_host_fonts(v); + if let Some(font) = fontconfig_cjk_font(script) + && !files.iter().any(|file| file.path == font.path) + { + files.push(font); + } + files } } @@ -306,7 +452,7 @@ fn with_flatpak_host_fonts(files: Vec) -> Vec { file.path .strip_prefix("/usr/share/fonts") .ok() - .map(|relative| FontFile { path: std::path::Path::new("/run/host/fonts").join(relative), family: file.family }) + .map(|relative| FontFile { path: std::path::Path::new("/run/host/fonts").join(relative), family: file.family.clone() }) } else { None }; @@ -320,12 +466,12 @@ fn with_flatpak_host_fonts(files: Vec) -> Vec { /// but look worse than the script fonts). #[cfg(not(target_arch = "wasm32"))] pub fn last_resort_files() -> Vec { - let f = |p: &str| FontFile { path: p.into(), family: "" }; + let f = |p: &str| FontFile { path: p.into(), family: String::new() }; if cfg!(target_os = "macos") { vec![f("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"), f("/Library/Fonts/Arial Unicode.ttf")] } else if cfg!(target_os = "windows") { let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into()); - vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: "" }] + vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: String::new() }] } else { with_flatpak_host_fonts(vec![ f("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), @@ -490,6 +636,25 @@ mod tests { assert_eq!(face_index_for_family(inter, ""), 0); } + #[cfg(target_os = "linux")] + #[test] + fn fontconfig_candidates_must_cover_the_requested_script() { + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + for script in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] { + assert!(!fontconfig_font_covers_sample(inter, "Inter", script), "{script:?}"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn fontconfig_matches_cover_the_requested_script() { + for script in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] { + let Some(file) = fontconfig_match(script) else { continue }; + let bytes = std::fs::read(&file.path).expect("Fontconfig match remains readable"); + assert!(fontconfig_font_covers_sample(&bytes, &file.family, script), "{script:?}: {}", file.path.display()); + } + } + /// On a Mac with PingFang, the SC and TC faces are found by name (not a hard-coded index). #[cfg(target_os = "macos")] #[test] diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 59ba1db70..1d728f14d 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1,9 +1,11 @@ //! Font database: bundled fonts (always available, also on the web), the optional craft-fonts //! Japanese fonts ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional system -//! fonts found by scanning the platform font directories (no fontconfig), user-registered font -//! data, and PostScript-name lookup for PSD import. +//! fonts found by scanning platform directories (plus Fontconfig's file list on Linux), +//! user-registered font data, and PostScript-name lookup for PSD import. use std::collections::HashMap; +#[cfg(not(target_arch = "wasm32"))] +use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock}; @@ -179,8 +181,8 @@ impl FontDb { db } - /// Scans the platform font directories (once). Font files are memory-mapped lazily by - /// fontique when a face is first used. + /// Scans the platform font directories once, plus Fontconfig's file list on Linux. Font files + /// are memory-mapped lazily by fontique when a face is first used. pub fn load_system_fonts(&mut self) { if self.system_loaded { return; @@ -193,12 +195,18 @@ impl FontDb { for d in system_font_dirs() { collect_font_files(&d, 0, &mut files); } + #[cfg(target_os = "linux")] + files.extend(fontconfig_font_paths()); // PingFang (the macOS Chinese UI font) lives outside the font folders. if cfg!(target_os = "macos") { files.extend(crate::cjk::mac_pingfang()); } - for f in files { - self.fcx.collection.load_fonts_from_paths([f]); + let mut seen = HashSet::new(); + for path in files { + let key = path.canonicalize().unwrap_or_else(|_| path.clone()); + if seen.insert(key) { + self.fcx.collection.load_fonts_from_paths([path]); + } } self.ps_cache.clear(); self.face_cache.clear(); @@ -560,6 +568,54 @@ pub(crate) fn collect_font_files(dir: &std::path::Path, depth: u32, out: &mut Ve } } +/// Fontconfig knows about configured font directories that are not in our portable path list. +/// Keep this optional and shell-free: directory scans remain the fallback when `fc-list` is not +/// installed or Fontconfig cannot enumerate fonts. +#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))] +fn fontconfig_font_paths() -> Vec { + let Ok(output) = std::process::Command::new("fc-list").arg("--format=%{file}\\n").output() else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + fontconfig_paths_from_output(&output.stdout).into_iter().filter(|path| path.is_file()).collect() +} + +#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))] +fn fontconfig_paths_from_output(output: &[u8]) -> Vec { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let mut seen = HashSet::new(); + output + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| std::path::PathBuf::from(OsString::from_vec(line.to_vec()))) + .filter(|path| { + path.is_absolute() + && path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) + }) + .map(|path| path.canonicalize().unwrap_or(path)) + .filter(|path| seen.insert(path.clone())) + .collect() +} + +#[cfg(all(test, target_os = "linux", not(target_arch = "wasm32")))] +mod fontconfig_tests { + use super::fontconfig_paths_from_output; + use std::path::PathBuf; + + #[test] + fn parses_unique_absolute_font_paths_from_fontconfig() { + let paths = fontconfig_paths_from_output( + b"/usr/share/fonts/example.ttf\nrelative.otf\n/usr/share/fonts/README.txt\n/usr/local/fonts/collection.OTC\n/usr/share/fonts/example.ttf\n\n", + ); + + assert_eq!(paths, vec![PathBuf::from("/usr/share/fonts/example.ttf"), PathBuf::from("/usr/local/fonts/collection.OTC")]); + } +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn system_font_dirs() -> Vec { use std::path::PathBuf; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index dcda5fb1d..da323b5a2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1,8 +1,9 @@ //! Photocraft's type engine. //! //! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the -//! web), optional system fonts from a directory scan (no fontconfig), user font data, TrueType -//! collections, PostScript-name lookup. Builds made with the optional craft-fonts input +//! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list, +//! user font data, TrueType collections, PostScript-name lookup. Builds made with the optional +//! craft-fonts input //! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks. //! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking), //! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules. diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 85ce2f6a0..c6724bf2c 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -743,6 +743,15 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> { /// `i32::MAX`, and sizing the array by it would make every save allocate gigabytes. pub const MAX_TEXT_INDEX: i32 = 0xFFFF; +/// Whether the document `Txt2` block is needed for `objects`: only a non-Metrics auto-kern mode +/// (`Optical`) is exclusive to `Txt2` — EngineData's `AutoKerning` cannot distinguish Metrics from +/// Optical — so a fresh document of ordinary Metrics text has nothing to put in the block. +/// Writing Photoshop's empty `<< /0 << >> >>` stub anyway makes some readers (Photopea) fail +/// (#3514); the export omits the block for such a document. +pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool { + objects.iter().any(|(_, l)| l.char_runs().iter().any(|r| r.style.kerning == Kerning::Optical)) +} + /// The document's `Txt2` block for `objects`: `(TextIndex, layer)` pairs, one text object each /// at the slot its `TextIndex` names (see [`set_text_index`] and [`text_index`]; unused slots are /// empty dictionaries), carrying the automatic kerning mode of every pair (`/11`: 0 manual, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 986211bcb..fa0a626fe 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -735,6 +735,22 @@ fn build_txt2_round_trips_kerning_modes() { assert_eq!(modes, vec![(1, Kerning::Off), (1, Kerning::Metrics)]); } +/// #3514: the document `Txt2` is only needed for a non-Metrics (`Optical`) auto-kern mode. A fresh +/// document of ordinary Metrics (or EngineData-carried Off) text must not write Photoshop's empty +/// stub, which some readers (Photopea) reject. +#[test] +fn needs_txt2_only_for_optical() { + use photocraft_doc::text::Kerning; + let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() }; + let metrics = runs_of("AV", &[(2, style(Kerning::Metrics))]); + assert!(!crate::psd::needs_txt2(&[(0, &metrics)])); + let off = runs_of("AV", &[(2, style(Kerning::Off))]); + assert!(!crate::psd::needs_txt2(&[(0, &off)]), "Off lives in EngineData, not Txt2"); + let optical = runs_of("AV", &[(1, style(Kerning::Optical)), (1, CharStyle::default())]); + assert!(crate::psd::needs_txt2(&[(0, &optical)])); + assert!(!crate::psd::needs_txt2(&[])); +} + /// A kept `Txt2` object — same text, so its extras are still true — keeps everything the file /// held beyond the regenerated keys (Photoshop stores glyph pen positions under `/21 /1`), and a /// changed text drops those extras with the stale runs. The block's own extras always survive. diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index 8ad433e5c..e056bdff3 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -121,9 +121,13 @@ mod tests { let w = warp("arc", 50.0); let mid = w.apply(100.0, -15.0); let end = w.apply(0.0, -15.0); - assert!((mid.1 - -15.0).abs() < 1e-9, "centre stays put"); + // As in Photoshop: the bottom edge keeps its corners, the middle rises and the ends of + // the upper rows swing out and down along the arc. + let corner = w.apply(0.0, 10.0); + assert!(corner.0.abs() < 1e-9 && (corner.1 - 10.0).abs() < 1e-9, "{corner:?}"); + assert!(mid.1 < -15.0, "the middle rises: {mid:?}"); assert!(end.1 > mid.1 + 5.0, "ends drop below the centre: {end:?}"); - assert!(end.0 > 0.0, "ends pull inwards along the arc"); + assert!(end.0 < 0.0, "ends swing out along the arc: {end:?}"); // A negative bend smiles. let s = warp("arc", -50.0); assert!(s.apply(0.0, -15.0).1 < -20.0); @@ -133,7 +137,9 @@ mod tests { fn bulge_and_squeeze_change_height_in_the_middle() { let b = warp("bulge", 50.0); assert!(b.apply(100.0, -40.0).1 < -50.0); + // Squeeze (Photoshop) pinches the sides in while the top and bottom bow out a little. let s = warp("squeeze", 50.0); - assert!(s.apply(100.0, -40.0).1 > -40.0); + assert!(s.apply(0.0, -15.0).0 > 10.0, "{:?}", s.apply(0.0, -15.0)); + assert!(s.apply(100.0, -40.0).1 < -40.0); } } diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs new file mode 100644 index 000000000..2cb30806a --- /dev/null +++ b/crates/ui-egui/src/brush_cursor.rs @@ -0,0 +1,75 @@ +//! The painting cursor for brushes with a tip shape: sampled tip outlines and rotated or +//! flattened round tips. Everything else falls back to the plain circle (`tool_cursor`). + +use std::sync::Arc; + +use egui::{CursorIcon, Painter, Pos2, Vec2}; +use photocraft_paint::brush::{BrushSettings, TipShape}; + +/// Below this on-screen radius an outline is unreadable: show the crosshair instead. +const MIN_RADIUS: f32 = 3.0; + +#[derive(Clone)] +struct Cached { + key: Key, + rings: Option>>>, +} + +/// The brush fields an outline depends on. +#[derive(Clone)] +struct Key { + tip: TipShape, + angle: f32, + roundness: f32, + hardness: f32, + flip_x: bool, + flip_y: bool, + full: bool, +} + +impl Key { + fn of(b: &BrushSettings, full: bool) -> Self { + Self { tip: b.tip.clone(), angle: b.angle, roundness: b.roundness, hardness: b.hardness, flip_x: b.flip_x, flip_y: b.flip_y, full } + } + fn matches(&self, b: &BrushSettings, full: bool) -> bool { + self.tip == b.tip + && self.angle == b.angle + && self.roundness == b.roundness + && self.hardness == b.hardness + && self.flip_x == b.flip_x + && self.flip_y == b.flip_y + && self.full == full + } +} + +/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom); +/// `normal` is Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). +pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, normal: bool, centre: bool) -> CursorIcon { + if let Some(rings) = rings(painter.ctx(), brush, !normal) { + let pts: Vec> = rings.iter().map(|r| r.iter().map(|p| Vec2::new(p[0], p[1]) * radius).collect()).collect(); + let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length())); + if extent < MIN_RADIUS { + return crate::tool_cursor::crosshair(painter, at, 4.0, 0.0); + } + return crate::tool_cursor::outline(painter, at, pts, centre); + } + let r = if normal { photocraft_paint::half_coverage_radius(radius, brush.hardness).max(1.0) } else { radius }; + crate::tool_cursor::circle(painter, at, r, centre) +} + +/// The tip outline in radius units, cached across frames; `None` when the plain circle says it all +/// (a plain round tip) or the tip has no traceable shape. +fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option>>> { + if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 { + return None; + } + let id = egui::Id::new("photocraft-tip-outline"); + if let Some(c) = ctx.data(|d| d.get_temp::(id)) + && c.key.matches(brush, full) + { + return c.rings; + } + let traced = photocraft_paint::outline::tip_outline(brush, full).map(Arc::new); + ctx.data_mut(|d| d.insert_temp(id, Cached { key: Key::of(brush, full), rings: traced.clone() })); + traced +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 12aa1c10f..5b68f6579 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -126,6 +126,9 @@ pub struct Drag { /// A Brush/Eraser stroke that erases: the Eraser, or a right-button Brush drag with /// Preferences › Tools › Right-click with painting tools set to Erase. pub erase: bool, + /// A Brush or Pencil stroke in Photoshop's Clear mode (`paint_mouse::clears`, #3439): it + /// erases, sent as `"mode": "clear"` so History names the tool rather than the Eraser. + pub clear: bool, /// ⇧ constraint of a painting stroke (stroke_constraint.rs). pub constrain: Option, /// Modifiers held now (updated on every pointer event of the gesture). @@ -150,6 +153,7 @@ impl Drag { points, modifiers, erase, + clear: false, constrain: None, live: modifiers, released: egui::Modifiers::NONE, @@ -471,8 +475,12 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> { /// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). The /// stroke smoothing is the session brush's (the options bar's Smoothing %). -fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec]) -> serde_json::Value { - let mut p = json!({ "points": points, "erase": erase, "zoom": app.point_zoom(), "target": paint_target(app) }); +fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec]) -> serde_json::Value { + let tool = d.tool; + let mut p = json!({ "points": points, "erase": d.erase, "zoom": app.point_zoom(), "target": paint_target(app) }); + if d.clear { + p["mode"] = json!("clear"); + } if tool == Tool::Pencil { p["autoErase"] = json!(app.ui.tool_options.pencil_auto_erase); } @@ -631,7 +639,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { p["target"] = paint_target(app); EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) } else { - let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points)); + let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?)) }; let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff; @@ -1772,7 +1780,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { .documents() .iter() .map(|st| { - let name = format!("{}{}", st.doc.name, if st.is_dirty() { " *" } else { "" }); + let name = studio_tab_name(app, st); ui.painter().layout_no_wrap(name, font.clone(), t.text).size().x + ui.painter().layout_no_wrap(format!("{}/{}", mode_label(&st.doc), st.doc.depth.bits()), meta_font.clone(), t.text_faint).size().x + STUDIO_TAB_PAD @@ -1802,7 +1810,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { for &(i, r) in placed.iter().filter(|(i, _)| *i < tab_count) { let Some(st) = app.session.documents().get(i) else { continue }; let sel = Some(i) == active && !focused_open; - let name = format!("{}{}", st.doc.name, if st.is_dirty() { " *" } else { "" }); + let name = studio_tab_name(app, st); let meta = format!("{}/{}", mode_label(&st.doc), st.doc.depth.bits()); let natural_w = natural.get(i).copied().unwrap_or(0.0); let meta_g = ui.painter().layout_no_wrap(meta, meta_font.clone(), t.text_faint); @@ -1811,7 +1819,11 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { // The title was cut: the tooltip has the rest of it. let cut = name_g.size().x + 0.5 < natural_w - STUDIO_TAB_PAD + STUDIO_TAB_GAP - meta_g.size().x; let resp = ui.interact(r, ui.id().with(("dtab", i)), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, &st.doc.name)); + let saving = crate::jobs_ui::save_progress(app, st.doc.id); + resp.widget_info(|| { + let label = saving.as_ref().map_or_else(|| st.doc.name.clone(), |s| format!("{} - {s}", st.doc.name)); + egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, label) + }); doc_tabs.push((i, r)); if sel { ui.painter().rect_filled(r, t.radius_sm, t.card); @@ -2019,7 +2031,15 @@ fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some()); let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() }; let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default(); - format!("{} @ {}% ({layer}{model}/{}){}", st.doc.name, fmt_zoom(zoom), st.doc.depth.bits(), if st.is_dirty() { "*" } else { "" }) + // A save in progress: "name @ 100% (Layer 1, RGB/16) - Saving 45%", as Photoshop does (#3389). + let saving = crate::jobs_ui::save_progress(app, st.doc.id).map(|s| format!(" - {s}")).unwrap_or_default(); + format!("{} @ {}% ({layer}{model}/{}){}{saving}", st.doc.name, fmt_zoom(zoom), st.doc.depth.bits(), if st.is_dirty() { "*" } else { "" }) +} + +/// The Studio strip's tab name: "name *" when unsaved, "name * - Saving 45%" while it saves (#3389). +fn studio_tab_name(app: &PhotocraftApp, st: &photocraft_engine::DocState) -> String { + let saving = crate::jobs_ui::save_progress(app, st.doc.id).map(|s| format!(" - {s}")).unwrap_or_default(); + format!("{}{}{saving}", st.doc.name, if st.is_dirty() { " *" } else { "" }) } /// Photoshop document tabs: "name @ 33.3% (RGB/8)" on a dark strip; active tab matches panels. @@ -2814,6 +2834,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // ⌘ held: this frame's gestures go to the Move tool (`PhotocraftApp::active_tool`). The Hand // and Zoom never reach `tool_event`, so only the Move needs the state machine to know. app.tool_override = (temporary == Some(crate::hold_keys::Temporary::Move)).then_some(Tool::Move); + // `~` held: a Brush or Pencil stroke that starts now erases (`tool_event`'s `Down`). + app.held_erase = crate::hold_keys::erase_held(app, &ctx); // Zoom direction: the temporary zoom key decides, else ⌥ (Zoom tool), held or latched in // Window › Modifier Keys (the way around a desktop that takes Alt+click, `alt_grab`). let sticky_alt = app.ui.shell.sticky_alt; @@ -3419,9 +3441,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } egui::CursorIcon::None } + // A shaped tip (sampled bitmap, or a rotated/flattened round tip) + // traces its outline; a plain round tip keeps the circle. _ => { let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r); - crate::tool_cursor::circle(&painter, p, r, centre) + crate::brush_cursor::cursor(&painter, p, brush, full, cur.painting == PaintingCursor::NormalTip, centre) } } } @@ -4079,6 +4103,19 @@ fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: } } +/// The line from the brush to the pointer while stroke smoothing lags (Pulled String Mode and +/// Stroke Catch-up, #3479): a dark underlay and a light line stay visible on any pixels, and a dot +/// marks the brush end. +fn draw_smoothing_string(painter: &egui::Painter, xf: &ViewXform, (brush, pointer): ([f64; 2], [f64; 2])) { + let (a, b) = (xf.to_screen(brush[0] as f32, brush[1] as f32), xf.to_screen(pointer[0] as f32, pointer[1] as f32)); + if a.distance(b) < 2.0 { + return; + } + painter.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(110))); + painter.line_segment([a, b], Stroke::new(1.0, Color32::WHITE)); + painter.circle_filled(a, 2.5, Color32::WHITE); +} + fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { draw_tool_state(app, painter, xf, painter.ctx().input(|i| i.pointer.hover_pos())); let Some(d) = &app.drag else { @@ -4097,7 +4134,14 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View match d.tool { // The canvas shows the live stroke itself (`LiveStroke`); a stroke that couldn't start one // (Sample All Layers, say) keeps the trail. - t if strokes_live(t) && app.live_stroke.is_some() => {} + t if strokes_live(t) && app.live_stroke.is_some() => { + // Smoothing holds the brush back from the pointer: draw the string between them. + if let Some(EngineStroke::Brush(l)) = app.live_stroke.as_ref().map(|l| &l.stroke) + && let Some(s) = l.smoothing_string() + { + draw_smoothing_string(painter, xf, s); + } + } t if t.is_brushlike() || t == Tool::QuickSelection => { // Retouching strokes preview as a translucent trail of the brush footprint: a mask, // not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189). @@ -4597,7 +4641,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) _ => {} } crate::paint_mouse::sync_tool_brush(app); - let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase); + // Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush. + let clear = crate::paint_mouse::clears(app, tool); + let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear; // ⇧-click connects to the last endpoint, including its pen pressure (#2827), so // the renderer interpolates size and opacity across the segment. let active = app.session.active().map(|st| st.doc.id); @@ -4608,7 +4654,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // ⌥ flips Dodge/Burn and Blur/Sharpen for this stroke. let tool = crate::retouch_ui::alt_flipped(tool, mods.alt); - app.drag = Some(Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase)); + app.drag = Some(Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) }); app.trail = None; app.stylus.begin_stroke(); if from.is_some() { @@ -4835,7 +4881,7 @@ fn finish_selection_drag(app: &mut PhotocraftApp, floating: bool, start: [f64; 2 /// the canvas then refreshes only what it hadn't shown yet (`CommittedStroke`). fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { let live = app.live_stroke.take(); - let mut p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points)); + let mut p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); if let Some(seed) = live.as_ref().and_then(|l| l.stroke.seed()) { p["seed"] = json!(seed); } @@ -6460,20 +6506,44 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width": 120, "height": 60})).unwrap(); app.run("tools.setBrush", json!({"brush": {"size": 30, "hardness": 0.0}})).unwrap(); + // A smoothed stroke also draws its string line (see + // `smoothed_brush_drag_draws_the_string_from_brush_to_pointer`): that is no stand-in shape. + let overlay = |app: &mut PhotocraftApp, ctx: &egui::Context| { + drag_preview_shapes(app, ctx, 12.0).iter().filter(|s| !matches!(s.shape, egui::Shape::LineSegment { .. } | egui::Shape::Circle(_))).count() + }; for tool in [Tool::Brush, Tool::Eraser] { app.ui.tool = tool; - let idle = drag_preview_shapes(&mut app, &ctx, 12.0).len(); + let idle = overlay(&mut app, &ctx); tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); for i in 1..40 { let t = f64::from(i); tool_event(&mut app, ToolEvent::Move { x: 20.0 + t, y: 30.0 + (t / 3.0).sin() * 4.0, pressure: 1.0 }, egui::Modifiers::NONE); } assert!(app.live_stroke.is_some(), "{tool:?}: the canvas shows the live stroke"); - assert_eq!(drag_preview_shapes(&mut app, &ctx, 12.0).len(), idle, "{tool:?}: no overlay while dragging"); + assert_eq!(overlay(&mut app, &ctx), idle, "{tool:?}: no overlay while dragging"); tool_event(&mut app, ToolEvent::Up { x: 59.0, y: 30.0 }, egui::Modifiers::NONE); } } + #[test] + fn smoothed_brush_drag_draws_the_string_from_brush_to_pointer() { + // #3479: with smoothing the brush lags behind the pointer; a line joins them while drawing. + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 120, "height": 60})).unwrap(); + app.ui.tool = Tool::Brush; + let lines = |app: &mut PhotocraftApp| drag_preview_shapes(app, &ctx, 4.0).iter().filter(|s| matches!(s.shape, egui::Shape::LineSegment { .. })).count(); + for (amount, string) in [(0.0, false), (0.8, true)] { + app.run("tools.setBrush", json!({"brush": {"size": 10, "smoothing": {"amount": amount}}})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + for x in [30.0, 60.0, 90.0] { + tool_event(&mut app, ToolEvent::Move { x, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + } + assert_eq!(lines(&mut app) > 0, string, "smoothing {amount}"); + tool_event(&mut app, ToolEvent::Up { x: 90.0, y: 30.0 }, egui::Modifiers::NONE); + } + } + #[test] fn retouch_drags_show_the_footprint_without_wedges() { // The retouching tools' trail was the same brush-wide polyline (#189): now a mask of the diff --git a/crates/ui-egui/src/cjk_fonts.rs b/crates/ui-egui/src/cjk_fonts.rs index 7cd253e95..e5166310e 100644 --- a/crates/ui-egui/src/cjk_fonts.rs +++ b/crates/ui-egui/src/cjk_fonts.rs @@ -65,6 +65,7 @@ pub fn no_embedded(_: CjkScript) -> Vec<&'static CraftFont> { impl Sources { #[cfg(not(target_arch = "wasm32"))] pub fn system() -> Self { + cjk::start_fontconfig_cjk_scan(); Self { locale: || match crate::i18n::current().code() { code @ ("ja" | "ko" | "zh-hans" | "zh-hant") => Some(code.to_string()), @@ -90,6 +91,18 @@ impl Sources { } } +/// Headless test frame pumps do not wait for `request_repaint_after`, so let the optional +/// Fontconfig worker finish before asserting on installed system fonts. +#[cfg(all(test, target_os = "linux"))] +pub(crate) fn wait_for_fontconfig_cjk_scan() { + cjk::start_fontconfig_cjk_scan(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while cjk::fontconfig_cjk_scan_pending() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!cjk::fontconfig_cjk_scan_pending(), "Fontconfig CJK lookup did not finish within 10 seconds"); +} + /// The lazy loader's state: which scripts were tried and which files were read. pub struct CjkFallback { sources: Sources, @@ -139,14 +152,20 @@ impl CjkFallback { if self.tried.contains(&s) { continue; } - self.tried.push(s); if let Some(f) = self.load_embedded(s) { + self.tried.push(s); return Some(f); } + let fontconfig_pending = cjk::fontconfig_cjk_scan_pending(); let files = (self.sources.files)(s); if let Some(f) = self.load_first(&files) { + self.tried.push(s); return Some(f); } + if fontconfig_pending { + return None; + } + self.tried.push(s); } if !self.last_resort_tried { self.last_resort_tried = true; @@ -211,7 +230,7 @@ impl CjkFallback { continue; } let Some(bytes) = read_font(&f.path) else { continue }; - let index = cjk::face_index_for_family(&bytes, f.family); + let index = cjk::face_index_for_family(&bytes, &f.family); self.loaded.push(f.path.clone()); let name = format!("{FONT_PREFIX}-{}", self.registered.len()); log::info!("UI font fallback: registered {} (face {index}) as {name}", f.path.display()); @@ -348,6 +367,8 @@ impl egui::Plugin for CjkFontPlugin { if let Some((name, data)) = font { add_to_all_families(ctx, name, data); ctx.request_repaint(); + } else if cjk::classify(c).is_some() && cjk::fontconfig_cjk_scan_pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); } else if !self.0.exhausted() || !self.0.scripts_exhausted() { // Nothing readable for this script; try the next one on the next frame. ctx.request_repaint(); @@ -375,7 +396,6 @@ pub fn install_with(ctx: &egui::Context, sources: Sources) { ctx.add_plugin(CjkFontPlugin(fallback)); } } - #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] mod tests { @@ -447,7 +467,7 @@ mod tests { } fn fake(name: &str) -> FontFile { - FontFile { path: fake_dir().join(name), family: "" } + FontFile { path: fake_dir().join(name), family: String::new() } } fn fake_sources(locale: fn() -> Option) -> Sources { @@ -805,6 +825,8 @@ mod tests { /// machine has no font for it, e.g. CI Linux without Noto CJK). #[test] fn system_fonts_cover_cjk_samples() { + #[cfg(target_os = "linux")] + wait_for_fontconfig_cjk_scan(); let available = |s: CjkScript| cjk::font_files(s).iter().any(|f| f.path.is_file()); let samples = [ ("图层", CjkScript::SimplifiedChinese), diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index be92f041f..b0500437c 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -202,6 +202,20 @@ fn query_key(app: &PhotocraftApp, params: &Value) -> u64 { key.finish().max(1) } +/// Keep the accelerated preview paths in sync with the engine's Color Range replacement. +/// The full-resolution engine handles other combine modes itself. +fn scope_preview(proxy: &Document, params: &Value, mask: &mut [f32]) { + if matches!(params.get("mode").and_then(Value::as_str), Some("add" | "subtract" | "intersect")) { + return; + } + if let Some(selection) = proxy.selection.as_ref() { + let prior = photocraft_algo::selection::mask_from_surface(Some(selection), proxy.bounds()); + for (value, bound) in mask.iter_mut().zip(prior) { + *value = value.min(bound); + } + } +} + /// The selection mask `params` would make, on the proxy: the engine command run on a scratch /// session (Out Of Gamut uses the document's own proof setup). fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Result, String> { @@ -223,7 +237,7 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> _ => photocraft_compose::render(proxy, area).px, }; let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false); - return Ok(query + let mut mask: Vec = query .coverage(&px, area.width() as usize, k as f32, [0.0, 0.0]) .into_iter() .map(|v| { @@ -231,13 +245,17 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> // Match the command's GRAY8 mask rounding, before display interpolation. (v.clamp(0.0, 1.0) * 255.0 + 0.5).floor() / 255.0 }) - .collect()); + .collect(); + scope_preview(proxy, params, &mut mask); + return Ok(mask); } if params.get("select").and_then(Value::as_str) == Some("outOfGamut") { let pv = app.session.color.proof(proxy.id); let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).map_err(|e| e.to_string())?; let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false); - return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect()); + let mut mask: Vec = m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect(); + scope_preview(proxy, params, &mut mask); + return Ok(mask); } let mut p = params.clone(); // Eyedropper points are in document pixels; the proxy is 1/k the size. @@ -258,9 +276,8 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> } let mut s = photocraft_engine::Session::new(); s.tools = app.session.tools.clone(); - let mut doc = proxy.clone(); - doc.selection = None; - s.add_document(doc, None); + // Preserve the proxy's pre-existing selection so preview and OK use the same scope. + s.add_document(proxy.clone(), None); if let Some(id) = app.session.active().and_then(|d| d.active_layer) { let _ = s.select_layer(id); } @@ -627,6 +644,9 @@ 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) => { @@ -683,6 +703,9 @@ 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 { @@ -788,6 +811,29 @@ mod tests { h.get_by_label(label).accesskit_node().is_disabled() } + #[test] + fn preview_and_eyedropper_keep_the_original_selection_boundary() { + let mut app = app_with_doc(); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + for params in [json!({"select": "sampledColors", "color": "#ff0000", "fuzziness": 0}), json!({"select": "reds"})] { + let mask = proxy_mask(&app, &doc, 1, ¶ms).unwrap(); + assert_eq!(mask[5 * 40 + 5], 1.0, "red inside the selection stays visible"); + assert_eq!(mask[5 * 40 + 25], 0.0, "the blue outside the selection stays excluded"); + } + for params in [json!({"select": "sampledColors", "color": "#ffffff", "invert": true}), json!({"select": "outOfGamut", "invert": true})] { + let mask = proxy_mask(&app, &doc, 1, ¶ms).unwrap(); + assert_eq!(mask[5 * 40 + 25], 0.0, "even inverted previews stay within the selection"); + } + + 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, [5.0, 5.0], egui::Modifiers::NONE)); + assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[5.0, 5.0]]); + } + #[test] fn preview_cache_refreshes_after_foreground_and_proof_changes() { let mut app = app_with_doc(); @@ -921,7 +967,8 @@ mod tests { #[test] fn ok_applies_one_undo_step() { let mut h = harness(app_with_doc()); - h.state_mut().run("select.rect", json!({"x": 0, "y": 20, "width": 5, "height": 5})).unwrap(); + // The prior selection covers the left red square: Color Range is scoped to it (#3313). + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); let past = h.state().session.active().unwrap().history.past_len(); open(h.state_mut()); h.run_steps(4); @@ -935,7 +982,7 @@ mod tests { assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Color Range")); h.state_mut().run("edit.undo", json!({})).unwrap(); let app = h.state(); - assert_eq!((coverage(app, 5, 5), coverage(app, 2, 22)), (0.0, 1.0), "undo restores the previous selection"); + assert_eq!((coverage(app, 5, 5), coverage(app, 2, 22), coverage(app, 30, 5)), (1.0, 1.0, 0.0), "undo restores the previous selection"); } #[test] diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index 2e5864743..28a6a0e12 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -63,17 +63,9 @@ pub fn dropdown_options() -> Vec<(String, &'static str)> { v } -/// The unit a suffix names (`in`, `cm`, `mm`, `px`, `pt`, `pica`, and their long forms). +/// The unit a suffix names ([`Unit::from_suffix`]); a crop size has no percent. fn unit_of(suffix: &str) -> Option { - Some(match suffix.to_ascii_lowercase().as_str() { - "px" | "pixel" | "pixels" => Unit::Pixels, - "in" | "inch" | "inches" | "\"" => Unit::Inches, - "cm" | "centimeter" | "centimeters" | "centimetre" | "centimetres" => Unit::Centimeters, - "mm" | "millimeter" | "millimeters" | "millimetre" | "millimetres" => Unit::Millimeters, - "pt" | "point" | "points" => Unit::Points, - "pica" | "picas" => Unit::Picas, - _ => return None, - }) + Unit::from_suffix(suffix).filter(|u| *u != Unit::Percent) } /// A typed length: a positive number and an optional unit ("4 in", "4in", "10,5 cm", "1024"). @@ -375,7 +367,8 @@ mod tests { assert_eq!(parse_length("1024 px", Unit::Inches), Some((1024.0, Unit::Pixels))); assert_eq!(parse_length("8,5 in", Unit::Pixels), Some((8.5, Unit::Inches))); assert_eq!(parse_length("12", Unit::Centimeters), Some((12.0, Unit::Centimeters)), "a bare number keeps the field's unit"); - for bad in ["", "in", "0 in", "-4 in", "4 furlongs", "NaN", "1e400 px", "4 in 5"] { + assert_eq!(parse_length("3 pc", Unit::Pixels), Some((3.0, Unit::Picas)), "the shared unit table"); + for bad in ["", "in", "0 in", "-4 in", "4 furlongs", "NaN", "1e400 px", "4 in 5", "50 %", "50 percent"] { assert_eq!(parse_length(bad, Unit::Pixels), None, "{bad}"); } assert_eq!(normalize_length("4in", ""), "4 in"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cf139aaed..1d45c03cc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -234,6 +234,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields), DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields), DialogKind::Command if crate::rasterize_prompt::owns(&fields) => crate::rasterize_prompt::body(ui, &fields), + DialogKind::Command if crate::duplicate_layer_ui::owns(&fields) => crate::duplicate_layer_ui::body(app, ui, &mut fields), DialogKind::Command if crate::tool_reset::owns(&fields) => crate::tool_reset::body(ui), DialogKind::Command if crate::crop_presets::owns(&fields) => crate::crop_presets::body(app, ui, &mut fields), DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields), @@ -266,6 +267,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { first_field_focused(ui, id, |ui| body(ui, &mut fields)); } } + DialogKind::Command if crate::new_guide_ui::owns(&fields) => crate::new_guide_ui::body(ui, &mut fields), DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields), DialogKind::Command => {} DialogKind::LayerStyle => crate::layer_style::body(app, ui, &mut fields), @@ -470,6 +472,8 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { } if r.is_ok() { crate::remember_new_document(app, &d.fields); + // File › New › Recent. Best effort: the document exists either way. + let _ = app.run("document.recent.add", json!({"settings": crate::new_doc_ui::document_settings(&d.fields)})); } r } @@ -482,10 +486,12 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), DialogKind::Command if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields), + DialogKind::Command if crate::duplicate_layer_ui::owns(&d.fields) => crate::duplicate_layer_ui::confirm(app, &d.fields), DialogKind::Command if crate::tool_reset::owns(&d.fields) => crate::tool_reset::confirm(app), DialogKind::Command if crate::crop_presets::owns(&d.fields) => crate::crop_presets::confirm(app, &d.fields), DialogKind::Command if crate::variables_ui::owns(&d.fields) => crate::variables_ui::confirm(app, &d.fields), DialogKind::Command if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields), + DialogKind::Command if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields), DialogKind::Command if crate::color_picker_ui::owns(&d.fields) => crate::color_picker_ui::confirm(app, &d.fields), DialogKind::Command if crate::color_range_ui::owns(&d.fields) => crate::color_range_ui::confirm(app, &d.fields), DialogKind::Command if crate::prefs_ui::owns(&d.fields) => crate::prefs_ui::confirm(app, &d.fields), diff --git a/crates/ui-egui/src/duplicate_layer_ui.rs b/crates/ui-egui/src/duplicate_layer_ui.rs new file mode 100644 index 000000000..b1e7d6d99 --- /dev/null +++ b/crates/ui-egui/src/duplicate_layer_ui.rs @@ -0,0 +1,129 @@ +//! Layer › Duplicate Layer… dialog (#3348): the copy's name and its destination document, as in +//! Photoshop. Only the menu, palette and Layers panel menu open it; ⌘J, Alt-drag and dragging onto +//! the New Layer button still duplicate at once. +use crate::{PhotocraftApp, state::DialogKind}; +use serde_json::{Map, Value, json}; +const MARK: &str = "__duplicateLayer"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +pub fn open(app: &mut PhotocraftApp) -> Result { + let index = app.session.active_index().ok_or("no document")?; + let st = app.session.active().ok_or("no document")?; + let selected = st.selected_layers(); + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("no active layer")?; + let mut fields = Map::new(); + fields.insert(MARK.into(), json!(true)); + fields.insert("__command".into(), json!("layer.duplicate")); + fields.insert("destination".into(), json!(index)); + fields.insert("document".into(), json!(st.doc.id.0)); + fields.insert("selected".into(), json!(selected.iter().map(|id| id.0).collect::>())); + // Several selected layers keep their names, as in Photoshop's Duplicate Layers. + if selected.len() > 1 { + fields.insert("__label".into(), json!("Duplicate Layers…")); + } else { + fields.insert("__label".into(), json!("Duplicate Layer…")); + fields.insert("name".into(), json!(st.doc.copy_name(&layer.name))); + } + let id = app.ui.open_dialog(DialogKind::Command, fields); + Ok(json!({"dialog": id})) +} + +pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { + let documents: Vec<(usize, String)> = app.session.documents().iter().enumerate().map(|(i, d)| (i, d.doc.name.clone())).collect(); + let options: Vec<(usize, &str)> = documents.iter().map(|(i, n)| (*i, n.as_str())).collect(); + let mut destination = fields.get("destination").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); + egui::Grid::new("duplicate-layer").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + if let Some(name) = fields.get("name").and_then(Value::as_str) { + let mut name = name.to_string(); + ui.label(tl!("Name:")); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); + fields.insert("name".into(), json!(name)); + ui.end_row(); + } + ui.label(egui::RichText::new(tl!("Destination")).font(crate::theme::semibold(13.0))); + ui.end_row(); + ui.label(tl!("Document")); + crate::widgets::dropdown(ui, "duplicate-layer-document", &mut destination, &options, 260.0); + ui.end_row(); + }); + fields.insert("destination".into(), json!(destination)); +} + +pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + let index = app.session.active_index().ok_or("The document is closed")?; + let st = app.session.active().ok_or("The document is closed")?; + if fields.get("document").and_then(Value::as_u64) != Some(st.doc.id.0) + || fields.get("selected") != Some(&json!(st.selected_layers().iter().map(|id| id.0).collect::>())) + { + return Err("The document or selected layers changed. Choose Duplicate Layer again.".into()); + } + let destination = fields.get("destination").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or("no destination document")?; + let mut params = Map::new(); + if let Some(name) = fields.get("name").and_then(Value::as_str) { + params.insert("name".into(), json!(name)); + } + if destination == index { + app.run_command("layer.duplicate", Value::Object(params)) + } else { + params.insert("document".into(), json!(destination)); + app.run_command("layer.copyToDocument", Value::Object(params)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn make_app() -> PhotocraftApp { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"name":"Poster"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Ink"})).unwrap(); + PhotocraftApp::new(s, crate::Services::default()) + } + fn invoke(app: &mut PhotocraftApp) -> u64 { + let ctx = egui::Context::default(); + crate::menus::invoke(app, &ctx, "layer.duplicate", json!({})).unwrap()["dialog"].as_u64().unwrap() + } + fn names(app: &PhotocraftApp, i: usize) -> Vec { + app.session.documents()[i].doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).collect() + } + + #[test] + fn the_menu_opens_the_dialog_and_ok_duplicates_under_the_typed_name() { + let mut app = make_app(); + let dialog = invoke(&mut app); + assert_eq!(app.session.active().unwrap().doc.layer_count(), 2, "nothing is copied until OK"); + assert_eq!(app.ui.dialogs[0].fields["name"], "Ink copy"); + app.ui.dialog_mut(dialog).unwrap().fields.insert("name".into(), json!("Shadow")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(names(&app, 0).contains(&"Shadow".to_string())); + assert_eq!(app.session.active().unwrap().doc.layer_count(), 3); + } + + #[test] + fn another_destination_document_receives_the_copy() { + let mut app = make_app(); + app.session.execute("file.new", json!({"width":8,"height":8,"name":"Card"})).unwrap(); + app.session.set_active(0); + let dialog = invoke(&mut app); + app.ui.dialog_mut(dialog).unwrap().fields.insert("destination".into(), json!(1)); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(app.session.documents()[0].doc.layer_count(), 2); + assert!(names(&app, 1).contains(&"Ink copy".to_string())); + } + + #[test] + fn a_changed_document_refuses_and_direct_runs_stay_dialog_free() { + let mut app = make_app(); + let dialog = invoke(&mut app); + app.session.execute("file.new", json!({"width":8,"height":8})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, dialog).is_err()); + app.session.set_active(0); + app.run("layer.duplicate", json!({})).unwrap(); + app.run("layer.new.layerViaCopy", json!({})).unwrap(); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(app.session.active().unwrap().doc.layer_count(), 4); + } +} diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 0ac8f9802..310c9fea0 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -985,4 +985,18 @@ mod tests { assert!(r["dialog"].is_u64(), "{id}"); } } + + #[test] + fn print_dialog_does_not_inherit_scripted_send_override() { + for send in [false, true] { + let (mut app, ctx) = app(); + let out = std::env::temp_dir().join(format!("pc-printui-state-{}-{send}.pdf", std::process::id())).to_string_lossy().into_owned(); + app.session.execute("file.print", json!({"output": out, "send": send, "dryRun": true})).unwrap(); + assert!(app.session.file_menu.last_print.as_ref().unwrap().get("send").is_none()); + let r = crate::menus::invoke(&mut app, &ctx, "file.print", json!({})).unwrap(); + let id = r["dialog"].as_u64().unwrap(); + assert!(app.ui.dialog_mut(id).unwrap().fields.get("send").is_none()); + std::fs::remove_file(out).unwrap(); + } + } } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa2652127..13ded7604 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -281,7 +281,6 @@ pub fn has_dialog(command: &str) -> bool { || matches!( command, "image.trim" - | "view.newGuide" | "select.refineEdge" | "edit.assignProfile" | "edit.convertToProfile" diff --git a/crates/ui-egui/src/guide_layout_ui.rs b/crates/ui-egui/src/guide_layout_ui.rs index d85daf305..d6c5c9fdb 100644 --- a/crates/ui-egui/src/guide_layout_ui.rs +++ b/crates/ui-egui/src/guide_layout_ui.rs @@ -8,7 +8,24 @@ use crate::{PhotocraftApp, theme::Tokens, widgets}; const COMMAND: &str = "view.newGuideLayout"; const MARK: &str = "__guideLayout"; -const COLORS: [(&str, &str); 5] = [("#4affff", "Cyan"), ("#ff00ff", "Magenta"), ("#378ef0", "Blue"), ("#00a86b", "Green"), ("#ffcc00", "Yellow")]; + +/// A `"#rrggbb"` colour as drawn. +fn rgb(hex: &str) -> Option { + photocraft_engine::prefs::parse_hex(hex).map(|[r, g, b]| Color32::from_rgb(r, g, b)) +} + +/// The Color list: New Guide's colours (`GUIDE_COLORS`) by their `"#rrggbb"`, then `current` as +/// Custom when it's none of them. +fn color_options(current: &str) -> Vec<(String, &'static str)> { + let mut colors: Vec<(String, &'static str)> = photocraft_engine::edit_cmds::GUIDE_COLORS + .iter() + .map(|(name, [r, g, b])| (format!("#{r:02x}{g:02x}{b:02x}"), crate::new_guide_ui::color_label(name))) + .collect(); + if !colors.iter().any(|(v, _)| v == current) { + colors.push((current.to_string(), "Custom")); + } + colors +} pub fn owns(f: &Map) -> bool { f.contains_key(MARK) @@ -102,7 +119,7 @@ fn calculate(app: &PhotocraftApp, f: &Map) -> Result) -> Result<(), String> { let color = f.get("color").and_then(Value::as_str).unwrap_or("#4affff"); - crate::rulers::parse_guide_color(color).ok_or("invalid guide color")?; + rgb(color).ok_or("invalid guide color")?; calculate(app, f).map(|_| ()) } @@ -110,7 +127,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result Option<(Guides, Color32)> return None; } let guides = calculate(app, f).ok().or_else(|| f.get("__previewGuides").and_then(|v| serde_json::from_value(v.clone()).ok()))?; - let color = f.get("color").and_then(Value::as_str).and_then(crate::rulers::parse_guide_color).unwrap_or(Color32::from_rgb(74, 255, 255)); + let color = f.get("color").and_then(Value::as_str).and_then(rgb).unwrap_or(Color32::from_rgb(74, 255, 255)); Some((guides, color)) } @@ -261,14 +278,11 @@ fn main_fields(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = COLORS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - if !COLORS.iter().any(|(v, _)| *v == color) { - colors.push((color.clone(), "Custom")); - } + let colors = color_options(&color); if widgets::dropdown(ui, "guide-color", &mut color, &colors, 174.0) { f.insert("color".into(), json!(color)); } - let shown = crate::rulers::parse_guide_color(&color).unwrap_or(Tokens::get(ui.ctx()).accent); + let shown = rgb(&color).unwrap_or(Tokens::get(ui.ctx()).accent); if widgets::color_swatch_button(ui, shown, tl!("Guide color")).clicked() { crate::color_picker_ui::request(f, tl!("Guide color")); } diff --git a/crates/ui-egui/src/guide_layout_ui/tests.rs b/crates/ui-egui/src/guide_layout_ui/tests.rs index 914a4a74b..17abf3e38 100644 --- a/crates/ui-egui/src/guide_layout_ui/tests.rs +++ b/crates/ui-egui/src/guide_layout_ui/tests.rs @@ -53,6 +53,16 @@ fn tab_skips_disabled_rows_and_margins() { assert_eq!(f["gutter"], 4.0); } +/// New Guide Layout offers New Guide's colours, by value, and keeps another one as Custom. +#[test] +fn color_list_is_new_guides() { + let hexes: Vec = color_options("#4affff").into_iter().map(|(hex, _)| hex).collect(); + assert_eq!(hexes, ["#3366ff", "#ff3333", "#33ff33", "#3333ff", "#ffcc00", "#ff00ff", "#4affff", "#e7e7e7", "#000000"]); + assert_eq!(color_options("#4affff")[6].1, "Cyan", "the default guide colour reads as Cyan"); + let custom = color_options("#123456"); + assert_eq!((custom.len(), custom[9].0.as_str(), custom[9].1), (10, "#123456", "Custom")); +} + fn harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { let mut initialized = false; egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index 5aba48c47..c182247bf 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -16,6 +16,8 @@ //! selection tools do their own ⌘ handling in `canvas` (⌘ inside the selection cuts the //! selected pixels, outside it moves the layer, #896), and the Hand, Zoom, Crop, Slice, Path //! Selection, shape, Pen and Type tools keep ⌘ for themselves. +//! - `~` (the backtick key) with the Brush or Pencil: a stroke started while it is held erases, +//! with that tool's own brush ([`erase_held`], #3439). //! //! The current tool is never changed, so releasing the key gives the previous tool back. A //! temporary tool that started a drag lasts until the button is released, as in Photoshop. @@ -123,8 +125,13 @@ pub fn is_temporary(id: &str) -> bool { /// The key binding of a temporary tool in effect (`None` when removed or unparsable). pub fn binding(app: &PhotocraftApp, t: Temporary) -> Option { - let def = photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|x| x.0 == t.id()).map(|x| x.2); - parse(app.session.prefs().shortcut(t.id(), def)?) + binding_of(app, t.id()) +} + +/// [`binding`] by id. +fn binding_of(app: &PhotocraftApp, id: &str) -> Option { + let def = photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|x| x.0 == id).map(|x| x.2); + parse(app.session.prefs().shortcut(id, def)?) } /// Is `sc` held: its key down with at least its modifiers (more may be held, so ⇧ still squares @@ -171,6 +178,20 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option held.or(cmd_move.then_some(Temporary::Move)) } +/// The binding id of the Brush and Pencil erase key (`prefs::TEMPORARY_TOOLS`). +const ERASE: &str = "tools.temporary.erase"; + +/// Tools whose strokes the erase key turns into erasing strokes with their own brush. +pub fn erases_while_held(t: Tool) -> bool { + matches!(t, Tool::Brush | Tool::Pencil) +} + +/// Is the erase key (`~` by default) held with a tool that [`erases_while_held`]? Never while a +/// text field has the keyboard. +pub fn erase_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { + erases_while_held(app.ui.tool) && !ctx.text_edit_focused() && binding_of(app, ERASE).is_some_and(|sc| ctx.input(|i| held(i, &sc))) +} + /// Is the reposition key (the Hand key, any modifiers) down? pub fn reposition_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { !ctx.text_edit_focused() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index 68b3a5bc5..45e806c64 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -622,6 +622,127 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() { } } +/// The params of the last brush stroke (`paint.stroke`, or the Pencil's `paint.pencil`). +fn last_stroke(app: &PhotocraftApp) -> serde_json::Value { + let journal = &app.session.journal; + journal.iter().rev().find(|(id, _)| id == "paint.stroke" || id == "paint.pencil").map(|(_, p)| p.clone()).unwrap_or_default() +} + +/// The `erase` flag of the last brush stroke. +fn last_stroke_erases(h: &Harness<'static, PhotocraftApp>) -> Option { + last_stroke(h.state())["erase"].as_bool() +} + +/// `~` held with the Brush or the Pencil erases with that tool's own brush (#3439): Photoshop's +/// Clear mode for that stroke, so History names the tool. The tool stays, and the next stroke +/// without the key paints again. +#[test] +fn tilde_held_with_the_brush_or_pencil_erases_with_its_brush() { + for (tool, label) in [(Tool::Brush, "Brush Tool"), (Tool::Pencil, "Pencil")] { + let mut h = harness(tool); + with_layer(&mut h); + key(&mut h, Key::Backtick, true, Modifiers::NONE); + drag_with(&mut h, Modifiers::NONE, 110.0, 130.0, 150.0, 130.0); + key(&mut h, Key::Backtick, false, Modifiers::NONE); + assert_eq!(last_stroke_erases(&h), Some(true), "{tool:?}"); + assert_eq!(rgba(&h, 130, 130)[3], 0.0, "{tool:?}: erased to transparency"); + assert_eq!(last_stroke(h.state())["mode"], json!("clear"), "{tool:?}"); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some(label)); + assert_eq!(h.state().ui.tool, tool, "the tool stays"); + drag_with(&mut h, Modifiers::NONE, 110.0, 140.0, 150.0, 140.0); + assert_eq!(last_stroke_erases(&h), Some(false), "{tool:?}: paints again"); + } +} + +/// Other tools ignore `~`: the Mixer Brush and the retouching tools have no erasing stroke. +#[test] +fn tilde_is_not_an_eraser_for_other_tools() { + let mut h = harness(Tool::Eraser); + with_layer(&mut h); + h.state_mut().ui.tool = Tool::CloneStamp; + key(&mut h, Key::Backtick, true, Modifiers::NONE); + assert!(!erase_held(h.state(), &h.ctx)); + key(&mut h, Key::Backtick, false, Modifiers::NONE); +} + +/// The key is a Tools › Temporary binding: rebound, the new key erases and `~` no longer does. +#[test] +fn rebound_brush_eraser_uses_the_new_key_only() { + let mut h = harness(Tool::Brush); + with_layer(&mut h); + let items = crate::prefs_ui::shortcut_items(h.state()); + let item = items.iter().find(|i| i.0 == "tools.temporary.erase").map(|i| (i.2.clone(), i.3.clone())); + assert_eq!(item, Some((vec!["Tools".to_string(), "Temporary".to_string()], Some("`".into())))); + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.erase": "F9"}})).unwrap(); + key(&mut h, Key::Backtick, true, Modifiers::NONE); + drag_with(&mut h, Modifiers::NONE, 110.0, 130.0, 150.0, 130.0); + key(&mut h, Key::Backtick, false, Modifiers::NONE); + assert_eq!(last_stroke_erases(&h), Some(false), "` is no longer bound"); + key(&mut h, Key::F9, true, Modifiers::NONE); + drag_with(&mut h, Modifiers::NONE, 110.0, 140.0, 150.0, 140.0); + key(&mut h, Key::F9, false, Modifiers::NONE); + assert_eq!(last_stroke_erases(&h), Some(true), "F9 erases"); +} + +/// The Mode dropdown's Clear (#3439): chosen there, every Pencil stroke erases with the Pencil +/// until another mode is chosen. +#[test] +fn clear_in_the_mode_dropdown_erases_until_another_mode_is_chosen() { + use crate::canvas::{ToolEvent, tool_event}; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 120, "height": 80})).unwrap(); + app.ui.tool = Tool::Pencil; + let mut h = Harness::builder().with_size(vec2(1600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let combo = |h: &Harness<'static, PhotocraftApp>, current: &str| { + h.get_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some(current)).unwrap().click(); + }; + combo(&h, "Normal"); + h.run_steps(3); + h.get_by_label("Clear").click(); + h.run_steps(3); + assert!(crate::paint_mouse::clears(h.state(), Tool::Pencil)); + let stroke = |h: &mut Harness<'static, PhotocraftApp>| { + let (app, m) = (h.state_mut(), Modifiers::NONE); + tool_event(app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: 100.0, y: 40.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: 100.0, y: 40.0 }, m); + }; + stroke(&mut h); + assert_eq!(last_stroke(h.state())["mode"], json!("clear")); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Pencil")); + combo(&h, "Clear"); + h.run_steps(3); + h.get_by_label("Normal").click(); + h.run_steps(3); + assert!(!crate::paint_mouse::clears(h.state(), Tool::Pencil)); + stroke(&mut h); + assert_eq!(last_stroke(h.state())["mode"], serde_json::Value::Null, "paints again"); + assert_eq!(last_stroke(h.state())["erase"], json!(false)); +} + +/// While `~` is held the Brush's Mode shows Clear, as in Photoshop. +#[test] +fn the_mode_shows_clear_while_tilde_is_held() { + let mut h = harness(Tool::Brush); + assert!(!crate::paint_mouse::clears(h.state(), Tool::Brush)); + key(&mut h, Key::Backtick, true, Modifiers::NONE); + assert!(crate::paint_mouse::clears(h.state(), Tool::Brush)); + key(&mut h, Key::Backtick, false, Modifiers::NONE); + h.run_steps(1); + assert!(!crate::paint_mouse::clears(h.state(), Tool::Brush)); +} + /// #3368 through the real key handling: holding Space (the temporary Hand) with a pending crop /// keeps it pending, while V (another tool) commits it, as in Photoshop. #[test] diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f66f69308..87ffb0cb1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2169,6 +2169,7 @@ Opening {name} Otevírání {name} Saved {name} Uloženo: {name} Saving {name} Ukládání {name} + Saving {percent} Ukládání {percent} A save to {name} is still running; try again when it finishes Ukládání do {name} stále probíhá; zkuste to znovu, až skončí {label} is writing the file and can no longer be cancelled {label} zapisuje soubor a už ho nelze zrušit Opening {name}… Otevírání {name}… @@ -3425,17 +3426,11 @@ cameraRaw Vibrance Živost cameraRaw Whites Bílé layerLabel No Color Žádná barva Loading fonts… Načítání písem… - Tool Nástroj Simulate Pressure Simulovat přítlak Background Eraser Guma pozadí - Clone Stamp Klonovací razítko Healing Brush Retušovací štětec Spot Healing Brush Bodový retušovací štětec - History Brush Štětec historie Smudge Rozmazání - Dodge Zesvětlení - Burn Ztmavení - Sponge Houba Amount: Míra: Protect: Chránit: Protect skin tones Chránit tóny pleti @@ -3657,3 +3652,7 @@ navigator Zoom Zvětšení Apply mask to layer before removing? Použít masku na vrstvu před odstraněním? The layer or its mask changed. Drag the mask onto the Trash again. Vrstva nebo její maska se změnila. Přetáhněte masku do koše znovu. Delete the vector mask? Odstranit vektorovou masku? +# View › Guides › New Guide… › Color + Light Blue Světle modrá + Light Red Světle červená + Medium Blue Středně modrá diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3deb6aefe..2f6e87946 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2136,6 +2136,7 @@ Opening {name} Öffne {name} Saved {name} Gespeichert: {name} Saving {name} Speichere {name} + Saving {percent} Speichern {percent} A save to {name} is still running; try again when it finishes {name} wird noch gespeichert; versuchen Sie es erneut, wenn der Vorgang abgeschlossen ist {label} is writing the file and can no longer be cancelled {label} schreibt die Datei und kann nicht mehr abgebrochen werden Opening {name}… Öffne {name}… @@ -2682,3 +2683,8 @@ curves Smooth Glätten @plural {n} step|{n} steps {n} Schritt|{n} Schritte @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Pinselvorgaben-Warnung)|{warning} (+{n} weitere Pinselvorgaben-Warnungen) @plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen +# View › Guides › New Guide… › Color + Light Blue Hellblau + Light Red Hellrot + Medium Blue Mittelblau + Light Gray Hellgrau diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ae85c5293..71531bd73 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1583,6 +1583,7 @@ Opening {name} Άνοιγμα του {name} Saved {name} Αποθηκεύτηκε το {name} Saving {name} Αποθήκευση του {name} + Saving {percent} Αποθήκευση {percent} A save to {name} is still running; try again when it finishes Η αποθήκευση στο {name} βρίσκεται ακόμη σε εξέλιξη· δοκιμάστε ξανά όταν ολοκληρωθεί {label} is writing the file and can no longer be cancelled {label}: γίνεται εγγραφή του αρχείου και δεν μπορεί πλέον να ακυρωθεί Opening {name}… Άνοιγμα του {name}… @@ -3740,3 +3741,8 @@ curves Smooth Εξομάλυνση @plural {n} step|{n} steps {n} βήμα|{n} βήματα @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογών πινέλων)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων) @plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις +# View › Guides › New Guide… › Color + Light Blue Ανοιχτό μπλε + Light Red Ανοιχτό κόκκινο + Medium Blue Μεσαίο μπλε + Light Gray Ανοιχτό γκρι diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1496ca486..948f40afd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2152,6 +2152,7 @@ Opening {name} Abriendo {name} Saved {name} Guardado: {name} Saving {name} Guardando {name} + Saving {percent} Guardando {percent} A save to {name} is still running; try again when it finishes Todavía se está guardando {name}; inténtelo de nuevo cuando termine {label} is writing the file and can no longer be cancelled {label} está escribiendo el archivo y ya no se puede cancelar Opening {name}… Abriendo {name}… @@ -2723,3 +2724,8 @@ curves Smooth Suavizar @plural {n} step|{n} steps {n} paso|{n} pasos @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel) @plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias +# View › Guides › New Guide… › Color + Light Blue Azul claro + Light Red Rojo claro + Medium Blue Azul medio + Light Gray Gris claro diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d4887522d..f4887a136 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2114,6 +2114,7 @@ Opening {name} Ouverture de {name} Saved {name} Enregistré : {name} Saving {name} Enregistrement de {name} + Saving {percent} Enregistrement {percent} A save to {name} is still running; try again when it finishes Un enregistrement de {name} est encore en cours ; réessayez lorsqu’il sera terminé {label} is writing the file and can no longer be cancelled {label} écrit le fichier et ne peut plus être annulé Opening {name}… Ouverture de {name}… @@ -3043,3 +3044,8 @@ curves Smooth Lisser Apply mask to layer before removing? Appliquer le masque au calque avant de le supprimer ? The layer or its mask changed. Drag the mask onto the Trash again. Le calque ou son masque a changé. Faites de nouveau glisser le masque sur la corbeille. Delete the vector mask? Supprimer le masque vectoriel ? +# View › Guides › New Guide… › Color + Light Blue Bleu clair + Light Red Rouge clair + Medium Blue Bleu moyen + Light Gray Gris clair diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ed68afcda..f0126aea4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2107,6 +2107,7 @@ Opening {name} Membuka {name} Saved {name} Tersimpan: {name} Saving {name} Menyimpan {name} + Saving {percent} Menyimpan {percent} A save to {name} is still running; try again when it finishes Penyimpanan ke {name} masih berjalan; coba lagi setelah selesai {label} is writing the file and can no longer be cancelled {label} sedang menulis berkas dan tidak dapat dibatalkan lagi Opening {name}… Membuka {name}… @@ -3030,3 +3031,8 @@ curves Smooth Haluskan Apply mask to layer before removing? Terapkan masker ke layer sebelum dihapus? The layer or its mask changed. Drag the mask onto the Trash again. Layer atau maskernya berubah. Seret masker ke Tempat Sampah lagi. Delete the vector mask? Hapus masker vektor? +# View › Guides › New Guide… › Color + Light Blue Biru Muda + Light Red Merah Terang + Medium Blue Biru Sedang + Light Gray Abu-abu Muda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3dd931dcc..002e60c74 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2151,6 +2151,7 @@ Opening {name} Apertura {name} Saved {name} Salvato: {name} Saving {name} Salvataggio {name} + Saving {percent} Salvataggio {percent} A save to {name} is still running; try again when it finishes Un salvataggio in {name} è ancora in corso; riprova quando termina {label} is writing the file and can no longer be cancelled {label} sta scrivendo il file e non può più essere annullato Opening {name}… Apertura {name}… @@ -2714,3 +2715,8 @@ curves Smooth Leviga @plural {n} step|{n} steps {n} passaggio|{n} passaggi @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso sui predefiniti pennello)|{warning} (+{n} altri avvisi sui predefiniti pennello) @plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi +# View › Guides › New Guide… › Color + Light Blue Blu chiaro + Light Red Rosso chiaro + Medium Blue Blu medio + Light Gray Grigio chiaro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f43ce5b8f..f4b85e2d4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2148,6 +2148,7 @@ Opening {name} {name} を開いています Saved {name} {name} を保存しました Saving {name} {name} を保存しています + Saving {percent} 保存中 {percent} A save to {name} is still running; try again when it finishes {name} への保存がまだ実行中です。完了してからもう一度お試しください {label} is writing the file and can no longer be cancelled {label} はファイルを書き込み中のため、キャンセルできません Opening {name}… {name} を開いています… @@ -3652,3 +3653,8 @@ curves Smooth スムーズ @plural {n} step|{n} steps {n} ステップ @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(ほかにブラシプリセットの警告 {n} 件) @plural {what} with {n} warning|{what} with {n} warnings {what}(警告 {n} 件) +# View › Guides › New Guide… › Color + Light Blue ライトブルー + Light Red ライトレッド + Medium Blue ミディアムブルー + Light Gray ライトグレー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 83529b618..4bd422faa 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2106,6 +2106,7 @@ Opening {name} {name} 여는 중 Saved {name} {name} 저장됨 Saving {name} {name} 저장 중 + Saving {percent} 저장 중 {percent} A save to {name} is still running; try again when it finishes {name}에 저장하는 작업이 아직 진행 중입니다. 완료된 후 다시 시도하세요 {label} is writing the file and can no longer be cancelled {label}: 파일을 쓰는 중이므로 더 이상 취소할 수 없습니다 Opening {name}… {name} 여는 중… @@ -3212,3 +3213,8 @@ curves Smooth 매끄럽게 Apply mask to layer before removing? 제거하기 전에 마스크를 레이어에 적용하시겠습니까? The layer or its mask changed. Drag the mask onto the Trash again. 레이어 또는 마스크가 변경되었습니다. 마스크를 휴지통으로 다시 드래그하세요. Delete the vector mask? 벡터 마스크를 삭제하시겠습니까? +# View › Guides › New Guide… › Color + Light Blue 연한 파랑 + Light Red 연한 빨강 + Medium Blue 중간 파랑 + Light Gray 연한 회색 diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index ea9aea440..7e5444fa2 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -166,6 +166,8 @@ fn cjk_font_order_follows_the_selected_language() { #[test] fn korean_glyphs_remain_available_after_repeated_language_switches() { use photocraft_text::cjk::{self, CjkChar, CjkScript}; + #[cfg(target_os = "linux")] + crate::cjk_fonts::wait_for_fontconfig_cjk_scan(); if !cjk::font_files(CjkScript::Korean).iter().any(|font| font.path.is_file()) { eprintln!("skipping: no installed Korean font"); return; diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c273c8f26..fc122160d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2117,6 +2117,7 @@ Opening {name} {name} openen Saved {name} {name} opgeslagen Saving {name} {name} opslaan + Saving {percent} Opslaan {percent} A save to {name} is still running; try again when it finishes Er wordt nog opgeslagen naar {name}; probeer het opnieuw zodra dat klaar is {label} is writing the file and can no longer be cancelled {label} schrijft het bestand en kan niet meer worden geannuleerd Opening {name}… {name} openen… @@ -3802,3 +3803,8 @@ curves Smooth Vloeiend maken @plural {n} step|{n} steps {n} stap|{n} stappen @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} andere waarschuwing over penseelvoorinstellingen)|{warning} (+{n} andere waarschuwingen over penseelvoorinstellingen) @plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen +# View › Guides › New Guide… › Color + Light Blue Lichtblauw + Light Red Lichtrood + Medium Blue Middelblauw + Light Gray Lichtgrijs diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e63d87b10..4a66daf1a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2139,6 +2139,7 @@ Opening {name} Otwieranie {name} Saved {name} Zapisano: {name} Saving {name} Zapisywanie {name} + Saving {percent} Zapisywanie {percent} A save to {name} is still running; try again when it finishes Zapisywanie do {name} wciąż trwa; spróbuj ponownie, gdy się zakończy {label} is writing the file and can no longer be cancelled {label} zapisuje plik i nie można już tego anulować Opening {name}… Otwieranie {name}… @@ -3639,3 +3640,8 @@ curves Smooth Wygładź Apply mask to layer before removing? Zastosować maskę do warstwy przed usunięciem? The layer or its mask changed. Drag the mask onto the Trash again. Warstwa lub jej maska uległa zmianie. Przeciągnij maskę ponownie do kosza. Delete the vector mask? Usunąć maskę wektorową? +# View › Guides › New Guide… › Color + Light Blue Jasnoniebieski + Light Red Jasnoczerwony + Medium Blue Średnioniebieski + Light Gray Jasnoszary diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8c3fedb7d..c6e43a705 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2111,6 +2111,7 @@ Opening {name} Abrindo {name} Saved {name} Salvo: {name} Saving {name} Salvando {name} + Saving {percent} Salvando {percent} A save to {name} is still running; try again when it finishes Um salvamento em {name} ainda está em andamento; tente novamente quando terminar {label} is writing the file and can no longer be cancelled {label} está gravando o arquivo e não pode mais ser cancelado Opening {name}… Abrindo {name}… @@ -3832,3 +3833,8 @@ layerLabel No Color Sem cor @plural {n} step|{n} steps {n} etapa|{n} etapas @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} outro aviso de predefinição de pincel)|{warning} (+{n} outros avisos de predefinição de pincel) @plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos +# View › Guides › New Guide… › Color + Light Blue Azul-claro + Light Red Vermelho-claro + Medium Blue Azul médio + Light Gray Cinza-claro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1cc764006..b32cf36fe 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2431,6 +2431,7 @@ Opening {name} Открытие {name} Saved {name} Сохранено: {name} Saving {name} Сохранение {name} + Saving {percent} Сохранение {percent} A save to {name} is still running; try again when it finishes Сохранение в {name} ещё выполняется; повторите попытку после его завершения {label} is writing the file and can no longer be cancelled {label}: идёт запись файла, отменить уже нельзя Opening {name}… Открытие {name}… @@ -2987,3 +2988,8 @@ curves Smooth Сгладить @plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (и ещё {n} предупреждение о наборах кистей)|{warning} (и ещё {n} предупреждения о наборах кистей)|{warning} (и ещё {n} предупреждений о наборах кистей) @plural {what} with {n} warning|{what} with {n} warnings {what}: {n} предупреждение|{what}: {n} предупреждения|{what}: {n} предупреждений +# View › Guides › New Guide… › Color + Light Blue Светло-синий + Light Red Светло-красный + Medium Blue Средне-синий + Light Gray Светло-серый diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b0110fb2c..4aa12cd73 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2404,6 +2404,7 @@ Opening {name} Відкриття {name} Saved {name} Збережено: {name} Saving {name} Збереження {name} + Saving {percent} Збереження {percent} A save to {name} is still running; try again when it finishes Збереження в {name} ще триває; спробуйте ще раз, коли воно завершиться {label} is writing the file and can no longer be cancelled {label}: триває запис файлу, скасувати вже не можна Opening {name}… Відкриття {name}… @@ -3740,3 +3741,8 @@ curves Smooth Згладити @plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попереджень щодо наборів пензлів) @plural {what} with {n} warning|{what} with {n} warnings {what}: {n} попередження|{what}: {n} попередження|{what}: {n} попереджень +# View › Guides › New Guide… › Color + Light Blue Світло-синій + Light Red Світло-червоний + Medium Blue Середньо-синій + Light Gray Світло-сірий diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 414324d34..4eb54e09c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2212,6 +2212,7 @@ cameraRaw Blending 混合 Opening {name} 正在打开 {name} Saved {name} 已保存 {name} Saving {name} 正在保存 {name} + Saving {percent} 正在保存 {percent} A save to {name} is still running; try again when it finishes 仍在保存到 {name};请在完成后重试 {label} is writing the file and can no longer be cancelled {label} 正在写入文件,已无法取消 Opening {name}… 正在打开 {name}… @@ -2647,3 +2648,8 @@ curves Smooth 平滑 Apply mask to layer before removing? 移去之前将蒙版应用到图层吗? The layer or its mask changed. Drag the mask onto the Trash again. 图层或其蒙版已更改。请再次将蒙版拖到垃圾桶。 Delete the vector mask? 要删除矢量蒙版吗? +# View › Guides › New Guide… › Color + Light Blue 浅蓝色 + Light Red 浅红色 + Medium Blue 中蓝色 + Light Gray 浅灰色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 11aeb09f2..dd6a73453 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2151,6 +2151,7 @@ Opening {name} 正在開啟 {name} Saved {name} 已儲存 {name} Saving {name} 正在儲存 {name} + Saving {percent} 正在儲存 {percent} A save to {name} is still running; try again when it finishes 仍在儲存到 {name};請在完成後再試一次 {label} is writing the file and can no longer be cancelled {label} 正在寫入檔案,已無法取消 Opening {name}… 正在開啟 {name}… @@ -2632,3 +2633,8 @@ curves Smooth 平滑 @plural {n} step|{n} steps {n} 個步驟 @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (另有 {n} 個筆刷預設集警告) @plural {what} with {n} warning|{what} with {n} warnings {what} ({n} 個警告) +# View › Guides › New Guide… › Color + Light Blue 淺藍色 + Light Red 淺紅色 + Medium Blue 中藍色 + Light Gray 淺灰色 diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 6e03a11ca..407c7ea50 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -593,12 +593,31 @@ fn shown_fraction(j: &JobInfo) -> Option { fn percent(j: &JobInfo) -> String { // Nothing yet reported: the bar is indeterminate, so no number. - if j.progress > 0.0 { format!("{:.0}%", j.progress * 100.0) } else { String::new() } + if j.progress > 0.0 { whole_percent(j.progress) } else { String::new() } } -/// The status bar's job readout: "Gaussian Blur ▬▬▬▭▭ 45 % ×". Draws nothing when no job runs. +/// "45%": rounded down, so a job reads 100% only once it's done. +fn whole_percent(fraction: f32) -> String { + format!("{:.0}%", (fraction.clamp(0.0, 1.0) * 100.0).floor()) +} + +/// A save's progress as Photoshop words it in the status bar and the tab title: "Saving 45%" +/// (#3389). +fn saving_percent(j: &JobInfo) -> String { + crate::i18n::fmt(tl!("Saving {percent}"), &[("percent", &whole_percent(j.progress))]) +} + +/// "Saving 45%" while a save of document `doc` runs, for its tab title; `None` otherwise. +pub fn save_progress(app: &PhotocraftApp, doc: DocId) -> Option { + let save = app.jobs.saves.iter().find(|s| s.doc == doc && !s.ended)?; + app.session.job(save.job).map(|j| saving_percent(&j)) +} + +/// The status bar's job readout: "Gaussian Blur ▬▬▬▭▭ 45 % ×", and for a save "Saving 45% ▬▬▬▭▭ ×" +/// (the file name on hover). Draws nothing when no job runs. pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(j) = job_in_view(app) else { return }; + let saving = app.jobs.saves.iter().any(|s| s.job == j.id); let t = Tokens::get(ui.ctx()); let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; let more = app.session.jobs().len().saturating_sub(1); @@ -613,11 +632,17 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_dim }); let xresp = xresp.on_hover_text(tl!("Cancel (Esc)")); - ui.label(RichText::new(percent(&j)).color(t.text_dim).size(11.5).monospace()); + if !saving { + ui.label(RichText::new(percent(&j)).color(t.text_dim).size(11.5).monospace()); + } let (br, _) = ui.allocate_exact_size(vec2(bar_w, bar_h), Sense::hover()); bar(ui, br, shown_fraction(&j), &t); - let label = if more > 0 { format!("{} (+{more})", tl!(&j.label)) } else { tl!(&j.label).to_owned() }; - ui.label(RichText::new(label).color(t.text_dim).size(12.0)); + let name = if saving { saving_percent(&j) } else { tl!(&j.label).to_owned() }; + let label = if more > 0 { format!("{name} (+{more})") } else { name }; + let resp = ui.label(RichText::new(label).color(t.text_dim).size(12.0)); + if saving { + resp.on_hover_text(tl!(&j.label)); + } if xresp.clicked() { cancel(app, j.id); } diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index 2ab8f7e94..3e01875c5 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -402,9 +402,11 @@ fn saves_run_in_the_background_and_are_recorded_when_they_end() { let st = h.state().session.active().unwrap(); assert!(st.is_dirty() && st.path.is_none()); assert!(h.state_mut().run("layer.new.layer", json!({})).is_err(), "the saving document is locked"); - // The progress dialog and the status bar name the save; the dialog shows its stage. - assert_eq!(h.query_all_by_label("Saving big.psd").count(), 2); + // The progress dialog names the save and shows its stage; the status bar reads "Saving 0%" + // until the encoder reports progress (#3389). + assert_eq!(h.query_all_by_label("Saving big.psd").count(), 1); h.get_by_label("Encoding"); + h.get_by_label("Saving 0%"); gates.encode.store(true, Ordering::Relaxed); step_until(&mut h, |a| !a.saving()); @@ -469,6 +471,41 @@ fn a_save_that_has_started_writing_cannot_be_cancelled() { assert!(!h.state().session.active().unwrap().is_dirty(), "the written save is recorded"); } +/// #3389: like Photoshop, a running save reads "Saving 45%" in the status bar and appends +/// " - Saving 45%" to its document's tab title, in both tab strips; both go when it ends. +#[test] +fn a_running_save_shows_its_percentage_in_the_status_bar_and_tab_title() { + for theme in crate::theme::ThemeKind::ALL { + let mut h = app_harness(); + h.state_mut().run("prefs.set", json!({"values": {"interface.theme": theme.id()}})).unwrap(); + h.run_steps(2); + let gate = Arc::new(AtomicBool::new(false)); + let g = gate.clone(); + let save: crate::SaveFileFn = Arc::new(move |_, _, _, ctl| { + ctl.progress(0.4567, "Encoding"); + while !g.load(Ordering::Relaxed) { + ctl.check()?; + std::thread::sleep(Duration::from_millis(1)); + } + ctl.commit()?; + ctl.progress(1.0, "Writing"); + Ok(Vec::new()) + }); + h.state_mut().services.save_file = Some(save); + h.state_mut().save_as(Some("/tmp/photocraft-test/pct.psd".into())).unwrap(); + let t = Instant::now(); + while h.query_by_label("Saving 45%").is_none() && t.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.query_by_label("Saving 45%").is_some(), "{theme:?}: the status bar reads Saving 45%"); + assert!(h.query_by_label_contains(" - Saving 45%").is_some(), "{theme:?}: the tab title carries the save's progress"); + gate.store(true, Ordering::Relaxed); + step_until(&mut h, |a| !a.saving()); + assert!(h.query_by_label_contains("Saving").is_none(), "{theme:?}: the readout and the tab suffix are gone once saved"); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("/tmp/photocraft-test/pct.psd")); + } +} + /// One frame with the window's close button pressed; whether the guard cancelled the close. fn press_window_close(app: &mut PhotocraftApp) -> bool { let mut info = egui::ViewportInfo::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e4bb040ab..3729181a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod artboard_ui; mod blend_keys; pub(crate) mod blend_preview; mod brand; +pub(crate) mod brush_cursor; pub mod brush_panel; pub mod brush_picker; pub mod brush_preview; @@ -68,6 +69,7 @@ pub mod discard_ui; pub mod distort_ui; pub mod doc_props_ui; pub mod dock; +pub mod duplicate_layer_ui; pub mod enable_rules; pub mod eraser_ui; pub mod export_dialog; @@ -122,6 +124,7 @@ pub mod move_mods; pub mod move_ui; pub mod native_menu; pub mod new_doc_ui; +pub mod new_guide_ui; pub mod new_layer_ui; pub mod notices; mod numeric_expression; @@ -493,6 +496,8 @@ pub struct PhotocraftApp { pub(crate) magnetic: magnetic_lasso_ui::Runtime, /// The next tool `Down` is a right-button drag that erases (see `paint_mouse`). secondary_erase: bool, + /// The Brush or Pencil erase key is held this frame (`hold_keys::erase_held`, #3439). + held_erase: bool, /// While a batch of recovered pointer samples is replayed, defer the live-stroke update to one /// call for the whole frame (see `canvas::canvas_view`). defer_live_stroke: bool, @@ -560,6 +565,9 @@ pub struct PhotocraftApp { pub fps: f32, last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, + /// History snapshot thumbnails by (document, snapshot id): a snapshot never changes, so each + /// is made once (`snapshot_thumb`, #2966). + snapshot_thumbs: HashMap<(DocId, u64), egui::TextureHandle>, /// Snapshots whose live layer/mask keys were last used to prune thumbnail handles. thumb_documents: Vec<(DocId, std::sync::Weak)>, /// Content bounds cached per (key, revision): scanning a 36 MP layer every frame cost ~77 ms. @@ -706,6 +714,7 @@ impl PhotocraftApp { patch_preview: None, magnetic: Default::default(), secondary_erase: false, + held_erase: false, defer_live_stroke: false, press_stroke: false, last_stroke_end: None, @@ -736,6 +745,7 @@ impl PhotocraftApp { fps: 0.0, last_frame_time: 0.0, thumbs: HashMap::new(), + snapshot_thumbs: HashMap::new(), thumb_documents: Vec::new(), bounds_cache: HashMap::new(), proxy: None, @@ -1537,7 +1547,7 @@ impl eframe::App for PhotocraftApp { self.collect_screenshots(ctx); self.issue_screenshots(ctx); prefs_ui::tick(self, ctx); - monitor_status::poll(self, ctx); + monitor_status::poll_frame(self, ctx, frame); // Control requests and persisted preferences can change the language in this frame. i18n::sync_context(ctx, &self.session.prefs().interface.language); type_tool::sync_font_names(ctx, self.session.prefs().type_.show_font_names_in_english); @@ -1872,6 +1882,29 @@ impl PhotocraftApp { self.store_thumb(ctx, key, rev, img) } + /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, + /// centred in a square. Thumbnails of `doc`'s snapshots not in `live` are dropped. + pub fn snapshot_thumb(&mut self, ctx: &egui::Context, doc: DocId, snap: &photocraft_engine::history_cmds::Snapshot, live: &[u64]) -> egui::TextureId { + self.snapshot_thumbs.retain(|(d, id), _| *d != doc || live.contains(id)); + if let Some(tex) = self.snapshot_thumbs.get(&(doc, snap.id)) { + return tex.id(); + } + let comp = photocraft_compose::thumbnail(&snap.doc, 56); + let (w, h) = (comp.width as usize, comp.height as usize); + let side = w.max(h); + let (ox, oy) = ((side - w) / 2, (side - h) / 2); + let mut px = vec![egui::Color32::TRANSPARENT; side * side]; + for (i, &[r, g, b, a]) in comp.pixels.as_chunks::<4>().0.iter().enumerate() { + if let Some(out) = px.get_mut((i / w + oy) * side + i % w + ox) { + *out = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + } + } + let tex = ctx.load_texture(format!("snapshot-{}", snap.id), egui::ColorImage::new([side, side], px), egui::TextureOptions::LINEAR); + let id = tex.id(); + self.snapshot_thumbs.insert((doc, snap.id), tex); + id + } + pub fn mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: photocraft_doc::LayerId, mask: &photocraft_doc::LayerMask) -> egui::TextureId { let rev = surface_fingerprint(&mask.surface) ^ (doc.size.width as u64) << 40; let key = (id, mask_thumbs_ui::THUMB_MASK); diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index c5545af73..a660a92dd 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -766,7 +766,7 @@ fn m_cycles_only_the_rectangular_and_elliptical_marquee() { let mut seen = Vec::new(); for _ in 0..4 { let raw = egui::RawInput { - events: vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + events: vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }], ..Default::default() }; ctx.begin_pass(raw); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 541586c98..1817cf1c4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -279,6 +279,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } Ok(Value::Null) } + "layer.duplicate" if params.as_object().is_none_or(|o| o.is_empty()) => crate::duplicate_layer_ui::open(app), "file.new" if params.as_object().is_none_or(|o| o.is_empty()) => { let fields = app.new_document_fields(); let d = app.ui.open_dialog(DialogKind::NewDocument, fields); @@ -568,6 +569,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: sz if crate::sizing::is_sizing(sz) && params.as_object().is_none_or(|o| o.is_empty()) => { Ok(json!({"dialog": crate::sizing::open(app, sz).ok_or("no document")?})) } + "view.newGuide" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::new_guide_ui::open(app)})), fl if crate::filter_dialog::has_dialog(fl) && params.as_object().is_none_or(|o| o.is_empty()) => { Ok(json!({"dialog": crate::filter_dialog::open(app, fl)})) } diff --git a/crates/ui-egui/src/monitor_status.rs b/crates/ui-egui/src/monitor_status.rs index 1f0d03a5d..8e29b8c2f 100644 --- a/crates/ui-egui/src/monitor_status.rs +++ b/crates/ui-egui/src/monitor_status.rs @@ -36,6 +36,8 @@ pub const REREAD_SECS: f64 = 30.0; #[derive(Default)] pub struct State { pending: Option>, + #[cfg(target_os = "linux")] + root_output_name: Option, /// Another reading is wanted once the pending one is done or the interval has passed. again: bool, /// A reading is wanted at once (ignores the interval). @@ -64,6 +66,22 @@ pub fn pending(app: &mut PhotocraftApp, rx: Receiver) { /// Per-frame (main window): apply a reading that arrived, track the main window's display, and /// start a new reading when the displays may have changed. pub fn poll(app: &mut PhotocraftApp, ctx: &egui::Context) { + poll_with_output_name(app, ctx, None); +} + +#[cfg(target_os = "linux")] +pub fn poll_frame(app: &mut PhotocraftApp, ctx: &egui::Context, frame: &eframe::Frame) { + let output_name = frame.winit_window().and_then(|window| window.current_monitor()).and_then(|monitor| monitor.name()); + poll_with_output_name(app, ctx, output_name); +} + +#[cfg(not(target_os = "linux"))] +pub fn poll_frame(app: &mut PhotocraftApp, ctx: &egui::Context, _frame: &eframe::Frame) { + poll(app, ctx); +} + +fn poll_with_output_name(app: &mut PhotocraftApp, ctx: &egui::Context, output_name: Option) { + set_root_output_name(app, output_name); if let Some(rx) = &app.monitors.pending { let r = match rx.try_recv() { Ok(r) => Some(r), @@ -228,17 +246,60 @@ fn window_frame(ctx: &egui::Context) -> Option<[f64; 4]> { /// The display showing most of the window `ctx` draws (`None`: unknown, e.g. no displays read /// or no window position, as on Wayland). pub fn view_display(app: &PhotocraftApp, ctx: &egui::Context) -> Option { + display_for_view(app, ctx) +} + +fn display_for_view(app: &PhotocraftApp, ctx: &egui::Context) -> Option { + if let Some(display) = root_output_display(app, ctx) { + return display; + } display_at(&app.session.color.displays, window_frame(ctx)?) } +#[cfg(target_os = "linux")] +fn set_root_output_name(app: &mut PhotocraftApp, name: Option) { + app.monitors.root_output_name = name; +} + +#[cfg(not(target_os = "linux"))] +fn set_root_output_name(_app: &mut PhotocraftApp, _name: Option) {} + +#[cfg(target_os = "linux")] +fn root_output_display(app: &PhotocraftApp, ctx: &egui::Context) -> Option> { + if ctx.viewport_id() != egui::ViewportId::ROOT { + return None; + } + let name = app.monitors.root_output_name.as_deref()?; + Some(app.session.color.displays.iter().find(|display| display.name == name).map(|display| display.id)) +} + +#[cfg(not(target_os = "linux"))] +fn root_output_display(_app: &PhotocraftApp, _ctx: &egui::Context) -> Option> { + None +} + +#[cfg(target_os = "linux")] +fn root_output_name_known(app: &PhotocraftApp, ctx: &egui::Context) -> bool { + ctx.viewport_id() == egui::ViewportId::ROOT && app.monitors.root_output_name.is_some() +} + +#[cfg(not(target_os = "linux"))] +fn root_output_name_known(_app: &PhotocraftApp, _ctx: &egui::Context) -> bool { + false +} + /// The window is on no display we know, or on one whose size differs from what was read. fn displays_changed(app: &PhotocraftApp, ctx: &egui::Context) -> bool { let c = &app.session.color; if c.displays.is_empty() || !matches!(c.monitor_detection, MonitorDetection::Found | MonitorDetection::Retained { .. }) { return false; } - let Some(frame) = window_frame(ctx) else { return false }; - let Some(d) = display_at(&c.displays, frame).and_then(|id| c.displays.iter().find(|d| d.id == id)) else { return true }; + let Some(d) = display_for_view(app, ctx).and_then(|id| c.displays.iter().find(|d| d.id == id)) else { + if root_output_name_known(app, ctx) { + return true; + } + return window_frame(ctx).is_some(); + }; // egui's monitor size is the window's display (winit's rule, AppKit's on macOS). let size = ctx.input(|i| i.viewport().monitor_size); let native = ctx.input(|i| i.viewport().native_pixels_per_point).filter(|n| *n > 0.0); @@ -531,4 +592,20 @@ mod tests { assert!(app.ui.notices.is_empty()); assert!(summary_lines(&app).iter().all(|l| l.contains("(manual)")), "{:?}", summary_lines(&app)); } + + #[cfg(target_os = "linux")] + #[test] + fn root_window_uses_winit_monitor_name_to_select_its_profile() { + let mut app = app(); + app.session.color.set_displays(Ok(vec![ + Display { id: 1, name: "DP-1".into(), frame: [0.0, 0.0, 1920.0, 1080.0], profile_name: None, icc: None }, + Display { id: 2, name: "HDMI-1".into(), frame: [1920.0, 0.0, 1920.0, 1080.0], profile_name: None, icc: None }, + ])); + app.monitors.root_output_name = Some("HDMI-1".into()); + let ctx = egui::Context::default(); + + assert_eq!(view_display(&app, &ctx), Some(2)); + app.monitors.root_output_name = Some("DP-3".into()); + assert_eq!(view_display(&app, &ctx), None); + } } diff --git a/crates/ui-egui/src/new_doc_remember_tests.rs b/crates/ui-egui/src/new_doc_remember_tests.rs index 3c0929333..d5fbcc31b 100644 --- a/crates/ui-egui/src/new_doc_remember_tests.rs +++ b/crates/ui-egui/src/new_doc_remember_tests.rs @@ -76,3 +76,27 @@ fn a_corrupt_remembered_value_is_ignored() { let _ = crate::dialogs::confirm(&mut app, id); assert_eq!(app.session.prefs().dialogs["file.new"]["height"], json!(600), "unchanged"); } + +/// #3131: File › New › Recent lists the documents created with the dialog, newest first, with +/// every setting restored by a click, and survives a restart. +#[test] +fn created_documents_fill_the_recent_category() { + let mut app = app(); + create(&mut app, json!({"width": 640, "height": 480, "resolution": 300.0, "mode": "cmyk", "depth": 16, "background": "transparent", "__unit": "in"})); + create(&mut app, json!({"width": 1920, "height": 1080, "resolution": 72.0, "mode": "rgb", "depth": 8, "background": "white", "__unit": "px"})); + let recent = app.session.presets.recent_documents.clone(); + assert_eq!(recent.iter().map(|r| (r.width, r.unit.as_str())).collect::>(), [(1920, "px"), (640, "in")]); + // The settings are kept with the preferences. + let mut restarted = photocraft_engine::Session::new(); + restarted.load_prefs_json(&app.session.prefs_to_json()).unwrap(); + assert_eq!(restarted.presets.recent_documents, recent); + // A recent card restores all of its settings but keeps the new document's name. + let mut f = app.new_document_fields(); + f.insert("name".into(), json!("Next")); + crate::new_doc_ui::apply_settings(&mut f, &recent[1]); + let p = crate::new_doc_ui::command_params(&f); + assert_eq!( + (&p["width"], &p["height"], &p["mode"], &p["depth"], &p["background"], &p["name"], &f["__unit"]), + (&json!(640), &json!(480), &json!("cmyk"), &json!(16), &json!("transparent"), &json!("Next"), &json!("in")) + ); +} diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e1330f055..9e11decfc 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -10,6 +10,7 @@ use photocraft_engine::prefs::UnitsAndRulers; use crate::theme::Tokens; use crate::{icons, widgets}; +use photocraft_engine::document_preset_cmds::DocumentSettings; /// A blank-document preset: (name, width px, height px, ppi). pub type Preset = (&'static str, u32, u32, f32); @@ -256,19 +257,103 @@ fn page_icon(ui: &egui::Ui, r: Rect, w: u32, h: u32, t: &Tokens) { /// Restore a snapshot without replacing the new document's name or its clipboard offer. pub fn apply_saved_preset(f: &mut Map, preset: &photocraft_engine::document_preset_cmds::DocumentPreset) { + apply_settings(f, &preset.settings); + f.insert("__savedPreset".into(), json!(preset.name)); +} + +/// Restore every setting of a saved or recent document (all but its name). +pub fn apply_settings(f: &mut Map, settings: &DocumentSettings) { f.remove("backgroundColor"); - if let Some(params) = preset.settings.command_params().as_object() { + if let Some(params) = settings.command_params().as_object() { f.extend(params.clone()); } - f.insert("__unit".into(), json!(preset.settings.unit)); - f.insert("__resUnit".into(), json!(preset.settings.resolution_unit)); - f.insert("__savedPreset".into(), json!(preset.name)); + f.insert("__unit".into(), json!(settings.unit)); + f.insert("__resUnit".into(), json!(settings.resolution_unit)); + f.remove("__savedPreset"); f.remove("__preset"); // Typed Width/Height belong to the previous values (`shown_size`). f.remove(&typed_key("width")); f.remove(&typed_key("height")); } +/// The dialog's settings as `document.presets.save` / `document.recent.add` take them: the +/// `file.new` params plus the display units. +pub fn document_settings(f: &Map) -> Value { + let mut settings = command_params(f); + let unit = get_s(f, "__unit", "px"); + settings["unit"] = json!(if UNITS.iter().any(|u| u.0 == unit) { unit.as_str() } else { "px" }); + settings["resolutionUnit"] = json!(if get_s(f, "__resUnit", "in") == "cm" { "cm" } else { "in" }); + settings +} + +/// Whether the dialog currently holds `settings` (so its Recent card shows selected). +fn holds(f: &Map, settings: &DocumentSettings) -> bool { + // Unset fields hold the dialog's defaults, as the details pane shows them. + let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0)); + (w, h, get_f(f, "resolution", 72.0)) == (settings.width as f32, settings.height as f32, settings.resolution) + && f.get("depth").and_then(Value::as_u64).unwrap_or(8) == u64::from(settings.depth) + && get_s(f, "mode", "rgb") == settings.mode + && get_s(f, "background", "white") == settings.background +} + +/// A card in the preset grid: a built-in preset or a recently created document. +enum Card { + Builtin(Preset), + Recent(DocumentSettings), +} + +impl Card { + /// Pixel size and resolution; a built-in preset at the Units & Rulers preset resolutions. + fn size(&self, units: &UnitsAndRulers) -> (u32, u32, f32) { + match self { + Card::Builtin(listed) => { + let p = at_pref_resolutions(listed, units); + (p.1, p.2, p.3) + } + Card::Recent(s) => (s.width, s.height, s.resolution), + } + } + + /// The preset's name; a recent document takes the name of the built-in preset it matches. + fn title(&self) -> &'static str { + match self { + Card::Builtin(p) => p.0, + Card::Recent(s) => { + CATEGORIES.iter().flat_map(|c| c.1.iter()).find(|p| (p.1, p.2, p.3) == (s.width, s.height, s.resolution)).map_or("Custom", |p| p.0) + } + } + } + + /// The size line, in the unit the preset is specified in (a recent one: the unit it was made in). + fn size_line(&self, units: &UnitsAndRulers) -> String { + let (w, h, ppi) = self.size(units); + let unit = match self { + Card::Builtin(p) if is_print(p) => "in", + Card::Builtin(_) => "px", + Card::Recent(s) => s.unit.as_str(), + }; + if unit == "px" { + format!("{w} x {h} px @ {} ppi", widgets::fmt_num(f64::from(ppi))) + } else { + let v = |n: u32| widgets::fmt_num(f64::from(to_unit(n as f32, unit, ppi))); + format!("{} x {} {unit} @ {} ppi", v(w), v(h), widgets::fmt_num(f64::from(ppi))) + } + } +} + +/// The Recent category: the Clipboard preset, the documents recently created here (newest +/// first), then the built-in defaults they don't already cover. +fn recent_cards(f: &Map, recent: &[DocumentSettings], units: &UnitsAndRulers) -> Vec { + let mut cards: Vec = clipboard_preset(f).map(Card::Builtin).into_iter().collect(); + cards.extend(recent.iter().cloned().map(Card::Recent)); + let defaults = CATEGORIES[0].1.iter().filter(|p| { + let (_, w, h, ppi) = at_pref_resolutions(p, units); + !recent.iter().any(|s| (s.width, s.height, s.resolution) == (w, h, ppi)) + }); + cards.extend(defaults.copied().map(Card::Builtin)); + cards +} + fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> bool { let presets = app.session.presets.documents.clone(); if presets.is_empty() { @@ -326,10 +411,7 @@ fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { if let Some(preset) = app.session.presets.documents.last() { apply_saved_preset(f, preset); @@ -380,11 +462,12 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = clipboard_preset(f).filter(|_| cat == CATEGORIES[0].0).into_iter().chain(presets.iter().copied()).collect(); - let chosen = get_s(f, "__preset", ""); let units = app.session.prefs().units_and_rulers.clone(); + let presets: Vec = match CATEGORIES.iter().find(|c| c.0 == cat) { + Some(c) if c.0 != CATEGORIES[0].0 => c.1.iter().copied().map(Card::Builtin).collect(), + _ => recent_cards(f, &app.session.presets.recent_documents, &units), + }; + let chosen = get_s(f, "__preset", ""); ui.horizontal_top(|ui| { // Left: preset grid. ui.vertical(|ui| { @@ -396,51 +479,65 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map chosen == p.0, + Card::Recent(settings) => holds(f, settings), + }; + ui.painter().rect_filled( + r, + t.radius, + if on { + t.row_selected + } else if resp.hovered() { + t.hover + } else { + t.field + }, + ); if on { - t.row_selected - } else if resp.hovered() { - t.hover - } else { - t.field - }, - ); - if on { - ui.painter().rect_stroke(r, t.radius, Stroke::new(1.5, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(r, t.radius, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + page_icon(ui, Rect::from_center_size(pos2(r.center().x, r.top() + 34.0), vec2(40.0, 40.0)), w, h, &t); + let title = card_title(ui.painter(), tl!(c.title()), card.x - 12.0, t.text); + let elided = title.elided; + ui.painter().galley(pos2(r.center().x, r.top() + 70.0 - title.size().y / 2.0), title, t.text); + let size = c.size_line(&units); + ui.painter().text(pos2(r.center().x, r.top() + 97.0), Align2::CENTER_CENTER, &size, egui::FontId::proportional(10.5), t.text_faint); + let resp = match c { + // A recent document's tooltip names everything it restores. + Card::Recent(s) => { + let background = match s.background.as_str() { + "white" => tl!("White"), + "black" => tl!("Black"), + "transparent" => tl!("Transparent"), + "backgroundColor" => tl!("Background Color"), + hex => hex, + }; + resp.on_hover_text(format!("{} · {size} · {}/{} · {background}", tl!(c.title()), s.mode.to_uppercase(), s.depth)) + } + Card::Builtin(_) if elided => resp.on_hover_text(tl!(c.title())), + Card::Builtin(_) => resp, + }; + if resp.clicked() { + match c { + Card::Builtin(p) => apply_preset(f, p, &units), + Card::Recent(settings) => apply_settings(f, settings), + } + create |= resp.double_clicked() || resp.triple_clicked(); + } } - page_icon(ui, Rect::from_center_size(pos2(r.center().x, r.top() + 34.0), vec2(40.0, 40.0)), p.1, p.2, &t); - let title = card_title(ui.painter(), tl!(p.0), card.x - 12.0, t.text); - let elided = title.elided; - ui.painter().galley(pos2(r.center().x, r.top() + 70.0 - title.size().y / 2.0), title, t.text); - let size = if is_print(listed) { - format!( - "{} x {} in @ {} ppi", - widgets::fmt_num(to_unit(p.1 as f32, "in", p.3) as f64), - widgets::fmt_num(to_unit(p.2 as f32, "in", p.3) as f64), - p.3 - ) - } else { - format!("{} x {} px @ {} ppi", p.1, p.2, p.3) - }; - ui.painter().text(pos2(r.center().x, r.top() + 97.0), Align2::CENTER_CENTER, size, egui::FontId::proportional(10.5), t.text_faint); - let resp = if elided { resp.on_hover_text(tl!(p.0)) } else { resp }; - if resp.clicked() { - apply_preset(f, listed, &units); - create |= resp.double_clicked() || resp.triple_clicked(); - } - } - }); - ui.add_space(8.0); - } + }); + ui.add_space(8.0); + } + }); }); ui.add_space(10.0); // Right: Preset Details. @@ -681,6 +778,35 @@ mod tests { } } + #[test] + fn recent_category_lists_clipboard_then_created_documents_then_uncovered_defaults() { + let settings = |w, h, ppi| DocumentSettings { + width: w, + height: h, + resolution: ppi, + mode: "rgb".into(), + depth: 8, + background: "white".into(), + background_color: None, + unit: "px".into(), + resolution_unit: "in".into(), + }; + let mut f = Map::new(); + set_clipboard(&mut f, 20, 30); + let cards = recent_cards(&f, &[settings(800, 600, 72.0), settings(1920, 1080, 72.0)], &UnitsAndRulers::default()); + let titles: Vec<(&str, String)> = cards.iter().map(|c| (c.title(), c.size_line(&UnitsAndRulers::default()))).collect(); + assert_eq!( + titles, + [ + (CLIPBOARD, "20 x 30 px @ 72 ppi".to_string()), + ("Custom", "800 x 600 px @ 72 ppi".to_string()), + ("HDTV 1080p", "1920 x 1080 px @ 72 ppi".to_string()), + ("Default PhotoCraft Size", "7 x 5 in @ 300 ppi".to_string()), + ] + ); + assert!(holds(&f, &settings(20, 30, 72.0)) && !holds(&f, &settings(800, 600, 72.0))); + } + #[test] fn clipboard_preset_comes_first_and_is_selected() { // Nothing on the clipboard: the dialog opens as before. diff --git a/crates/ui-egui/src/new_guide_ui.rs b/crates/ui-egui/src/new_guide_ui.rs new file mode 100644 index 000000000..8b8a20928 --- /dev/null +++ b/crates/ui-egui/src/new_guide_ui.rs @@ -0,0 +1,186 @@ +//! View › Guides › New Guide…: an orientation, a position with a unit and a colour, as in +//! Photoshop. +//! +//! The position opens in the ruler unit ("0 cm") and takes any unit Photoshop's field takes: +//! "50%", "2.5 cm", "1in", "12 pt", "3 pc", "100 px". A bare number is in the ruler unit, as in +//! Photoshop. OK runs `view.newGuide` with the text, so the engine converts it with the document's +//! resolution. A position the engine refuses reopens the dialog with what was typed and the +//! engine's message, as Photoshop keeps New Guide open on a bad position. The +//! dialog is an ordinary one in the UI state, so `ui.inspect`, `ui.dialog.confirm` and +//! `ui.dialog.cancel` drive it. The last successful guide's orientation and colour open next +//! time, with its position shown in the current ruler unit, as Photoshop does. +//! +//! Color lists Photoshop's guide colours (Cyan by default) and Custom…, whose swatch opens the +//! Color Picker. The guide stores the chosen colour itself, so changing the preference guide +//! colour later doesn't recolour it. + +use photocraft_engine::edit_cmds::GUIDE_COLORS; +use photocraft_engine::prefs::Unit; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::state::DialogKind; + +const MARK: &str = "__newGuide"; +const COMMAND: &str = "view.newGuide"; +const CUSTOM: &str = "custom"; +/// New Guide's colour when nothing was remembered, as in Photoshop. +const DEFAULT_COLOR: &str = "cyan"; + +/// A colour's label in the list (the catalogue holds each one). +pub(crate) fn color_label(name: &str) -> &'static str { + match name { + "lightBlue" => tl!("Light Blue"), + "lightRed" => tl!("Light Red"), + "green" => tl!("Green"), + "mediumBlue" => tl!("Medium Blue"), + "yellow" => tl!("Yellow"), + "magenta" => tl!("Magenta"), + "cyan" => tl!("Cyan"), + "lightGray" => tl!("Light Gray"), + "black" => tl!("Black"), + _ => tl!("Custom…"), + } +} + +fn is_color_name(name: &str) -> bool { + name == CUSTOM || GUIDE_COLORS.iter().any(|(n, ..)| *n == name) +} + +/// The Custom… colour as "#rrggbb", starting from Cyan's. +fn custom_color(f: &Map) -> String { + f.get("customColor") + .and_then(Value::as_str) + .filter(|h| photocraft_engine::prefs::parse_hex(h).is_some()) + .map_or_else(|| "#4affff".to_string(), str::to_string) +} + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +fn ruler_unit(app: &PhotocraftApp) -> Unit { + app.session.prefs().units_and_rulers.rulers +} + +/// "0 cm", "0 px", "0 %": a value in `unit` as the field shows it, with a space before the unit +/// as in Photoshop. +fn with_unit(value: &str, unit: Unit) -> String { + format!("{value} {}", unit.suffix()) +} + +/// `px` (document pixels) in the ruler unit, as the field shows it: "14.11 cm". Percent is of the +/// active document's width for a vertical guide and its height for a horizontal one. +fn in_ruler_unit(app: &PhotocraftApp, px: f64, vertical: bool) -> String { + let units = &app.session.prefs().units_and_rulers; + let (dpi, extent) = app.session.active().map_or((72.0, 1.0), |st| { + let d = &st.doc; + (f64::from(d.resolution_dpi), f64::from(if vertical { d.size.width } else { d.size.height })) + }); + with_unit(&units.format(px, dpi, extent), units.rulers) +} + +/// Open the dialog with the last successful guide's orientation, position (in the current ruler +/// unit) and colour, else horizontal at 0. +pub fn open(app: &mut PhotocraftApp) -> u64 { + let saved = app.session.prefs().dialogs.get(COMMAND).and_then(Value::as_object).cloned().unwrap_or_default(); + let orientation = saved.get("orientation").and_then(Value::as_str).filter(|o| matches!(*o, "horizontal" | "vertical")).unwrap_or("horizontal"); + let px = saved.get("positionPx").and_then(Value::as_f64).filter(|p| p.is_finite()).unwrap_or(0.0); + let position = in_ruler_unit(app, px, orientation == "vertical"); + let color = saved.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR).to_string(); + let custom = custom_color(&saved); + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("New Guide…")); + f.insert("orientation".into(), json!(orientation)); + f.insert("position".into(), json!(position)); + f.insert("color".into(), json!(color)); + f.insert("customColor".into(), json!(custom)); + app.ui.open_dialog(DialogKind::Command, f) +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string(); + let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string(); + let mut color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR).to_string(); + if let Some(error) = f.get("__error").and_then(Value::as_str) { + ui.colored_label(ui.visuals().error_fg_color, error); + ui.add_space(4.0); + } + egui::Grid::new("new-guide-dialog").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Orientation")); + ui.horizontal(|ui| { + ui.radio_value(&mut orientation, "horizontal".to_string(), tl!("Horizontal")); + ui.radio_value(&mut orientation, "vertical".to_string(), tl!("Vertical")); + }); + ui.end_row(); + ui.label(tl!("Position")); + ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)); + ui.end_row(); + ui.label(tl!("Color")); + ui.horizontal(|ui| { + // Custom… first, then Photoshop's colours. + let options: Vec<(String, &str)> = + std::iter::once(CUSTOM).chain(GUIDE_COLORS.iter().map(|(n, ..)| *n)).map(|n| (n.to_string(), color_label(n))).collect(); + crate::widgets::dropdown(ui, "new-guide-color", &mut color, &options, 140.0); + if color == CUSTOM { + let hex = custom_color(f); + let rgb = photocraft_engine::prefs::parse_hex(&hex).unwrap_or([0x4a, 0xff, 0xff]); + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), tl!("Custom…")).clicked() { + f.insert("customColor".into(), json!(hex)); + crate::color_picker_ui::request_field(f, "customColor", &crate::color_picker_ui::title_for(tl!("Guides"))); + } + ui.label(egui::RichText::new(hex).monospace()); + } + }); + ui.end_row(); + }); + f.insert("orientation".into(), json!(orientation)); + f.insert("position".into(), json!(position)); + f.insert("color".into(), json!(color)); +} + +/// The command's parameters: a bare number gets the ruler unit; anything else goes as typed, for +/// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb". +pub fn params(app: &PhotocraftApp, f: &Map) -> Value { + let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal"); + let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim(); + let position = match Unit::parse_length(typed) { + Some((_, None)) => with_unit(typed, ruler_unit(app)), + _ => typed.to_string(), + }; + let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); + let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; + json!({"orientation": orientation, "position": position, "color": color}) +} + +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let params = params(app, f); + match app.run(COMMAND, params.clone()) { + Ok(result) => { + // Remember where the guide landed (so it reopens in whatever the ruler unit is then), + // the list's choice (not the colour it sent) and the last Custom… colour. + let vertical = params["orientation"] == "vertical"; + let landed = app.session.active().and_then(|st| if vertical { st.doc.guides.vertical.last() } else { st.doc.guides.horizontal.last() }.copied()); + let remembered = json!({ + "orientation": params["orientation"], + "positionPx": landed.unwrap_or(0.0), + "color": f.get("color").cloned().unwrap_or(json!(DEFAULT_COLOR)), + "customColor": custom_color(f), + }); + app.session.prefs.edit(|prefs| prefs.dialogs.insert(COMMAND.into(), remembered)); + Ok(result) + } + Err(e) => { + // `dialogs::confirm` has already closed it: open it again with the typed values. + let mut again = f.clone(); + again.insert("__error".into(), json!(e)); + app.ui.open_dialog(DialogKind::Command, again); + Err(e) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/new_guide_ui/tests.rs b/crates/ui-egui/src/new_guide_ui/tests.rs new file mode 100644 index 000000000..4bf4fa80d --- /dev/null +++ b/crates/ui-egui/src/new_guide_ui/tests.rs @@ -0,0 +1,144 @@ +use serde_json::json; + +use super::*; +use crate::menus::invoke as menu; + +/// One 4 × 2 inch document at 100 ppi, with the rulers in `unit`. +fn app(unit: &str) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": unit})).unwrap(); + app.sync_views(); + (app, egui::Context::default()) +} + +fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> u64 { + menu(app, ctx, "view.newGuide", json!({})).unwrap()["dialog"].as_u64().unwrap() +} + +fn set(app: &mut PhotocraftApp, d: u64, orientation: &str, position: &str) { + let f = &mut app.ui.dialog_mut(d).unwrap().fields; + f.insert("orientation".into(), json!(orientation)); + f.insert("position".into(), json!(position)); +} + +fn guides(app: &PhotocraftApp) -> (Vec, Vec) { + let g = &app.session.active().unwrap().doc.guides; + (g.horizontal.clone(), g.vertical.clone()) +} + +#[test] +fn opens_in_the_ruler_unit_and_takes_photoshop_units() { + let (mut app, ctx) = app("cm"); + let d = open(&mut app, &ctx); + let f = &app.ui.dialog_mut(d).unwrap().fields; + assert!(owns(f), "New Guide has its own dialog, not the generic one"); + assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("horizontal"), Some("0 cm"))); + // Percent is of the width for a vertical guide. + set(&mut app, d, "vertical", "50%"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(guides(&app), (vec![], vec![200.0])); + // The last guide opens next time, in the ruler unit (200 px at 100 ppi is 5.08 cm); a bare + // number is in the ruler unit, as in Photoshop. + let d = open(&mut app, &ctx); + let f = &app.ui.dialog_mut(d).unwrap().fields; + assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("vertical"), Some("5.08 cm"))); + set(&mut app, d, "horizontal", "2.54"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(guides(&app), (vec![100.0], vec![200.0])); + // Any unit overrides the ruler unit. + let d = open(&mut app, &ctx); + set(&mut app, d, "horizontal", "36 pt"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(guides(&app).0, vec![100.0, 50.0]); +} + +#[test] +fn percent_rulers_open_at_zero_percent() { + let (mut app, ctx) = app("percent"); + let d = open(&mut app, &ctx); + assert_eq!(app.ui.dialog_mut(d).unwrap().fields["position"], "0 %"); + set(&mut app, d, "horizontal", "25"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(guides(&app).0, vec![50.0], "25% of the 200 px height"); +} + +#[test] +fn a_refused_position_reopens_the_dialog_without_a_guide() { + let (mut app, ctx) = app("pixels"); + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", "12 furlongs"); + let err = crate::dialogs::confirm(&mut app, d).unwrap_err(); + assert!(err.contains("not a length"), "{err}"); + assert_eq!(guides(&app), (vec![], vec![])); + let again = app.ui.dialogs.iter().find(|d| owns(&d.fields)).expect("the dialog is open again"); + assert_eq!(again.fields["position"], "12 furlongs", "what was typed is kept"); + assert_eq!(again.fields["orientation"], "vertical"); + assert_eq!(again.fields["__error"].as_str(), Some(err.as_str())); + // A refused position is not remembered. + assert!(!app.session.prefs().dialogs.contains_key("view.newGuide")); +} + +#[test] +fn remembered_values_that_no_longer_parse_are_ignored() { + let (mut app, ctx) = app("mm"); + app.session.prefs.edit(|p| p.dialogs.insert("view.newGuide".into(), json!({"orientation": "diagonal", "positionPx": "far away"}))); + let d = open(&mut app, &ctx); + let f = &app.ui.dialog_mut(d).unwrap().fields; + assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("horizontal"), Some("0 mm"))); +} + +fn color_of(app: &PhotocraftApp, vertical: bool, position: f64) -> Option<[u8; 3]> { + app.session.active().unwrap().doc.guides.color(vertical, position) +} + +#[test] +fn guides_store_the_chosen_colour_cyan_by_default() { + let (mut app, ctx) = app("pixels"); + let d = open(&mut app, &ctx); + assert_eq!(app.ui.dialog_mut(d).unwrap().fields["color"], "cyan"); + set(&mut app, d, "vertical", "10"); + crate::dialogs::confirm(&mut app, d).unwrap(); + // Stored on the guide itself, so a later change of the preference colour leaves it alone. + assert_eq!(color_of(&app, true, 10.0), Some([0x4a, 0xff, 0xff])); + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", "20"); + app.ui.dialog_mut(d).unwrap().fields.insert("color".into(), json!("magenta")); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(color_of(&app, true, 20.0), Some([0xff, 0x00, 0xff])); + // Custom… sends the picked colour; the list's choice and the custom colour open next time. + let d = open(&mut app, &ctx); + assert_eq!(app.ui.dialog_mut(d).unwrap().fields["color"], "magenta"); + set(&mut app, d, "horizontal", "30"); + let f = &mut app.ui.dialog_mut(d).unwrap().fields; + f.insert("color".into(), json!("custom")); + f.insert("customColor".into(), json!("#abcdef")); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(color_of(&app, false, 30.0), Some([0xab, 0xcd, 0xef])); + let d = open(&mut app, &ctx); + let f = &app.ui.dialog_mut(d).unwrap().fields; + assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("custom"), Some("#abcdef"))); +} + +#[test] +fn remembered_colours_that_are_not_on_the_list_are_ignored() { + let (mut app, ctx) = app("pixels"); + app.session.prefs.edit(|p| p.dialogs.insert("view.newGuide".into(), json!({"color": "puce", "customColor": "#zzzzzz"}))); + let d = open(&mut app, &ctx); + let f = &app.ui.dialog_mut(d).unwrap().fields; + assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("cyan"), Some("#4affff"))); +} + +#[test] +fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() { + let (mut app, ctx) = app("pixels"); + let d = open(&mut app, &ctx); + set(&mut app, d, "horizontal", "1in"); + crate::dialogs::confirm(&mut app, d).unwrap(); + for (unit, shown) in [("pixels", "100 px"), ("inches", "1 in"), ("mm", "25.4 mm"), ("points", "72 pt"), ("picas", "6 pica"), ("percent", "50 %")] { + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": unit})).unwrap(); + let d = open(&mut app, &ctx); + assert_eq!(app.ui.dialog_mut(d).unwrap().fields["position"], shown, "{unit}"); + crate::dialogs::cancel(&mut app, d).unwrap(); + } +} diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index bc78df9e0..bb2d6653d 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -65,6 +65,17 @@ fn first_smoothing() -> photocraft_engine::paint::brush::Smoothing { photocraft_engine::paint::brush::Smoothing { amount: 0.1, ..Default::default() } } +/// Does a stroke with `tool` starting now paint in Photoshop's Clear mode, erasing with its own +/// brush (#3439)? Clear chosen in its Mode dropdown, or `~` held (`hold_keys::erase_held`). +pub fn clears(app: &PhotocraftApp, tool: Tool) -> bool { + let chosen = match tool { + Tool::Brush => app.ui.tool_options.brush_clear, + Tool::Pencil => app.ui.tool_options.pencil_clear, + _ => false, + }; + crate::hold_keys::erases_while_held(tool) && (chosen || app.held_erase) +} + /// Which tool events this frame's canvas response produces. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Buttons { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6a87a8317..e30047aa6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -942,11 +942,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); } else { opt_label(ui, tl!("Mode")); - let mut mode = b.mode; - let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { - b.mode = mode; - } + let held = app.held_erase; + paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held); } ui.add_enabled_ui(!block, |ui| { percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); @@ -981,11 +978,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); } opt_label(ui, tl!("Mode")); - let mut mode = b.mode; - let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - if widgets::dropdown(ui, "pencil-mode", &mut mode, &opts, 96.0) { - b.mode = mode; - } + let held = app.held_erase; + paint_mode_dropdown(ui, "pencil-mode", b, &mut app.ui.tool_options.pencil_clear, held); percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, if t.pro { 62.0 } else { 66.0 }); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 }); @@ -2880,12 +2874,16 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(thumb.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &doc.name, egui::FontId::proportional(12.0), t.text); ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); } + let mut snap_cmd = None; let mut target: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); // The dock gives History a fixed height: the rows scroll above the footer. let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; let max_h = (ui.available_height() - footer).max(40.0); egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| { + // Snapshots head the list and scroll with the states, so many of them never push the + // footer out of a short dock. + snap_cmd = snapshot_rows(app, ui); for (i, (e, is_redo)) in all.enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); if i == current { @@ -2923,8 +2921,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash", 26.0, false, tl!("Delete current state"))).inner.clicked() { footer_cmd = Some("history.deleteState"); } - // Snapshots are not implemented yet: shown greyed, as Photoshop's footer has the button. - ui.add_enabled_ui(false, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))); + let can_snap = app.session.is_enabled("history.newSnapshot"); + if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() { + footer_cmd = Some("history.newSnapshot"); + } if ui.add_enabled_ui(can_new, |ui| icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state"))).inner.clicked() { footer_cmd = Some("history.newDocument"); } @@ -2932,8 +2932,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under // its box would leave it transforming pixels that changed. - if let Some(cmd) = footer_cmd.filter(|_| app.ui.transform.is_none()) - && let Err(e) = app.run(cmd, json!({})) + // Selecting a snapshot changes the document, so it waits for an open transform like the rows. + let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); + if let Some((cmd, p)) = footer_cmd.map(|c| (c, json!({}))).filter(|_| app.ui.transform.is_none()).or(snap_cmd) + && let Err(e) = app.run(cmd, p) { app.ui.status = e; app.ui.status_error = true; @@ -2948,6 +2950,79 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and +/// name: a click returns to the snapshot (a new state), a double-click or right-click › Rename… +/// edits the name in place (Enter or a click elsewhere keeps it, Escape cancels), and +/// right-click › Delete removes it. Returns the command a row asks for. +fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static str, Value)> { + let t = Tokens::get(ui.ctx()); + let st = app.session.active()?; + let (doc_id, snaps) = (st.doc.id, st.snapshots.clone()); + let live: Vec = snaps.iter().map(|s| s.id).collect(); + // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. + let rename_id = egui::Id::new(("history-snapshot-rename", doc_id)); + let mut renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)).flatten(); + let mut cmd = None; + let ctx = ui.ctx().clone(); + for snap in &snaps { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &snap.name)); + if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); + } + let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0)); + widgets::checker(ui.painter(), thumb, 4.0); + let tex = app.snapshot_thumb(&ctx, doc_id, snap, &live); + ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside); + match renaming.as_mut().filter(|(id, _)| *id == snap.id) { + Some((_, text)) => { + let field = Rect::from_min_max(pos2(thumb.right() + 6.0, rect.center().y - 10.0), pos2(rect.right() - 6.0, rect.center().y + 10.0)); + let r = ui.put(field, egui::TextEdit::singleline(text)); + if r.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cmd = Some(("history.renameSnapshot", json!({"snapshot": snap.id, "name": text.clone()}))); + } + renaming = None; + } else if !r.has_focus() { + // Starting to rename: focus the field with the whole name selected, so typing + // replaces it (Photoshop). + r.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), r.id); + } + } + } + None => { + ui.painter().text(pos2(thumb.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &snap.name, egui::FontId::proportional(12.0), t.text); + } + } + if resp.double_clicked() { + renaming = Some((snap.id, snap.name.clone())); + } else if resp.clicked() { + cmd = Some(("history.selectSnapshot", json!({"snapshot": snap.id}))); + } + resp.context_menu(|ui| { + if ui.button(tl!("Rename…")).clicked() { + renaming = Some((snap.id, snap.name.clone())); + ui.close(); + } + if ui.button(tl!("Delete")).clicked() { + cmd = Some(("history.deleteSnapshot", json!({"snapshot": snap.id}))); + ui.close(); + } + }); + } + if !snaps.is_empty() { + let y = ui.cursor().top(); + ui.painter().hline(ui.max_rect().x_range(), y, Stroke::new(1.0, t.separator)); + } + ui.data_mut(|d| d.insert_temp(rename_id, renaming)); + cmd +} + // ----------------------------------------------------------------------------- properties /// Floating Properties card anchored to the canvas' top-right corner. @@ -3464,6 +3539,22 @@ fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: C } } +/// The Brush's or Pencil's Mode dropdown: the blend modes, with Photoshop's painting-only Clear +/// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while +/// `~` is held (`held`). +fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) { + let mut opts: Vec<(Option, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); + let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); + opts.insert(at, (None, "Clear")); + let mut mode = if *clear || held { None } else { Some(b.mode) }; + if widgets::dropdown(ui, id, &mut mode, &opts, 96.0) { + *clear = mode.is_none(); + if let Some(m) = mode { + b.mode = m; + } + } +} + /// Options-bar Smoothing % (the brush's stroke smoothing; the live stroke and the commit use it). fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) { let mut sm = (b.smoothing.amount * 100.0).round(); @@ -4264,8 +4355,8 @@ mod history_transform_tests { } } - /// The Photoshop-theme footer (#1117): trash deletes the current state, file-plus makes a - /// new document from it, and the snapshot button is greyed (snapshots aren't implemented). + /// The Photoshop-theme footer (#1117): trash deletes the current state, the camera takes a + /// snapshot (#2966), and file-plus makes a new document from the current state. #[test] fn history_footer_buttons_run_their_commands() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -4286,7 +4377,12 @@ mod history_transform_tests { }; let b = buttons(&h); assert_eq!(b.len(), 3, "trash, snapshot, new document: {b:?}"); - assert!(b[0].1 && !b[1].1 && b[2].1, "only the snapshot button is disabled: {b:?}"); + assert!(b.iter().all(|b| b.1), "all enabled: {b:?}"); + let steps = h.state().session.active().unwrap().history.entries().len(); + click(&mut h, b[1].0.center()); + let st = h.state().session.active().unwrap(); + assert_eq!(st.snapshots.len(), 1, "the camera takes a snapshot"); + assert_eq!(st.history.entries().len(), steps, "which is not a history state"); let docs = h.state().session.documents().len(); click(&mut h, b[2].0.center()); assert_eq!(h.state().session.documents().len(), docs + 1, "file-plus makes a new document"); @@ -4301,6 +4397,123 @@ mod history_transform_tests { } } +#[cfg(test)] +mod snapshot_row_tests { + use egui::vec2; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use serde_json::json; + + use super::*; + + fn top_pixel(h: &Harness<'static, PhotocraftApp>) -> [f32; 4] { + h.state().session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().rgba(1, 1) + } + + fn snapshot_names(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().snapshots.iter().map(|s| s.name.clone()).collect() + } + + /// Snapshot rows (#2966) sit under the opening row: clicking one returns to it as a new + /// state, and its right-click menu renames or deletes it. + #[test] + fn snapshot_rows_select_rename_and_delete() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = + Harness::builder().with_size(vec2(300.0, 400.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(3); + let steps = h.state().session.active().unwrap().history.entries().len(); + h.get_by_label("Snapshot 1").click(); + h.run_steps(2); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.entries().len(), steps + 1); + assert_eq!(st.history.entries().last().map(String::as_str), Some("Snapshot 1")); + assert_eq!(top_pixel(&h), [1.0, 0.0, 0.0, 1.0], "back to red"); + // Right-click › Rename…: type a name, Enter. + h.get_by_label("Snapshot 1").click_secondary(); + h.run_steps(2); + h.get_by_label("Rename…").click(); + h.run_steps(2); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("Lesson start"); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(snapshot_names(&h), ["Lesson start"]); + // A double-click renames too; Escape keeps the old name. + let steps_now = h.state().session.active().unwrap().history.entries().len(); + // A real double-click (accessibility clicks are single clicks). + let p = h.get_by_label("Lesson start").rect().center(); + let p = egui::pos2(p.x as f32, p.y as f32); + h.event(egui::Event::PointerMoved(p)); + // Wait out the double-click window of the earlier clicks. + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("Oops"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(snapshot_names(&h), ["Lesson start"], "Escape cancels"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps_now + 1, "the click selected it once"); + // Right-click › Delete. + h.get_by_label("Lesson start").click_secondary(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(snapshot_names(&h).is_empty()); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 2, "deleting leaves the history alone"); + } + + /// Snapshot rows scroll with the states: many snapshots in a short dock leave the footer and + /// its camera button in view. + #[test] + fn many_snapshots_keep_the_footer_in_a_short_panel() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + for _ in 0..6 { + app.run("history.newSnapshot", json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(300.0, 250.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium; + h.run_steps(3); + let view = h.ctx.input(|i| i.viewport_rect()); + let buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + assert_eq!(buttons.len(), 3, "{buttons:?}"); + assert!(buttons.iter().all(|b| view.contains_rect(*b)), "footer in view: {buttons:?} in {view:?}"); + } + + /// Renaming belongs to one document: snapshot ids restart in each, so switching tabs must not + /// carry the edit over to the other document's snapshot with the same id. + #[test] + fn a_rename_in_progress_stays_with_its_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for _ in 0..2 { + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("history.newSnapshot", json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(3); + h.get_by_label("Snapshot 1").click_secondary(); + h.run_steps(2); + h.get_by_label("Rename…").click(); + h.run_steps(2); + assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "renaming in document 2"); + h.state_mut().session.set_active(0); + h.run_steps(2); + assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_none(), "not in document 1"); + } +} + #[cfg(test)] #[path = "layer_pct_slider_tests.rs"] mod layer_pct_slider_tests; diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 55042703e..89dcd49c5 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -4,6 +4,7 @@ use egui::{Event, Key, Modifiers, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; use crate::PhotocraftApp; @@ -121,3 +122,108 @@ fn without_a_path_it_says_why_and_the_key_can_be_rebound() { press(&mut h, Key::Y, m); assert_eq!(selection(&h), Some([10, 10, 20, 20]), "{}", h.state().ui.status); } + +fn panel(h: &mut Harness<'_, PhotocraftApp>, tab: usize) { + let ctx = h.ctx.clone(); + let (req, _) = crate::control::ControlRequest::new( + "ui.set", + json!({"theme":"promedium", "dockTabs":{"layers":tab}, "dock":{"collapsed":["color","properties","history","navigator"]}}), + ); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(4); +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2, modifiers: Modifiers) { + h.hover_at(at); + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(1); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn thumbnail_selection_ctrl_click_shape_keeps_active_layer_and_supports_undo() { + let mut h = harness(); + let shape = + h.state_mut().run("shape.create", json!({"kind":"rect","rect":[40,20,60,40],"fill":"#cc3333","name":"Badge"})).unwrap()["layer"].as_u64().unwrap(); + let active = h.state_mut().run("layer.new.layer", json!({"name":"Other"})).unwrap()["layer"].as_u64().unwrap(); + panel(&mut h, 0); + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == shape).unwrap(); + click(&mut h, row.name.unwrap().center(), command()); + assert!(selection(&h).is_none(), "Ctrl-click on a shape's name only selects the layer"); + h.state_mut().run("layer.select", json!({"layer": active})).unwrap(); + h.run_steps(2); + click(&mut h, egui::pos2(row.row.left() + 46.0, row.row.center().y), command()); + assert_eq!(selection(&h), Some([40, 20, 60, 40]), "{}", h.state().ui.status); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(active))); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(selection(&h).is_none()); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(selection(&h), Some([40, 20, 60, 40])); +} + +#[test] +fn thumbnail_selection_ctrl_click_paths_targets_thumbnail_and_clicked_path() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name":"work","path":rect_path(10.0,10.0,50.0,40.0)})).unwrap(); + h.state_mut().run("path.set", json!({"name":"Saved","path":rect_path(100.0,40.0,60.0,50.0)})).unwrap(); + h.state_mut().ui.selected_path = Some("Saved".into()); + panel(&mut h, 2); + let row = h.get_by_label("Work Path").rect(); + click(&mut h, egui::pos2(row.left() + 70.0, row.center().y), command()); + assert!(selection(&h).is_none(), "Ctrl-click on the name only selects the path row"); + h.state_mut().ui.selected_path = Some("Saved".into()); + h.run_steps(2); + click(&mut h, egui::pos2(row.left() + 22.0, row.center().y), command()); + assert_eq!(selection(&h), Some([10, 10, 50, 40]), "{}", h.state().ui.status); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("Saved"), "loading a thumbnail does not change the selected path"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(selection(&h).is_none()); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(selection(&h), Some([10, 10, 50, 40])); + h.state_mut().run("select.deselect", json!({})).unwrap(); + let saved = h.get_by_label("Saved").rect(); + click(&mut h, egui::pos2(saved.left() + 22.0, saved.center().y), command()); + assert_eq!(selection(&h), Some([100, 40, 60, 50])); +} + +#[test] +fn thumbnail_selection_ctrl_click_paths_combines_with_shift_and_alt() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name":"work","path":rect_path(10.0,10.0,50.0,40.0)})).unwrap(); + panel(&mut h, 2); + let row = h.get_by_label("Work Path").rect(); + let at = egui::pos2(row.left() + 22.0, row.center().y); + h.state_mut().run("select.rect", json!({"x":100,"y":40,"width":20,"height":20})).unwrap(); + click(&mut h, at, command() | Modifiers::SHIFT); + let selected = h.state().session.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(selected.sample_channel(20, 20, 0) > 0.99 && selected.sample_channel(110, 50, 0) > 0.99); + h.state_mut().run("select.all", json!({})).unwrap(); + click(&mut h, at, command() | Modifiers::ALT); + let selected = h.state().session.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(selected.sample_channel(20, 20, 0) < 0.01 && selected.sample_channel(110, 50, 0) > 0.99); + h.state_mut().run("select.rect", json!({"x":30,"y":0,"width":100,"height":100})).unwrap(); + click(&mut h, at, command() | Modifiers::SHIFT | Modifiers::ALT); + assert_eq!(selection(&h), Some([30, 10, 30, 40])); +} + +#[test] +fn thumbnail_selection_ctrl_click_layer_path_preserves_panel_target() { + let mut h = harness(); + h.state_mut().run("shape.create", json!({"kind":"rect","rect":[40,20,60,40],"fill":"#cc3333","name":"Badge"})).unwrap(); + h.state_mut().run("path.set", json!({"name":"work","path":rect_path(0.0,0.0,10.0,10.0)})).unwrap(); + h.state_mut().ui.selected_path = Some("work".into()); + panel(&mut h, 2); + let row = h.get_by_label("Badge Shape Path").rect(); + click(&mut h, egui::pos2(row.left() + 22.0, row.center().y), command()); + assert_eq!(selection(&h), Some([40, 20, 60, 40])); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("work")); + assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target); + if let Ok(path) = std::env::var("PHOTOCRAFT_THUMBNAIL_SELECTION_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } +} diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index 2b6f461b5..d5557c529 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -223,6 +223,7 @@ fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { app }); h.state_mut().ui.tool = Tool::Move; + h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); h.run_steps(4); let press = |h: &mut Harness<'_, PhotocraftApp>, m: Modifiers| { h.event(egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: m }); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index fd69c38e5..58bcf04cf 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -159,17 +159,6 @@ fn guide_segment(xf: &ViewXform, clip: Rect, vertical: bool, pos: f64) -> [Pos2; if vertical { [xf.to_screen(pos, lo), xf.to_screen(pos, hi)] } else { [xf.to_screen(lo, pos), xf.to_screen(hi, pos)] } } -pub(crate) fn parse_guide_color(value: &str) -> Option { - let value = value.strip_prefix('#')?; - if value.len() != 6 { - return None; - } - let r = u8::from_str_radix(value.get(0..2)?, 16).ok()?; - let g = u8::from_str_radix(value.get(2..4)?, 16).ok()?; - let b = u8::from_str_radix(value.get(4..6)?, 16).ok()?; - Some(Color32::from_rgb(r, g, b)) -} - pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let clip = painter.clip_rect(); let drag = app.guide_drag; @@ -182,18 +171,23 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, let [a, b] = guide_segment(xf, clip, vertical, pos); styled_line(painter, a, b, Stroke::new(1.0, color), style); }; + // A guide given a colour in New Guide keeps it; the rest use the preference colour (or, while + // New Guide Layout previews, the colour it will set). + let own = |vertical: bool, p: f64| guides.color(vertical, p).map_or(guide, |[r, g, b]| Color32::from_rgb(r, g, b)); if app.ui.extras.guides || preview.is_some() { for (vertical, list) in [(true, &guides.vertical), (false, &guides.horizontal)] { for (i, p) in list.iter().enumerate() { if drag.is_some_and(|d| d.vertical == vertical && d.index == Some(i)) { continue; } - line(vertical, *p, guide); + line(vertical, *p, own(vertical, *p)); } } } if let Some(d) = drag { - line(d.vertical, d.pos, guide); + // A guide being moved shows its own colour; a new one dragged from a ruler the preference's. + let list = if d.vertical { &doc.guides.vertical } else { &doc.guides.horizontal }; + line(d.vertical, d.pos, d.index.and_then(|i| list.get(i)).map_or(guide, |p| own(d.vertical, *p))); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 3b1b64242..00280102b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -576,6 +576,8 @@ mod tests { handle(app, &ctx); ctx.end_pass().textures_delta.clear(); }; + // Plain-key cycling (the pref is on by default, as in Photoshop). + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); // Polygonal Lasso picked from the toolbar flyout, then the Brush (#2608). app.ui.tool = Tool::PolygonLasso; press(&mut app, Key::B, Modifiers::NONE); @@ -596,6 +598,37 @@ mod tests { assert_eq!(app.ui.tool, Tool::MagneticLasso); } + /// #3296: by default T keeps the current type tool and only Shift+T cycles, as in Photoshop. + #[test] + fn shift_key_tool_switch_is_the_default() { + use crate::state::Tool; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, modifiers| { + let raw = egui::RawInput { + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key: Key::T, physical_key: None, pressed: true, repeat: false, modifiers }, + ], + ..Default::default() + }; + ctx.begin_pass(raw); + handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + app.ui.tool = Tool::Type; + press(&mut app, Modifiers::NONE); + press(&mut app, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Type); + press(&mut app, Modifiers::SHIFT); + assert_ne!(app.ui.tool, Tool::Type); + // A stored "off" restores plain-key cycling. + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); + app.ui.tool = Tool::Type; + press(&mut app, Modifiers::NONE); + assert_ne!(app.ui.tool, Tool::Type); + } + /// #3109: on a German layout Ö / # (physical `;` / `\`) step the brush size and ⇧Ö / ⇧# /// (⇧# types `'`) its hardness, as Photoshop's German shortcuts. #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9a46e8a6f..8d84b5515 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -652,6 +652,13 @@ pub struct ToolOptions { /// Pencil › Auto Erase: a stroke that starts on the foreground colour paints the background colour. #[serde(default)] pub pencil_auto_erase: bool, + /// Brush › Mode: Clear, which erases with the Brush (Photoshop's painting-only mode, #3439). + /// Not a blend mode, so it is kept beside the brush's `mode` rather than in it. + #[serde(default)] + pub brush_clear: bool, + /// Pencil › Mode: Clear (see `brush_clear`). + #[serde(default)] + pub pencil_clear: bool, /// Eraser › Mode: `brush` (soft, anti-aliased) or `pencil` (hard, aliased pixels, through /// `paint.pencil`), as in Photoshop's options bar (#2662). #[serde(default = "default_eraser_mode")] @@ -809,6 +816,8 @@ impl Default for ToolOptions { cr_anti_alias: true, zoom_scrubby: true, pencil_auto_erase: false, + brush_clear: false, + pencil_clear: false, eraser_mode: default_eraser_mode(), magnetic_width: 10.0, magnetic_contrast: 10.0, diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index f89e39137..da78f1160 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, vec2}; +use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; mod hand; @@ -19,11 +19,13 @@ mod hand; const MAX_SIDE: u16 = if cfg!(target_os = "windows") { 2048 } else { 255 }; /// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags. -#[derive(Clone, Copy, Debug, PartialEq)] +/// `Outline` is a brush tip outline: closed rings of logical-point offsets from the hotspot. +#[derive(Clone, Debug, PartialEq)] enum Shape { Circle { radius: f32, centre: bool }, Crosshair { length: f32, gap: f32 }, Hand { closed: bool }, + Outline { rings: Arc<[Vec]>, centre: bool }, } #[derive(Clone)] @@ -69,6 +71,10 @@ pub(crate) fn crosshair(painter: &Painter, at: Pos2, length: f32, gap: f32) -> C show(painter, at, Shape::Crosshair { length, gap }) } +pub(crate) fn outline(painter: &Painter, at: Pos2, rings: Vec>, centre: bool) -> CursorIcon { + show(painter, at, Shape::Outline { rings: rings.into(), centre }) +} + /// Can this pass show an OS cursor bitmap? eframe's immediate viewports and its web integration /// don't upload cursor images; every desktop root viewport does (winit custom cursors). fn native(ctx: &egui::Context) -> bool { @@ -91,7 +97,7 @@ pub(crate) fn hand(ctx: &egui::Context, closed: bool) -> CursorIcon { } fn hand_with(ctx: &egui::Context, closed: bool, bitmap: bool) -> CursorIcon { - if bitmap && let Some(image) = cached_image(ctx, Shape::Hand { closed }, native_scale(ctx)) { + if bitmap && let Some(image) = cached_image(ctx, &Shape::Hand { closed }, native_scale(ctx)) { ctx.set_cursor_image(Some(image)); return CursorIcon::None; } @@ -103,36 +109,36 @@ fn show(painter: &Painter, at: Pos2, shape: Shape) -> CursorIcon { // Where cursor images can't be uploaded (immediate viewports, the web), paint the shape. if native(ctx) { let scale = native_scale(ctx); - if let Some(image) = cached_image(ctx, shape, scale) { + if let Some(image) = cached_image(ctx, &shape, scale) { ctx.set_cursor_image(Some(image)); return CursorIcon::None; } // An enormous tip cannot fit an OS cursor. Preserve its full-size outline, with an OS // crosshair at the hotspot so the user still gets immediate position feedback. - paint(painter, at, shape); - if let Some(image) = cached_image(ctx, Shape::Crosshair { length: 6.0, gap: 2.0 }, scale) { + paint(painter, at, &shape); + if let Some(image) = cached_image(ctx, &Shape::Crosshair { length: 6.0, gap: 2.0 }, scale) { ctx.set_cursor_image(Some(image)); } return CursorIcon::None; } - paint(painter, at, shape); + paint(painter, at, &shape); CursorIcon::None } -fn cached_image(ctx: &egui::Context, shape: Shape, scale: f32) -> Option { +fn cached_image(ctx: &egui::Context, shape: &Shape, scale: f32) -> Option { let id = egui::Id::new("photocraft-tool-cursor"); if let Some(cached) = ctx.data(|d| d.get_temp::(id)) - && cached.shape == shape + && cached.shape == *shape && cached.scale == scale { return Some(cached.image); } let image = rasterize(shape, scale)?; - ctx.data_mut(|d| d.insert_temp(id, Cached { shape, scale, image: image.clone() })); + ctx.data_mut(|d| d.insert_temp(id, Cached { shape: shape.clone(), scale, image: image.clone() })); Some(image) } -fn paint(painter: &Painter, at: Pos2, shape: Shape) { +fn paint(painter: &Painter, at: Pos2, shape: &Shape) { if extent(shape).is_none() { return; } @@ -141,12 +147,22 @@ fn paint(painter: &Painter, at: Pos2, shape: Shape) { let stroke = Stroke::new(width, color); match shape { Shape::Circle { radius, centre } => { - painter.circle_stroke(at, radius, stroke); - if centre { + painter.circle_stroke(at, *radius, stroke); + if *centre { + paint_crosshair(painter, at, 3.0, 0.0, stroke); + } + } + Shape::Crosshair { length, gap } => paint_crosshair(painter, at, *length, *gap, stroke), + Shape::Outline { rings, centre } => { + for ring in rings.iter() { + if ring.len() >= 2 { + painter.add(egui::Shape::closed_line(ring.iter().map(|v| at + *v).collect(), stroke)); + } + } + if *centre { paint_crosshair(painter, at, 3.0, 0.0, stroke); } } - Shape::Crosshair { length, gap } => paint_crosshair(painter, at, length, gap, stroke), // Only ever a bitmap: without one the OS draws its own grab cursors. Shape::Hand { .. } => {} } @@ -166,42 +182,81 @@ fn cross_distance(x: f32, y: f32, length: f32, gap: f32) -> f32 { horizontal.min(vertical) } -fn extent(shape: Shape) -> Option { +fn extent(shape: &Shape) -> Option { Some(match shape { Shape::Circle { radius, centre } => { - if !radius.is_finite() || radius <= 0.0 { + if !radius.is_finite() || *radius <= 0.0 { return None; } - if centre { radius.max(3.0) } else { radius } + if *centre { radius.max(3.0) } else { *radius } } Shape::Crosshair { length, gap } => { - if !length.is_finite() || !gap.is_finite() || gap < 0.0 || length < gap { + if !length.is_finite() || !gap.is_finite() || *gap < 0.0 || length < gap { return None; } - length + *length + } + Shape::Outline { rings, centre } => { + let mut e = 0.0f32; + for ring in rings.iter() { + for p in ring { + if !p.x.is_finite() || !p.y.is_finite() { + return None; + } + e = e.max(p.x.hypot(p.y)); + } + } + if e <= 0.0 { + return None; + } + if *centre { e.max(3.0) } else { e } } Shape::Hand { .. } => hand::HALF, }) } +/// Distance from (`x`, `y`) to the segment `a`-`b`, all in the same units. +fn seg_distance(x: f32, y: f32, a: Vec2, b: Vec2) -> f32 { + let (abx, aby) = (b.x - a.x, b.y - a.y); + let t = (((x - a.x) * abx + (y - a.y) * aby) / (abx * abx + aby * aby).max(1e-12)).clamp(0.0, 1.0); + ((x - a.x - abx * t).powi(2) + (y - a.y - aby * t).powi(2)).sqrt() +} + /// How much of the pixel at `(dx, dy)` physical pixels from the hotspot the light fill covers and /// how much the dark outline around it does, each 0 to 1. A dark crease inside the hand takes /// from the fill, so the fill never covers it. -fn coverage(shape: Shape, dx: f32, dy: f32, scale: f32) -> (f32, f32) { +fn coverage(shape: &Shape, dx: f32, dy: f32, scale: f32) -> (f32, f32) { let (x, y) = (dx.abs(), dy.abs()); match shape { Shape::Circle { radius, centre } => { let ring = (x.hypot(y) - radius * scale).abs(); - let distance = if centre { ring.min(cross_distance(x, y, 3.0 * scale, 0.0)) } else { ring }; + let distance = if *centre { ring.min(cross_distance(x, y, 3.0 * scale, 0.0)) } else { ring }; stroke_coverage(distance, scale) } Shape::Crosshair { length, gap } => stroke_coverage(cross_distance(x, y, length * scale, gap * scale), scale), + Shape::Outline { rings, centre } => { + let (lx, ly) = (dx / scale, dy / scale); + let mut distance = f32::INFINITY; + for ring in rings.iter() { + let n = ring.len(); + if n < 2 { + continue; + } + for (i, &a) in ring.iter().enumerate() { + distance = distance.min(seg_distance(lx, ly, a, ring[(i + 1) % n])); + } + } + if *centre { + distance = distance.min(cross_distance(dx, dy, 3.0 * scale, 0.0)); + } + stroke_coverage(distance * scale, scale) + } Shape::Hand { closed } => { let (lx, ly) = (dx / scale, dy / scale); - let edge = hand::shape(closed, lx, ly) * scale; + let edge = hand::shape(*closed, lx, ly) * scale; let inside = (0.5 - edge).clamp(0.0, 1.0); let outline = (scale + 0.5 - edge).clamp(0.0, 1.0); - let crease = (0.45 * scale + 0.5 - hand::crease(closed, lx, ly) * scale).clamp(0.0, 1.0); + let crease = (0.45 * scale + 0.5 - hand::crease(*closed, lx, ly) * scale).clamp(0.0, 1.0); (inside * (1.0 - crease), outline.max(inside * crease)) } } @@ -212,7 +267,57 @@ fn stroke_coverage(distance: f32, scale: f32) -> (f32, f32) { ((0.5 * scale + 0.5 - distance).clamp(0.0, 1.0), (1.5 * scale + 0.5 - distance).clamp(0.0, 1.0)) } -fn rasterize(shape: Shape, scale: f32) -> Option { +/// Splat the distance to one ring segment into an outline's side × side distance buffer. +fn splat_segment(dist: &mut [f32], side: usize, hot: f32, a: Vec2, b: Vec2, reach: f32) { + let n = side as f32; + let (x0, x1) = (a.x.min(b.x) + hot - reach, a.x.max(b.x) + hot + reach); + let (y0, y1) = (a.y.min(b.y) + hot - reach, a.y.max(b.y) + hot + reach); + if x1 < 0.0 || y1 < 0.0 || x0 > n - 1.0 || y0 > n - 1.0 { + return; + } + let (c0, c1) = (x0.max(0.0).floor() as i32, x1.min(n - 1.0).ceil() as i32); + let (r0, r1) = (y0.max(0.0).floor() as i32, y1.min(n - 1.0).ceil() as i32); + for row in r0..=r1 { + let dy = row as f32 - hot; + for col in c0..=c1 { + let d = seg_distance(col as f32 - hot, dy, a, b); + let i = row as usize * side + col as usize; + if d < dist[i] { + dist[i] = d; + } + } + } +} + +/// Fold the centre crosshair (the circle cursor's) into the outline distances. +fn splat_cross(dist: &mut [f32], side: usize, hot: f32, arm: f32) { + let reach = arm + 2.0; + let lo = (hot - reach).max(0.0).floor() as i32; + let hi = ((hot + reach).min(side as f32 - 1.0)).ceil() as i32; + for row in lo..=hi { + let dy = row as f32 - hot; + for col in lo..=hi { + let dx = col as f32 - hot; + let d = cross_distance(dx, dy, arm, 0.0); + let i = row as usize * side + col as usize; + if d < dist[i] { + dist[i] = d; + } + } + } +} + +/// One bitmap pixel from its distance to the stroke centre, in straight RGBA (winit form). +fn push_stroke_pixel(rgba: &mut Vec, distance: f32, scale: f32, dark: egui::Color32, light: egui::Color32) { + let (fill, outline) = stroke_coverage(distance, scale); + let a_dark = outline * f32::from(dark.a()) / 255.0; + let a_light = fill * f32::from(light.a()) / 255.0; + let alpha = a_light + a_dark * (1.0 - a_light); + let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 }; + rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]); +} + +fn rasterize(shape: &Shape, scale: f32) -> Option { if !scale.is_finite() || scale <= 0.0 { return None; } @@ -228,6 +333,30 @@ fn rasterize(shape: Shape, scale: f32) -> Option { let bytes = usize::from(side).checked_mul(usize::from(side))?.checked_mul(4)?; let mut rgba = Vec::with_capacity(bytes); let [dark, light] = crate::theme::Tokens::cursor_outline(); + if let Shape::Outline { rings, centre } = shape { + // Splat each segment into its bounding box instead of measuring every ring per pixel: + // tip outlines may carry hundreds of points. + let hotf = f32::from(hot); + let n = usize::from(side); + let reach = 1.5 * scale + 2.0; + let mut dist = vec![f32::INFINITY; n * n]; + for ring in rings.iter() { + let len = ring.len(); + if len < 2 { + continue; + } + for (i, &a) in ring.iter().enumerate() { + splat_segment(&mut dist, n, hotf, a * scale, ring[(i + 1) % len] * scale, reach); + } + } + if *centre { + splat_cross(&mut dist, n, hotf, 3.0 * scale); + } + for &d in &dist { + push_stroke_pixel(&mut rgba, d, scale, dark, light); + } + return Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] }); + } for row in 0..side { let dy = f32::from(row) - f32::from(hot); for col in 0..side { diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 3ce97b79b..3a8bd1f01 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -21,7 +21,7 @@ fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutp fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() { for scale in [1.0, 1.25, 1.5, 2.0, 3.0] { let radius = 20.0; - let image = rasterize(Shape::Circle { radius, centre: false }, scale).unwrap(); + let image = rasterize(&Shape::Circle { radius, centre: false }, scale).unwrap(); let [x, y] = image.hotspot; assert_eq!(image.size, [2 * x + 1, 2 * y + 1]); assert_eq!(image.rgba.len(), usize::from(image.size[0]).pow(2) * 4); @@ -38,13 +38,13 @@ fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() { #[test] fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() { - let ring = rasterize(Shape::Circle { radius: 20.0, centre: true }, 1.0).unwrap(); + let ring = rasterize(&Shape::Circle { radius: 20.0, centre: true }, 1.0).unwrap(); assert!(alpha(&ring, ring.hotspot[0], ring.hotspot[1]) > 230); - let precise = rasterize(Shape::Crosshair { length: 8.0, gap: 2.0 }, 1.0).unwrap(); + let precise = rasterize(&Shape::Crosshair { length: 8.0, gap: 2.0 }, 1.0).unwrap(); let [x, y] = precise.hotspot; assert_eq!(alpha(&precise, x, y), 0); assert!(alpha(&precise, x + 5, y) > 230); - let solid = rasterize(Shape::Crosshair { length: 6.0, gap: 0.0 }, 1.0).unwrap(); + let solid = rasterize(&Shape::Crosshair { length: 6.0, gap: 0.0 }, 1.0).unwrap(); assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230); } @@ -52,7 +52,7 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() { /// larger one falls back to the painted outline. Windows takes far larger cursors. #[test] fn cursor_bitmaps_stay_within_the_platform_limit() { - let fits = |radius: f32| rasterize(Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]); + let fits = |radius: f32| rasterize(&Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]); assert!(fits(100.0).is_some_and(|side| side <= 255)); assert_eq!(fits(200.0).is_some(), cfg!(target_os = "windows")); assert!(fits(200.0).is_none_or(|side| side <= 2048)); @@ -61,21 +61,21 @@ fn cursor_bitmaps_stay_within_the_platform_limit() { #[test] fn hostile_sizes_are_rejected_before_allocating() { for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, 2048.0] { - assert!(rasterize(Shape::Circle { radius, centre: false }, 1.0).is_none()); + assert!(rasterize(&Shape::Circle { radius, centre: false }, 1.0).is_none()); } for scale in [f32::NAN, f32::INFINITY, -1.0, 0.0, f32::MAX] { - assert!(rasterize(Shape::Circle { radius: 20.0, centre: false }, scale).is_none()); + assert!(rasterize(&Shape::Circle { radius: 20.0, centre: false }, scale).is_none()); } for (length, gap) in [(1.0, 2.0), (8.0, -1.0), (f32::NAN, 0.0), (8.0, f32::NAN)] { - assert!(rasterize(Shape::Crosshair { length, gap }, 1.0).is_none()); + assert!(rasterize(&Shape::Crosshair { length, gap }, 1.0).is_none()); } } #[test] fn hand_bitmaps_are_outlined_hands_around_a_centred_hotspot_at_each_dpi() { for scale in [1.0, 1.25, 1.5, 2.0, 3.0] { - let open = rasterize(Shape::Hand { closed: false }, scale).unwrap(); - let fist = rasterize(Shape::Hand { closed: true }, scale).unwrap(); + let open = rasterize(&Shape::Hand { closed: false }, scale).unwrap(); + let fist = rasterize(&Shape::Hand { closed: true }, scale).unwrap(); for image in [&open, &fist] { let [x, y] = image.hotspot; assert_eq!(image.size, [2 * x + 1, 2 * y + 1]); @@ -93,7 +93,7 @@ fn hand_bitmaps_are_outlined_hands_around_a_centred_hotspot_at_each_dpi() { #[test] fn hand_bitmap_is_a_straight_rgba_silhouette_outlined_outside_the_hand() { - let image = rasterize(Shape::Hand { closed: false }, 1.0).unwrap(); + let image = rasterize(&Shape::Hand { closed: false }, 1.0).unwrap(); let side = usize::from(image.size[0]); let px = |x: usize, y: usize| <[u8; 4]>::try_from(&image.rgba[(y * side + x) * 4..][..4]).unwrap(); let mid = side / 2; @@ -123,8 +123,8 @@ fn hand_bitmap_is_a_straight_rgba_silhouette_outlined_outside_the_hand() { #[test] fn hostile_scales_get_no_hand_bitmap() { for scale in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, 500.0] { - assert!(rasterize(Shape::Hand { closed: false }, scale).is_none(), "{scale}"); - assert!(rasterize(Shape::Hand { closed: true }, scale).is_none(), "{scale}"); + assert!(rasterize(&Shape::Hand { closed: false }, scale).is_none(), "{scale}"); + assert!(rasterize(&Shape::Hand { closed: true }, scale).is_none(), "{scale}"); } } @@ -145,7 +145,7 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() { ui.ctx().set_cursor_icon(icon); }); let image = bitmap.platform_output.cursor_image.expect("Windows has no stock hand"); - assert_eq!(image.rgba, rasterize(Shape::Hand { closed }, 1.0).unwrap().rgba); + assert_eq!(image.rgba, rasterize(&Shape::Hand { closed }, 1.0).unwrap().rgba); assert_eq!(bitmap.platform_output.cursor_icon, CursorIcon::Crosshair, "visible fallback if the OS rejects a bitmap"); } let open = hand_with(&ctx, false, true); @@ -157,13 +157,13 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() { fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() { let ctx = egui::Context::default(); let shape = Shape::Circle { radius: 20.0, centre: false }; - let a = cached_image(&ctx, shape, 1.0).unwrap(); - let b = cached_image(&ctx, shape, 1.0).unwrap(); + let a = cached_image(&ctx, &shape, 1.0).unwrap(); + let b = cached_image(&ctx, &shape, 1.0).unwrap(); assert!(Arc::ptr_eq(&a.rgba, &b.rgba)); - let scaled = cached_image(&ctx, shape, 2.0).unwrap(); + let scaled = cached_image(&ctx, &shape, 2.0).unwrap(); assert!(!Arc::ptr_eq(&a.rgba, &scaled.rgba)); assert!(scaled.size[0] > a.size[0]); - let larger = cached_image(&ctx, Shape::Circle { radius: 40.0, centre: false }, 2.0).unwrap(); + let larger = cached_image(&ctx, &Shape::Circle { radius: 40.0, centre: false }, 2.0).unwrap(); assert!(larger.size[0] > scaled.size[0]); } @@ -171,7 +171,7 @@ fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() { fn lifecycle_clears_sticky_images_and_respects_later_widgets() { let ctx = egui::Context::default(); ctx.add_plugin(CursorLifecycle); - let image = rasterize(Shape::Circle { radius: 20.0, centre: false }, 1.0).unwrap(); + let image = rasterize(&Shape::Circle { radius: 20.0, centre: false }, 1.0).unwrap(); let output = frame(&ctx, |ui| { ui.ctx().set_cursor_image(Some(image.clone())); ui.ctx().set_cursor_icon(CursorIcon::None); @@ -233,8 +233,8 @@ fn write_cursor_preview() { rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(background); } } - for (row, &(shape, scale)) in cases.iter().enumerate() { - let image = rasterize(shape, scale).unwrap(); + for (row, (shape, scale)) in cases.iter().enumerate() { + let image = rasterize(shape, *scale).unwrap(); let side = usize::from(image.size[0]); let x0 = col * 200 + 100 - usize::from(image.hotspot[0]); let y0 = row * 100 + 50 - usize::from(image.hotspot[1]); @@ -306,7 +306,7 @@ fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_chan let hand = h.output().platform_output.cursor_image.clone(); if cfg!(target_os = "windows") { let hand = hand.expect("the Hand's open hand reaches the OS as a bitmap"); - assert_eq!(hand.rgba, rasterize(Shape::Hand { closed: false }, h.ctx.pixels_per_point()).unwrap().rgba); + assert_eq!(hand.rgba, rasterize(&Shape::Hand { closed: false }, h.ctx.pixels_per_point()).unwrap().rgba); } else { assert!(hand.is_none(), "the OS draws the hand"); assert_eq!(h.output().platform_output.cursor_icon, CursorIcon::Grab); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 4925e06bb..6baad29ad 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1468,7 +1468,8 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5)); } - thumb(ui, Rect::from_min_size(pos2(r.left() + 8.0, r.top() + 4.0), vec2(28.0, 28.0)), path, &doc); + let thumbnail = Rect::from_min_size(pos2(r.left() + 8.0, r.top() + 4.0), vec2(28.0, 28.0)); + thumb(ui, thumbnail, path, &doc); let mut job = egui::text::LayoutJob::default(); // Temporary paths (the work path, the selected layer's shape path or vector mask) are italic. let italics = *kind != PathRow::Saved; @@ -1479,11 +1480,20 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); let g = ui.painter().layout_job(job); ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); - if resp.clicked() { + // Ctrl/Cmd-click loads only the thumbnail's path, without changing the panel target. + let modifiers = ui.input(|i| i.modifiers); + let load = resp.clicked() && modifiers.command && resp.interact_pointer_pos().is_some_and(|p| thumbnail.contains(p)); + if load { + let mode = match crate::channels_panel::load_operation(modifiers) { + "new" => "replace", + mode => mode, + }; + action = Some(("path.toSelection", json!({"name": key, "mode": mode}))); + } else if resp.clicked() { app.ui.selected_path = Some(key.clone()); } // Double-clicking the Work Path saves it (Photoshop's "Save Path"). - if resp.double_clicked() && *kind == PathRow::Work { + if !load && resp.double_clicked() && *kind == PathRow::Work { let n = doc.paths.len() + 1; action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")}))); } diff --git a/docs/architecture.md b/docs/architecture.md index 893617a92..7856636cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -349,7 +349,7 @@ Both backends consume the same plan. This is the only place that encodes Photosh - Blending happens in document space by default, which is Photoshop-compatible. A per-document "linear light blending" option is also available. - **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas the transform, plus Proof Colors / Gamut Warning / 32-bit preview, is baked into a 33³ 3D LUT the canvas shader's final pass applies; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports). -- **Monitor profile:** Edit › Color Settings › Monitor Profile: `auto`, a built-in RGB profile or an `.icc` path. In `auto` each window uses the profile of the display it is on (#569): the desktop app reads every display's id, name, frame and ICC profile (macOS: AppKit `NSScreen` through `osascript`, no FFI) at launch and again when the app comes back to the front, when any window is on an unknown or resized display (at most every 30 s; a trigger inside that window is deferred) and when Edit › Color Settings opens (`ui-egui/src/monitor_status.rs`; the helper is stopped after 10 s). There is deliberately no periodic re-read (#569 decision: a read costs about 0.2 s of CPU), so a profile reassigned while PhotoCraft stays in front with no window moving is picked up at the next return to the front or Color Settings, and each window picks the display it overlaps most (`display_color::display_at`, AppKit's `NSWindow.screen` rule). Elsewhere `auto` is sRGB. The GPU canvas keeps one texture per document and a display LUT per (document, display); CPU canvas textures are per (document, display). `ColorState::monitor_status_for` says what is applied per display: `auto`, `manual`, or `fallback` to sRGB with the reason (missing, unreadable, non-RGB or unusable as a destination), and whether the reading is an earlier one kept after a failed re-read; `edit.colorSettings` (`monitorStatus`, `displays`), Help › System Info and the Color Settings dialog show it, and a fallback in `auto` posts a notice. Letting macOS colour-match the canvas instead is #581 (an architecture decision). +- **Monitor profile:** Edit › Color Settings › Monitor Profile: `auto`, a built-in RGB profile or an `.icc` path. In `auto` each window uses the profile of the display it is on (#569): the desktop app reads every display's id, name, frame and ICC profile at launch and again when the app comes back to the front, when any window is on an unknown or resized display (at most every 30 s; a trigger inside that window is deferred) and when Edit › Color Settings opens (`ui-egui/src/monitor_status.rs`). macOS reads AppKit `NSScreen` through `osascript` (no FFI); X11 reads RandR outputs and the default colord profile matched by exact `XRANDR_name`; native Wayland and Xwayland windows from a Wayland session stay on sRGB because eframe/winit cannot safely set the app surface's Wayland image description. The macOS helper is stopped after 10 s. There is deliberately no periodic re-read (#569 decision: a read costs about 0.2 s of CPU), so a profile reassigned while PhotoCraft stays in front with no window moving is picked up at the next return to the front or Color Settings. X11 selects the root window's RandR output by winit's current-monitor name and other windows by their overlapping RandR frame; macOS follows `NSWindow.screen`. Elsewhere `auto` is sRGB. The GPU canvas keeps one texture per document and a display LUT per (document, display); CPU canvas textures are per (document, display). `ColorState::monitor_status_for` says what is applied per display: `auto`, `manual`, or `fallback` to sRGB with the reason (missing, unreadable, non-RGB or unusable as a destination), and whether the reading is an earlier one kept after a failed re-read; `edit.colorSettings` (`monitorStatus`, `displays`), Help › System Info and the Color Settings dialog show it, and a fallback in `auto` posts a notice. Letting macOS colour-match the canvas instead is #581 (an architecture decision). - **HDR/EDR output** (an `rgba16float` surface with an extended-range colorspace) is a later-phase feature. The interfaces already carry `f32` pixels. ### 7.4 Oracle diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 75575abd7..119da10d6 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-10 · **Change:** major (first version: brush engine, dynamics, pressure, smoothing, cursors, retouch tools and feel audited from source and open issues) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`~` held with the Brush or Pencil erases with its brush; Clear painting mode) · **Target:** Adobe Photoshop 2026 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 @@ -69,6 +69,8 @@ tablets against Photoshop side by side. | `[` `]` size, `⇧[` `⇧]` hardness, number keys opacity, ⇧+numbers flow | done | 2026-10-07 | | ⌥-click samples a colour with painting tools; Eyedropper comparison ring | done | TOOL-213-9 | | ⇧-click draws a straight line from the last point | done | | +| Hold `~` with the Brush or Pencil to erase with its brush: Clear mode while held (rebindable under Tools › Temporary) | done | `hold_keys.rs`, #3439 | +| Clear painting mode in the Brush and Pencil Mode menus | done | `paint_mode_dropdown` in `panels.rs`, `"mode": "clear"`, #3439 | | Right-click opens the Brush Preset picker | done | [`context-menu-parity.md`](context-menu-parity.md); XP-Pen right-click not (#1980), macOS issue #2360 | | Precise / normal / full-size tip cursors, crosshair in the tip | partial | Caps Lock precise cursor not audited; round Pencil cursor requested (#2662, #1057) | | Brush choice kept per tool (Brush, Eraser, Blur each their own) | partial | #2542 | @@ -111,3 +113,4 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| | 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) | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4b940981b..c73a0be6a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -104,12 +104,14 @@ store expose and persist the same setting; scripts keep using canonical command | `layer.newAdjustmentLayer.hueSaturation` | `{"hue":30,"saturation":10}` | | `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, selection bounds | +| `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | | `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 | | `document.presets.get` | `{"name":"Product square"}` → `{preset,params}`: fetch settings; pass `params` to `file.new` to create a document, optionally adding a document `name` | | `document.presets.delete` | `{"name":"Product square"}`: delete the named saved configuration | +| `document.recent.list` | `{}` → `{recent:[settings]}`: File › New › Recent, the settings of the documents last created with the New Document dialog, newest first (at most 20) | +| `document.recent.add` | `{"settings":{…as document.presets.save}}`: put settings first in Recent (the dialog does this after Create; equal entries collapse); not recorded into actions | | `type.hitTest` | `{"layer":id?,"x":px,"y":px}`: character under a document point. No `layer` picks the topmost visible type layer there. Result `{"layer","index","line","inside"}` | | `type.caret` | `{"layer":id?,"index":char}`: caret segment in document pixels, `{"index","line","segment":[[x,y],[x,y]]}` (rotated and vertical type included) | | `type.navigate` | `{"layer":id?,"index":char,"move":"wordPrev\|wordNext\|linePrev\|lineNext\|lineStart\|lineEnd\|start\|end","x":px?}`: neighbouring caret. `x` keeps the column across line moves. Result `{"index"}` | @@ -186,7 +188,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca | `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key | | `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.` or `menus.colors.` reset removes the override and returns `null`, including when it is already absent | | `edit.preferences.
` | the section's values; in the app (no params) it opens the Preferences dialog on that section | -| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once | +| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once | | `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` | | `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt | diff --git a/docs/development.md b/docs/development.md index 5504958a2..bb291af49 100644 --- a/docs/development.md +++ b/docs/development.md @@ -283,6 +283,7 @@ translations do not affect the denominator. Unregistered locale TSV files are ig - **Numbers** (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro; `cargo run --release -p photocraft-ui-egui --example fx_bench`), CPU fallback → GPU: full refresh with warm effect maps 5.7 s → 47 ms; Hue/Saturation tweak above the effects 4.7 s → 31 ms; brush dab on a plain layer 32 → 1.7 ms; brush dab on the effect layer (its maps rebuilt around the dab) 659 → 3.7 ms; first refresh (all maps built) 6.4 s → 0.39 s. With the CPU ~14× oversubscribed by parallel builds (min of 7 runs): 8.1 s → 0.33 s, 9.9 s → 0.28 s, 28 → 2.2 ms, 1.7 s → 89 ms; moving a text layer 7 px 336 → 13 ms. `fx_bench --compare corpus/psd/…/*.psd` reports the GPU vs CPU difference on real files (30 of the 31 corpus files with effects render on the GPU, worst 0.12/255). - On the CPU path `compose::effect_maps` caches shadow, glow, bevel and satin maps per layer state (LRU, 768 MB budget). - Live adjustment previews on large documents use a downsampled proxy (`ui-egui/src/proxy.rs`). +- **Content-Aware tools** (`algo::inpaint`, `algo::content_aware`: Fill, Spot Healing, Patch, Content-Aware Move). `cargo run --release -p photocraft-engine --example content_aware_bench -- bench caf|heal|cancel` times the P21, P22 and P49 rows without the UI, one sample per line, so two builds can be run in turn and pooled. `-- hash` prints a hash of every tool's output at 8, 16 and 32 bits and in Gray, CMYK and Lab: the fill is fitted to Photoshop, so a speed-up must leave both builds' lists equal. The solver works only over the hole's bounds grown by a patch radius (nothing else changes or is matched). PatchMatch sweeps its bands in wavefronts when there are fewer bands than threads; each cell sees the same neighbours and random numbers as in raster order, so the result does not depend on the thread count. Cancellation is checked per row: a 24 MP fill stops within tens of milliseconds, most of it spent freeing its buffers. - `ui.inspect` returns `perf` timings (UI ms per frame, composite ms, upload ms). - Never scan full surfaces per frame. Cache per document revision (`PhotocraftApp::cached_bounds`). An uncached `content_bounds()` on a 36 MP layer once cost 77 ms per frame. @@ -540,7 +541,8 @@ Desktop commands run composition and submission on a background worker. The UI r only when the spooler accepts the job; remove submitted jobs in the system print queue. Headless `Session::execute` stays synchronous. The returned `pdf` is `null` for a spool-only job, and `command` is a diagnostic preview, not an executable command with a PDF path. Supplying `output` -keeps a PDF at that path and defaults `send` to false. `dryRun` renders a diagnostic PDF but never -submits; an implicit diagnostic file is exclusively created with a random name and belongs to -the caller, who may delete it after inspection. Shared spooler transport files are automatically -removed on Windows; CUPS needs none. +keeps a PDF at that path and defaults `send` to false. `dryRun` renders the PDF and reports the +backend command without submitting; it writes a PDF only when `output` is explicit. `send: false` +without an output requires `dryRun: true`. Remembered print settings omit `output`, `dryRun` and +`send`, so Print One Copy derives submission from its own output choice. Shared spooler transport +files are automatically removed on Windows; CUPS needs none. diff --git a/docs/mcp.md b/docs/mcp.md index 7ae522472..4f7e9cc70 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -15,8 +15,9 @@ described in [development.md](development.md). UI tools require a running deskto - `command_list`: discover engine ids, parameter docs and current enabled state; accepts `filter` and `enabled_only`. - `command_run`: `id`, optional `params` and `wait`. Command parameters remain engine-defined. -- `command_batch`: ordered `steps`, optional `stop_on_error`; one history step per command, - not an atomic transaction. Existing per-step results and reply budgets are preserved. +- `command_batch`: ordered `steps`, optional `stop_on_error` (default true); stops at the first + error unless false. Not an atomic transaction: earlier successful steps stay applied, with + no rollback. Existing per-step results and reply budgets are preserved. - `doc_inspect`: layer tree, history and selection; an empty session returns `document:null`. Every layer (including groups and nested children) reports raster-only `hasMask`, `hasVectorMask` for vector mask presence, and `vectorMaskEnabled` (`true`/`false` when @@ -29,6 +30,18 @@ Existing document/session/job/UI tools remain listed, including the documented and all four annotation hints. Generic command/control tools conservatively advertise writes; file saves may replace existing targets. Hints do not grant permission. +After a failed batch, `completed` and per-step `results` report successful steps, not a +rolled-back transaction. Inspect the document and its history with `doc_inspect`; commands +do not necessarily add one history entry each. For undoable edits, use `command_run` with +`{"id":"edit.undo","params":{}}` once per undoable history entry added, inspecting history +as you recover. Undo does not roll back files written to disk. If a reply-budget error says +the current step may have completed, inspect its effects before retrying or undoing it. + +Before experiments, use `command_run` with `image.duplicate` and `{}` params, or +`history.newDocument` and `{}` params (copies the current state by default). Both create a +new active document, leaving the source unchanged; run experimental edits on that copy. +The optional `state` parameter of `history.newDocument` selects a retained historical state. + Unknown tool arguments return JSON-RPC `-32602`, naming the key, before execution. Batch step envelopes are strict too. Nested engine `params` retain their command-specific handling. Tool execution failures return `isError:true`. A worker panic becomes a tool error and the diff --git a/docs/releasing.md b/docs/releasing.md index 12cb224c3..6d35b6379 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -63,7 +63,7 @@ Every binary reports its version, the commit and the build date: `photocraft --v `cargo build` doesn't set them and reports `0.2.0 (dev build)`. Every desktop build job (macOS, Windows, Linux, FreeBSD) also checks out [craft-fonts](https://github.com/storytold/craft-fonts) -at the commit in `CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and +at the `craft-fonts-ref` commit in `.github/workflows/release.yml` and builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so desktop releases embed its Japanese fonts (the web build embeds none: see `docs/development.md` › Fonts) and fail rather than ship without them. The packages carry each font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; the portable zip on Windows; @@ -183,6 +183,13 @@ Why these formats: - **AppImage** runs on any distribution without installing anything. The download-and-go option and the fallback for distros the packages don't cover. It's built with the maintained `AppImage/appimagetool`, whose static runtime doesn't need libfuse2. + The packaging script verifies pinned AppImageTool 1.9.1 and type2 runtime 20251108 binaries. + For an offline build, pre-seed `$CARGO_TARGET_DIR/appimagetool-1.9.1-$ARCH.AppImage` and + `$CARGO_TARGET_DIR/type2-runtime-20251108-$ARCH` with the matching official release assets; + the script checks both SHA-256 digests and makes the cached AppImageTool executable after verification; + the runtime is only read as data. + An `APPIMAGETOOL` override must point to that same verified AppImageTool binary and already be + executable (`chmod +x` it before packaging). Each AppImage embeds update information (`gh-releases-zsync|…|latest|…`, #349) and ships with a `.zsync` beside it, so AppImageUpdate, AppImageLauncher and similar tools can find the next release and download only the blocks that changed. diff --git a/docs/scorecard.md b/docs/scorecard.md index 2db7bcc5f..d30503ef6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 12 | 6 | 5 | settings that do nothing: 30 of 155 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -193,7 +193,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes | -| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard | +| UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | | UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) | | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) | diff --git a/docs/ui-design.md b/docs/ui-design.md index 183670932..f3193b268 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -55,6 +55,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | +| Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | | Shape Stroke Options | `shape_stroke_ui.rs`, `vector_ui.rs` | Direct editing: valid changes apply immediately, with one numeric drag per undo step in Properties. Escape/outside click closes the popup and retains changes; a child dropdown keeps the parent open. The options bar sets new-shape defaults; Properties edits the selected shape. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c901cad3a..147125519 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Hand-tool flick panning) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (History snapshots) · **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, @@ -49,7 +49,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Toolbar behaviour | Status | Evidence | |---|---|---| -| Letter shortcuts, ⇧+letter cycles the group, "Use Shift Key for Tool Switch" | done | `shortcuts.rs:413` | +| Letter shortcuts, ⇧+letter cycles the group, "Use Shift Key for Tool Switch" (on by default, as in Photoshop) | done | `shortcuts.rs:413` | | Group remembers the last tool used | partial | L always resets to Lasso (#2608) | | Press-and-hold or click-drag opens the flyout | missing | right-click only (#2301) | | Edit Toolbar customization | done | `edit.toolbar` | @@ -200,6 +200,7 @@ Paragraph; the rest open as in-app floating windows. | Command palette with recents | done | UI-217-7 | | Every preference takes effect | partial | 46 of 149 do nothing (scorecard) | | UI scale presets | missing | UI-217-9, #2492 | +| History snapshots: camera button, snapshot rows (click returns, rename, delete), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | @@ -211,3 +212,4 @@ Paragraph; the rest open as in-app floating windows. | 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 | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | +| 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index c5e003e04..615bef25b 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -9,8 +9,8 @@ # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] # -# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage -# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, +# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com). By default AppImage tooling +# is downloaded at verified pins into $CARGO_TARGET_DIR. Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. # @@ -21,6 +21,8 @@ set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/linux" +# shellcheck source=verified-download.sh +. "$HERE/verified-download.sh" APP_ID=ai.storyteller.photocraft SKIP_BUILD=0 @@ -115,19 +117,42 @@ if has appimage; then cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" ln -s "$APP_ID.png" "$APPDIR/.DirIcon" - TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" - fi + # Digests from the official GitHub release asset metadata: + # https://github.com/AppImage/appimagetool/releases/tag/1.9.1 + # https://github.com/AppImage/type2-runtime/releases/tag/20251108 + # Keep both pins together: + # appimagetool otherwise downloads the mutable latest type2 runtime itself. + TOOL_VERSION=1.9.1 + RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) + TOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + aarch64) + TOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + ;; + esac + mkdir -p "$CARGO_TARGET_DIR" + TOOL="${APPIMAGETOOL:-$CARGO_TARGET_DIR/appimagetool-$TOOL_VERSION-$ARCH.AppImage}" + if [ -n "${APPIMAGETOOL:-}" ]; then + verify_appimagetool_override "$TOOL" "$TOOL_SHA256" "$TOOL_VERSION" "$ARCH" + else + download_verified \ + "https://github.com/AppImage/appimagetool/releases/download/$TOOL_VERSION/appimagetool-$ARCH.AppImage" \ + "$TOOL" "$TOOL_SHA256" true fi + RUNTIME="$CARGO_TARGET_DIR/type2-runtime-$RUNTIME_VERSION-$ARCH" + download_verified \ + "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$ARCH" \ + "$RUNTIME" "$RUNTIME_SHA256" # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information (#349): AppImageUpdate, AppImageLauncher and the like read it from the @@ -139,7 +164,8 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream \ + --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" if [ -s "$OUT.zsync" ]; then echo "wrote $OUT.zsync" diff --git a/packaging/linux/verified-download-test.sh b/packaging/linux/verified-download-test.sh new file mode 100644 index 000000000..540cbd54b --- /dev/null +++ b/packaging/linux/verified-download-test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Fast, network-free checks of the verified AppImage tooling cache and override. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=verified-download.sh +. "$HERE/verified-download.sh" + +sha256() { + if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi +} +fail() { echo "FAIL: $*" >&2; exit 1; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +source_asset="$TMP/source.AppImage" +cache="$TMP/cached.AppImage" +printf '#!/bin/sh\nexit 0\n' >"$source_asset" +expected="$(sha256 "$source_asset")" +# Setup uses the external binary because failure-path tests below shadow the chmod function. + +# Offline preseed: a valid 0644 cache must not try the unavailable URL and must become runnable. +cp "$source_asset" "$cache" +/bin/chmod 644 "$cache" +download_verified "file://$TMP/unavailable" "$cache" "$expected" true || fail "offline cache was not reused" +[ -x "$cache" ] || fail "valid preseed did not become executable" + +# If a valid but non-executable cache cannot be chmod'd, fail here rather than at invocation. +/bin/chmod 644 "$cache" +chmod() { return 1; } +if err="$(download_verified "file://$TMP/unavailable" "$cache" "$expected" true 2>&1)"; then fail "unfixable cache was accepted"; fi +case "$err" in *"verified asset is not executable and cannot be chmod'd: $cache"*) ;; *) fail "wrong cache error: $err" ;; esac +unset -f chmod +/bin/chmod 755 "$cache" + +# A verified, executable cache can be owned by another user; do not try to chmod it again. +chmod() { fail "chmod called for an already executable cache"; } +download_verified "file://$TMP/unavailable" "$cache" "$expected" true || fail "executable cache was not reused" +unset -f chmod + +# The type2 runtime is read as data; a valid 0644 preseed must remain usable as-is. +runtime="$TMP/runtime" +cp "$source_asset" "$runtime" +/bin/chmod 644 "$runtime" +chmod() { fail "chmod called for a readable runtime cache"; } +download_verified "file://$TMP/unavailable" "$runtime" "$expected" || fail "readable runtime was not reused" +unset -f chmod +[ ! -x "$runtime" ] || fail "runtime was needlessly made executable" + +# A fresh runtime download is verified, readable data rather than an executable program. +fresh_runtime="$TMP/fresh-runtime" +download_verified "file://$source_asset" "$fresh_runtime" "$expected" || fail "fresh runtime download failed" +cmp -s "$source_asset" "$fresh_runtime" || fail "fresh runtime differs from verified asset" +[ -r "$fresh_runtime" ] || fail "fresh runtime is not readable" +[ ! -x "$fresh_runtime" ] || fail "fresh runtime was made executable" +mode="$(stat -c %a "$fresh_runtime" 2>/dev/null || stat -f %Lp "$fresh_runtime")" +[ "$mode" = 644 ] || fail "fresh runtime mode is $mode, expected 644" + +# A corrupt cache is replaced only by bytes with the pinned digest. +printf 'corrupt' >"$cache" +download_verified "file://$source_asset" "$cache" "$expected" true || fail "corrupt cache was not replaced" +cmp -s "$source_asset" "$cache" || fail "replacement differs from verified asset" +[ -x "$cache" ] || fail "replacement is not executable" + +wrong_digest="$(printf '%064d' 0)" +bad="$TMP/bad.AppImage" +if download_verified "file://$source_asset" "$bad" "$wrong_digest" >/dev/null 2>&1; then fail "wrong digest accepted"; fi +[ ! -e "$bad" ] || fail "wrong-digest download left a cache entry" + +verify_appimagetool_override "$cache" "$expected" 1.9.1 x86_64 || fail "valid override rejected" +/bin/chmod 644 "$cache" +if verify_appimagetool_override "$cache" "$expected" 1.9.1 x86_64 >/dev/null 2>&1; then fail "non-executable override accepted"; fi +/bin/chmod 755 "$cache" +if verify_appimagetool_override "$cache" "$wrong_digest" 1.9.1 x86_64 >/dev/null 2>&1; then fail "wrong-digest override accepted"; fi + +echo "verified AppImage tooling ok" diff --git a/packaging/linux/verified-download.sh b/packaging/linux/verified-download.sh new file mode 100644 index 000000000..75c816a61 --- /dev/null +++ b/packaging/linux/verified-download.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Source after packaging/env.sh, which supplies sha256(). Keep the verified cache usable when +# an official asset was pre-seeded by a tool that created it without an execute bit. + +download_verified() { + local url="$1" dest="$2" expected="$3" executable="${4:-false}" tmp + if [ -f "$dest" ] && [ "$(sha256 "$dest")" = "$expected" ]; then + if [ "$executable" = true ] && [ ! -x "$dest" ] && ! chmod 755 "$dest"; then + echo "error: verified asset is not executable and cannot be chmod'd: $dest" >&2 + return 1 + fi + return 0 + fi + tmp="$(mktemp "$dest.XXXXXX")" + if ! curl -fsSL -o "$tmp" "$url"; then rm -f "$tmp"; return 1; fi + if [ "$(sha256 "$tmp")" != "$expected" ]; then + echo "error: SHA-256 mismatch for $url" >&2 + rm -f "$tmp" + return 1 + fi + if [ "$executable" = true ]; then + if ! chmod 755 "$tmp"; then rm -f "$tmp"; return 1; fi + else + if ! chmod 644 "$tmp"; then rm -f "$tmp"; return 1; fi + fi + if ! mv -f "$tmp" "$dest"; then rm -f "$tmp"; return 1; fi +} + +verify_appimagetool_override() { + local tool="$1" expected="$2" version="$3" arch="$4" + if [ ! -f "$tool" ] || [ "$(sha256 "$tool")" != "$expected" ]; then + echo "error: APPIMAGETOOL must be the verified $version $arch release asset" >&2 + return 1 + fi + if [ ! -x "$tool" ]; then + echo "error: APPIMAGETOOL must be executable (chmod +x $tool)" >&2 + return 1 + fi +} diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 5eaf43558..3fa79b59f 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -85,9 +85,9 @@ note = "theme.rs ThemeKind has twelve themes (the four Pro brightness levels, St [[item]] id = "UI-217-12" target = "Save custom New Document presets; a real Recent category" -status = "partial" +status = "done" issue = 217 -note = "document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard" +note = "document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category" [[item]] id = "UI-217-13"