diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094c1f3893..bec29abaf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + # No test or fuzz run may print a page: the engine refuses to spool, and if something ever + # bypasses that, lp fails loudly on a printer that does not exist. + PHOTOCRAFT_NO_PRINT: "1" + LPDEST: photocraft-no-such-printer # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the # debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough # for the Linux job's second, craft-fonts build (it ran out of disk with full debug info). diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c2b9f87854..227aee8ac9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,6 +10,7 @@ next to them. | Path | Title | Author | Source | License | |---|---|---|---|---| +| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | | MIT (X.Org app/rgb) | | `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | @@ -22,9 +23,10 @@ next to them. | `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -36,6 +38,7 @@ next to them. | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -51,6 +54,7 @@ next to them. | `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code | | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | | `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work | +| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work | ### Optional build input: craft-fonts (not files in this repo) diff --git a/Cargo.lock b/Cargo.lock index a0499ca1c8..79e7cd895e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4220,12 +4220,14 @@ dependencies = [ "photocraft-raster", "photocraft-tablet", "photocraft-ui-egui", + "resvg", "rfd", "ron", "serde_json", "winit", "winresource", "winsafe", + "wl-clipboard-rs", "x11rb", "xcap", "zbus", diff --git a/README.md b/README.md index b6e8c5a601..5fd2650415 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗂️ Formats

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

🪄 The everyday essentials

diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 7bf7f8164d..87050381a5 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,18 +15,19 @@ pub const USAGE: &str = "\ photocraft-cli: headless Photocraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. + --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -68,17 +69,17 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--in-place", "--tiff-layers"], run: batch, }, @@ -214,6 +215,10 @@ fn export_opts(a: &Args) -> Result { if let Some(b) = a.get("--tga-bits") { o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); } + if let Some(b) = a.get("--dpx-bits") { + o.encode.dpx_bits = + Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); + } Ok(o) } @@ -433,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool { fn is_input(p: &Path) -> bool { let known = |e: &str| { - matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub") + matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) }; p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) @@ -669,6 +674,11 @@ mod missing_font_warning_tests { let (code, _, stderr) = invoke(&["convert", &ora, &png]); assert_eq!(code, 0, "{stderr}"); assert!(is_input(std::path::Path::new(&ora))); + // Krita documents are batch inputs too (import-only). + for name in ["sketch.kra", "old.KRZ"] { + std::fs::write(folder.join(name), b"").unwrap(); + assert!(is_input(&folder.join(name)), "{name}"); + } let _ = std::fs::remove_dir_all(&folder); } diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 162483bcf9..231eb2c321 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -455,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() { assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}"); } +/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the +/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049). +#[test] +fn dpx_converts_both_ways_with_the_bits_asked_for() { + let d = tmp("dpx"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] { + let out = d.join("a.dpx"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(b) = bits { + convert.args(["--dpx-bits", b]); + } + ok(&mut convert); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}"); + } + let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")); + let v: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}"); + assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back)); + assert!(back.exists()); + for bad in ["24", "0", "abc"] { + let out = d.join("bad.dpx"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}"); + assert!(!out.exists(), "--dpx-bits {bad} wrote a file"); + } +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is /// refused and nothing is written. #[test] diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 16781e42bb..8d7e8e0826 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; -/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and +/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", - "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; @@ -441,6 +441,7 @@ fn mime_for(name: &str) -> &'static str { Some("gif") => "image/gif", Some("psd" | "psb") => "image/vnd.adobe.photoshop", Some("ora") => "image/openraster", + Some("kra" | "krz") => "application/x-krita", _ => "application/octet-stream", } } diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 57b537931c..e5af663471 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -66,8 +66,14 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } [target.'cfg(target_os = "linux")'.dependencies] photocraft-cms = { workspace = true } x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } +# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for +# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly. +wl-clipboard-rs = { version = "=0.9.4", default-features = false } # Wayland desktop color selection through the existing XDG portal transport. zbus = "=5.19.0" +# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no +# text or images. Already in Cargo.lock (photocraft-io); pinned exactly. +resvg = { version = "=0.45.1", default-features = false } # Already used by the portal stack; bounded waits let cancelling close the OS request. futures-lite = "=2.6.1" async-io = "=2.6.0" diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index 8389ee83eb..aaae41a1f2 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; const PORTAL_KEY: &str = "color-scheme"; #[cfg(target_os = "linux")] -fn portal_proxy() -> Option> { +pub(crate) fn portal_proxy() -> Option> { let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() } @@ -14,7 +14,7 @@ fn portal_proxy() -> Option> { #[cfg(target_os = "linux")] fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; - decode(portal_code(value)?) + portal_code(value).and_then(decode).or_else(gtk_theme) } #[cfg(target_os = "linux")] @@ -39,8 +39,8 @@ fn decode(code: u32) -> Option { } } -/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by -/// absolute path, and is never repeated. +/// GNOME fallback when the portal has no preference. Read only on the appearance worker, +/// using an absolute path rather than the shell's PATH. #[cfg(target_os = "linux")] fn gtk_theme() -> Option { let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; @@ -55,7 +55,7 @@ fn gtk_theme() -> Option { fn parse_gtk_scheme(s: &str) -> Option { match s.trim().trim_matches('\'') { "prefer-dark" => Some(egui::Theme::Dark), - "prefer-light" => Some(egui::Theme::Light), + "prefer-light" | "default" => Some(egui::Theme::Light), _ => None, } } @@ -97,7 +97,7 @@ pub fn service() -> Option { if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { continue; } - publisher.publish(portal_code(changed).and_then(decode)); + publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme)); } }) } @@ -184,7 +184,10 @@ mod tests { assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); - assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + // GNOME's "default" appearance is light, even though the portal reports no preference. + assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None); } /// #2798: a first read that misses the start-up wait still wakes the idle UI. diff --git a/apps/photocraft/src/caption_style.rs b/apps/photocraft/src/caption_style.rs new file mode 100644 index 0000000000..5ee16f9f96 --- /dev/null +++ b/apps/photocraft/src/caption_style.rs @@ -0,0 +1,399 @@ +//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on +//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of +//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE +//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's +//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order. +//! +//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and +//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found. + +#[cfg(target_os = "linux")] +use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle}; + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Desktop { + Gnome, + Kde, + Other, +} + +/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and +/// the like count as GNOME. +#[cfg(target_os = "linux")] +fn desktop(var: &dyn Fn(&str) -> Option) -> Desktop { + for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] { + let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue }; + return if v.contains("gnome") { + Desktop::Gnome + } else if v.contains("kde") || v.contains("plasma") { + Desktop::Kde + } else { + Desktop::Other + }; + } + if var("KDE_FULL_SESSION").is_some() { + Desktop::Kde + } else if var("GNOME_DESKTOP_SESSION_ID").is_some() { + Desktop::Gnome + } else { + Desktop::Other + } +} + +/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK +/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons. +#[cfg(target_os = "linux")] +fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily { + match desktop { + Desktop::Gnome => CaptionFamily::Adwaita, + Desktop::Kde => CaptionFamily::Breeze, + Desktop::Other => { + let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase(); + if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) { + CaptionFamily::Adwaita + } else if theme.starts_with("breeze") { + CaptionFamily::Breeze + } else { + CaptionFamily::Generic + } + } + } +} + +/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`). +#[cfg(target_os = "linux")] +fn ini_value(text: &str, section: &str, key: &str) -> Option { + let mut inside = false; + for line in text.lines().map(str::trim) { + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + inside = name == section; + } else if inside + && let Some((k, v)) = line.split_once('=') + && k.trim() == key + { + return Some(v.trim().to_string()); + } + } + None +} + +/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a +/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's +/// defaults: the window menu on the left, minimize, maximize and close on the right. +#[cfg(target_os = "linux")] +fn kwin_layout(kwinrc: &str) -> String { + let side = |key: &str, default: &str| { + let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string()); + letters + .chars() + .filter_map(|c| match c { + 'I' => Some("minimize"), + 'A' => Some("maximize"), + 'X' => Some("close"), + _ => None, + }) + .collect::>() + .join(",") + }; + format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX")) +} + +/// What the style is worked out from. +#[cfg(target_os = "linux")] +#[derive(Clone, Default)] +struct Found { + gtk_theme: Option, + /// A GTK decoration layout. + layout: Option, + icon_theme: Option, +} + +/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own), +/// the desktop's setting, or its default theme. +#[cfg(target_os = "linux")] +fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option) -> Option { + match var("PHOTOCRAFT_CAPTION_ICONS") { + Some(name) if name.trim().eq_ignore_ascii_case("none") => None, + Some(name) => Some(name.trim().to_string()), + None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())), + } +} + +/// The style from what was found; `icons` loads a theme's glyphs. +#[cfg(target_os = "linux")] +fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option, icons: &dyn Fn(&str) -> Option) -> CaptionStyle { + let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref())); + // GNOME's own default shows Close only; GTK's shows all three. + let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" }; + let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string()); + let mut style = CaptionStyle::from_gtk_layout(family, &layout); + style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name)); + style +} + +#[cfg(target_os = "linux")] +fn read_text(path: &std::path::Path) -> Option { + crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned()) +} + +/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's +/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them). +#[cfg(target_os = "linux")] +fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option) -> Found { + let config = var("XDG_CONFIG_HOME") + .filter(|d| std::path::Path::new(d).is_absolute()) + .map(std::path::PathBuf::from) + .or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config"))); + let Some(config) = config else { return Found::default() }; + let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default(); + let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name"); + if desktop == Desktop::Kde { + let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default())); + let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme"); + Found { gtk_theme, layout, icon_theme } + } else { + let layout = ini_value(>k, "Settings", "gtk-decoration-layout"); + Found { gtk_theme, layout, icon_theme: ini_value(>k, "Settings", "gtk-icon-theme-name") } + } +} + +/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at +/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them. +#[cfg(target_os = "linux")] +fn load_icons(theme: &str) -> Option { + use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small}; + let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?; + let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from); + let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")]; + if files.iter().all(Option::is_none) { + return None; + } + let render = move |icon: CaptionIcon, px: u32| { + let i = match icon { + CaptionIcon::Minimize => 0, + CaptionIcon::Maximize => 1, + CaptionIcon::Restore => 2, + CaptionIcon::Close => 3, + }; + files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px)) + }; + Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) }) +} + +/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct IconCache(std::sync::Mutex)>>); + +#[cfg(target_os = "linux")] +impl IconCache { + fn get(&self, theme: &str) -> Option { + let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((name, icons)) = last.as_ref() + && name == theme + { + return icons.clone(); + } + let icons = load_icons(theme); + *last = Some((theme.to_string(), icons.clone())); + icons + } +} + +#[cfg(target_os = "linux")] +const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences"; +#[cfg(target_os = "linux")] +const LAYOUT_KEY: &str = "button-layout"; +#[cfg(target_os = "linux")] +const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface"; +#[cfg(target_os = "linux")] +const ICONS_KEY: &str = "icon-theme"; + +/// A string setting from the portal, unwrapping the variants some portals nest it in. +#[cfg(target_os = "linux")] +fn portal_string(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + zbus::zvariant::Value::Str(s) => return Some(s.to_string()), + _ => return None, + } + } + None +} + +/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings, +/// following the button layout and icon theme as they change. A worker does the file and D-Bus +/// work and wakes the UI only when the style changes; the first answer usually lands before the +/// first frame. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{Arc, Mutex, OnceLock, PoisonError}; + let var = |k: &str| std::env::var(k).ok(); + let desktop = desktop(&var); + let mut found = from_files(desktop, &var); + // Icons need the theme read, which takes tens of milliseconds: the worker brings them. + let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None))); + let wake: Arc> = Arc::new(OnceLock::new()); + let (shared, waker) = (Arc::clone(¤t), Arc::clone(&wake)); + let cache = IconCache::default(); + let publish = move |found: &Found| { + let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t)); + let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner); + if *cur != style { + *cur = style; + if let Some(ctx) = waker.get() { + ctx.request_repaint(); + } + } + }; + let (ready, wait) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || { + let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None }; + let Some(proxy) = proxy else { + publish(&found); + let _ = ready.send(()); + return; + }; + // Subscribe before the first read, so a change during start-up isn't lost. + let signals = proxy.receive_signal("SettingChanged").ok(); + let read = |namespace: &str, key: &str| { + let value: Option = proxy.call("Read", &(namespace, key)).ok(); + value.and_then(portal_string) + }; + found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take()); + found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take()); + publish(&found); + let _ = ready.send(()); + for message in signals.into_iter().flatten() { + let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue }; + match (namespace.as_str(), key.as_str()) { + (WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value), + (INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value), + _ => continue, + } + publish(&found); + } + }); + // A fast desktop answers before the first frame; a slow one updates the bar later. + if spawned.is_ok() { + let _ = wait.recv_timeout(std::time::Duration::from_millis(150)); + } + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + current.lock().unwrap_or_else(PoisonError::into_inner).clone() + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use photocraft_ui_egui::Caption::{Close, Maximize, Minimize}; + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone()) + } + + #[test] + fn the_desktop_comes_from_the_first_variable_set() { + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome); + assert_eq!(desktop(&env(&[])), Desktop::Other); + } + + #[test] + fn the_family_follows_the_desktop_then_the_gtk_theme() { + assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze); + assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze); + assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic); + assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic); + } + + #[test] + fn kwin_button_letters_become_a_gtk_layout() { + assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults"); + let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n"; + assert_eq!(kwin_layout(rc), "close,minimize,maximize:"); + assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze")); + assert_eq!(ini_value(rc, "nope", "library"), None); + // Garbage is just no buttons on that side, never a panic. + assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close"); + } + + fn no_icons(_: &str) -> Option { + None + } + + #[test] + fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() { + let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() }; + assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus")); + assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita")); + assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze")); + assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None); + assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela")); + // The style carries what the loader returns for that name. + let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| { + (t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) }) + }); + assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus")); + } + + #[test] + fn layouts_and_overrides_resolve_to_a_style() { + let none = env(&[]); + let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons); + assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout"); + let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons); + assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close])); + let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None }; + let other = resolve(Desktop::Other, &found, &none, &no_icons); + assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![])); + let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]); + let s = resolve(Desktop::Gnome, &found, &forced, &no_icons); + assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize])); + // An unknown override name falls back to what was found. + assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze); + } + + #[test] + fn portal_strings_unwrap_nested_variants() { + use zbus::zvariant::{OwnedValue, Value}; + let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close"))))); + assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close")); + assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None); + } + + #[test] + fn settings_files_are_read_from_the_config_dir() { + let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id())); + std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap(); + std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap(); + std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap(); + std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap(); + let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]); + let other = from_files(Desktop::Other, &var); + assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:"))); + let kde = from_files(Desktop::Kde, &var); + assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark"))); + // A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found. + assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index b1282419d8..258d89e1ec 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -12,7 +12,7 @@ use photocraft_automation::security::{ ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; -use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error}; +use photocraft_ui_egui::control::REPLY_DEADLINE; use serde_json::{Value, json}; pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver { @@ -93,12 +93,13 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: let deadline = Instant::now() + REPLY_DEADLINE; let (mut req, rrx) = ControlRequest::new(method, params); req.deadline = Some(deadline); + let dispatch = req.dispatch.clone(); if tx.send(req).is_err() { break; } ctx.request_repaint(); let mut r = - rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()})); + rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()})); if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 4a612d3a0e..bef5bb41f6 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -672,7 +672,7 @@ mod tests { } else { s.finish(); } - let (prev, _s) = Sentinel::begin(&dir); + let (prev, _s) = begin_when_unlocked(&dir); assert_eq!(prev, Previous::Clean); assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto); let _ = std::fs::remove_dir_all(&dir); @@ -684,6 +684,22 @@ mod tests { d } + /// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns + /// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child + /// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test + /// drops its sentinel. + fn begin_when_unlocked(dir: &Path) -> (Previous, Option) { + let (mut prev, mut s) = Sentinel::begin(dir); + for _ in 0..50 { + if prev != Previous::Busy { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + (prev, s) = Sentinel::begin(dir); + } + (prev, s) + } + #[test] fn sentinel_detects_a_crash_and_clears_on_success() { let dir = temp_dir("cycle"); @@ -699,16 +715,7 @@ mod tests { assert!(none.is_none()); // The process dies in the driver: the lock goes, the marker stays. drop(s); - // Another test in this binary spawns a process: between its fork and exec the child briefly - // shares this file's flock, so `begin` can see the lock as busy. Retry for a moment. - let (mut prev, mut s) = Sentinel::begin(&dir); - for _ in 0..50 { - if prev != Previous::Busy { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - (prev, s) = Sentinel::begin(&dir); - } + let (prev, s) = begin_when_unlocked(&dir); let m = prev.crashed().cloned().expect("crash detected"); assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan))); // This start succeeds: the next one finds nothing. @@ -721,6 +728,29 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn begin_when_unlocked_waits_for_a_briefly_held_lock() { + let dir = temp_dir("shared-lock"); + let (_, s) = Sentinel::begin(&dir); + let mut s = s.expect("sentinel"); + s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap(); + // After the start dies, a second handle on the marker holds the lock for 50 ms, as a + // spawned child's copy of the descriptor does until the child calls exec. + let held = File::open(dir.join(MARKER_FILE)).unwrap(); + drop(s); + held.lock().unwrap(); + assert_eq!(Sentinel::begin(&dir).0, Previous::Busy); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + held.unlock().unwrap(); + }); + let (prev, s) = begin_when_unlocked(&dir); + release.join().unwrap(); + assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan")); + s.expect("sentinel").finish(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn returned_non_graphics_errors_do_not_trigger_backend_recovery() { let cases = [ @@ -738,7 +768,7 @@ mod tests { } else { s.finish(); } - let (previous, s) = Sentinel::begin(&dir); + let (previous, s) = begin_when_unlocked(&dir); assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}"); if !keep { assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan); diff --git a/apps/photocraft/src/icon_theme.rs b/apps/photocraft/src/icon_theme.rs new file mode 100644 index 0000000000..cf2ede3b7b --- /dev/null +++ b/apps/photocraft/src/icon_theme.rs @@ -0,0 +1,329 @@ +//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the +//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits, +//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG +//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour. + +use std::path::{Path, PathBuf}; + +/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a +/// theme lists, and the bytes read from one file. +const MAX_THEMES: usize = 32; +const MAX_DIRS: usize = 4096; +/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`). +const SCAN_DEPTH: usize = 3; +const MAX_FILE: u64 = 1 << 20; +/// The size the caption glyphs are drawn at, in the spec's units. +const ICON_SIZE: i64 = 16; + +/// A theme or icon name that stays inside its directory. +pub fn valid_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) +} + +/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small). +pub fn read_small(path: &Path) -> Option> { + use std::io::Read; + let mut bytes = vec![]; + std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?; + Some(bytes) +} + +/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each +/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says. +pub fn base_dirs(var: &dyn Fn(&str) -> Option) -> Vec { + let absolute = |p: &String| Path::new(p).is_absolute(); + let home = var("HOME").filter(absolute).map(PathBuf::from); + let mut dirs = vec![]; + if let Some(home) = &home { + dirs.push(home.join(".icons")); + } + if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) { + dirs.push(data.join("icons")); + } + let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons"))); + dirs +} + +/// One `[section]` of an `index.theme`, as (key, value) pairs. +fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> { + let mut inside = false; + let mut pairs = vec![]; + for line in index.lines().map(str::trim) { + if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + if inside { + break; + } + inside = s == name; + } else if inside && let Some((k, v)) = line.split_once('=') { + pairs.push((k.trim(), v.trim())); + } + } + pairs +} + +fn list(value: Option<&str>) -> impl Iterator { + value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty()) +} + +/// How far a theme directory is from a 16 px icon at scale 1 (the spec's +/// `DirectorySizeDistance`), then its scale: 0 is an exact fit. +fn distance(keys: &[(&str, &str)]) -> (i64, i64) { + let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v); + let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::().ok()).unwrap_or(default).clamp(0, 1 << 16); + let size = num("Size", 0); + let scale = num("Scale", 1).max(1); + let d = match get("Type").unwrap_or("Threshold") { + "Fixed" => (size * scale - ICON_SIZE).abs(), + "Scalable" => { + let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale); + (min - ICON_SIZE).max(ICON_SIZE - max).max(0) + } + _ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0), + }; + (d, scale) +} + +/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in +/// path order: scalable and symbolic ones first, as icons there fit any size. +fn subdirectories(root: &Path) -> Vec { + let mut found = vec![]; + let mut level = vec![root.to_path_buf()]; + for _ in 0..SCAN_DEPTH { + let mut next = vec![]; + for dir in &level { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + for entry in entries.flatten().take(MAX_DIRS) { + let path = entry.path(); + if found.len() + next.len() < MAX_DIRS && path.is_dir() { + next.push(path); + } + } + } + found.extend(next.iter().cloned()); + level = next; + } + let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic"))); + found.sort_by_key(|p| (vector(p), p.clone())); + found +} + +/// One theme of a chain: its listed directories, best fit first, in every base directory that has +/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot. +struct Theme { + roots: Vec, + listed: Vec, + unlisted: std::cell::OnceCell>, +} + +impl Theme { + /// The directories the index forgot to list: GTK still finds icons there, and custom themes + /// often add `symbolic/ui` without listing it. + fn unlisted(&self) -> &[PathBuf] { + self.unlisted.get_or_init(|| { + let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect(); + self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect() + }) + } +} + +/// A theme and the themes it inherits, in search order. +pub struct IconTheme { + themes: Vec, +} + +impl IconTheme { + /// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an + /// `index.theme`. + pub fn load(name: &str, bases: &[PathBuf]) -> Option { + let mut chain: Vec = vec![name.to_string()]; + let mut themes = vec![]; + let mut i = 0; + loop { + let Some(theme) = chain.get(i).cloned() else { + // Every chain ends in hicolor, whether or not the themes before it exist. + if chain.iter().any(|t| t == "hicolor") { + break; + } + chain.push("hicolor".into()); + continue; + }; + i += 1; + if !valid_name(&theme) { + continue; + } + let roots: Vec = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect(); + // The first index wins; a theme can still be spread over several base directories. + let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue }; + let index = String::from_utf8_lossy(&index); + let head = section(&index, "Icon Theme"); + let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v); + let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories")) + .chain(list(get("ScaledDirectories"))) + .filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty())) + .take(MAX_DIRS) + .map(|d| (distance(§ion(&index, d)), d)) + .collect(); + subdirs.sort_by_key(|(key, _)| *key); + // Not checked for existence: a theme lists hundreds, and the search stops at a hit. + let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect(); + for parent in list(get("Inherits")) { + if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) { + chain.push(parent.to_string()); + } + } + themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() }); + } + (!themes.is_empty()).then_some(Self { themes }) + } + + /// The first `.svg` in search order: each theme's listed directories, then its unlisted + /// ones, before its parent. + pub fn find_svg(&self, icon: &str) -> Option { + if !valid_name(icon) { + return None; + } + let file = format!("{icon}.svg"); + let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file()); + self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted()))) + } +} + +/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an +/// unreadable SVG or an absurd size. +pub fn rasterize_mask(svg: &[u8], px: u32) -> Option> { + use resvg::{tiny_skia, usvg}; + if !(1..=256).contains(&px) { + return None; + } + // Glyphs are paths: no `` is loaded, so an icon can't make us read another file + // (`/dev/zero`, a huge picture) or nest an SVG. + let opts = usvg::Options { + image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) }, + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_data(svg, &opts).ok()?; + let size = tree.size(); + let side = size.width().max(size.height()); + if !side.is_finite() || side <= 0.0 { + return None; + } + let s = px as f32 / side; + let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); + let mut pixmap = tiny_skia::Pixmap::new(px, px)?; + resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); + Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A throwaway icon tree: `files` are (path under the base, contents). + fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf { + let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for (path, text) in files { + let p = base.join(path); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + base + } + + const SQUARE: &str = r##""##; + + #[test] + fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() { + let base = tree( + "chain", + &[ + ("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"), + ("Child/48/a-symbolic.svg", "big"), + // Not in the index: still found, and before the parent's copy. + ("Child/symbolic/ui/d-symbolic.svg", SQUARE), + ("Parent/ui/d-symbolic.svg", SQUARE), + ("Child/16/a-symbolic.svg", SQUARE), + ("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"), + ("Parent/ui/b-symbolic.svg", SQUARE), + ("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"), + ("hicolor/s/c-symbolic.svg", SQUARE), + ], + ); + let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads"); + assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px"); + assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg"))); + assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg"))); + assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own"); + assert_eq!(theme.find_svg("missing"), None); + assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None); + assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers"); + assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none()); + let _ = std::fs::remove_dir_all(base); + } + + /// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic. + #[test] + fn cyclic_or_hostile_indexes_are_harmless() { + let base = tree( + "hostile", + &[ + ( + "A/index.theme", + "[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n", + ), + ("A/ok/x.svg", SQUARE), + ("B/index.theme", "[Icon Theme]\nInherits=A\n"), + ], + ); + let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads"); + assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg"))); + assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn distance_follows_the_spec() { + assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1)); + assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1)); + assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2)); + assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1)); + assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1)); + assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default"); + } + + #[test] + fn masks_are_square_coverage() { + let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders"); + assert_eq!(mask.len(), 32 * 32); + assert_eq!(mask[16 * 32 + 16], 255, "inside the square"); + assert_eq!(mask[0], 0, "outside it"); + assert!(rasterize_mask(b"not svg", 16).is_none()); + assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none()); + assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none()); + } + + /// An icon's `` is never loaded: not a device file, not an embedded picture. + #[test] + fn masks_load_no_images() { + let svg = r##" + + + "##; + let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders"); + assert!(mask.iter().all(|a| *a == 0), "nothing drawn"); + } + + #[test] + fn base_dirs_follow_xdg() { + let var = |k: &str| match k { + "HOME" => Some("/home/u".to_string()), + "XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()), + _ => None, + }; + let dirs = base_dirs(&var); + let want: Vec = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect(); + assert_eq!(dirs, want); + assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]); + } +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 00d334758b..938fc1f032 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -26,11 +26,14 @@ mod appearance; #[cfg(target_os = "macos")] mod apple_events; mod caps_lock; +mod caption_style; mod control_server; mod crash_guard; mod cursor; mod file_dialog_probe; mod gpu_startup; +#[cfg(target_os = "linux")] +mod icon_theme; #[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] @@ -492,6 +495,10 @@ fn main() -> eframe::Result { // The custom title bar's undecorated window must stay borderless, or its caption and // border offset the content and the pointer on some Windows systems (#2246). app.services.window_frame = window_frame::service(cc, custom_titlebar); + // Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them. + if custom_titlebar { + app.services.caption_style = caption_style::service(); + } // Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the // precise crosshair for painting tools while it is toggled on (#1758). app.services.caps_lock = caps_lock::service(cc); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index fea0d34541..d4a3869f31 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -16,13 +16,13 @@ use std::sync::Arc; #[cfg(test)] mod recovery_tests; -/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and +/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", - "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", + "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -43,7 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> { ("All Formats", open_filter_extensions(OPEN_EXTS)), ("PhotoCraft", open_filter_extensions(&["pcraft"])), ("OpenRaster", open_filter_extensions(&["ora"])), - ("Krita", open_filter_extensions(&["kra"])), + ("Krita", open_filter_extensions(&["kra", "krz"])), ("SVG", open_filter_extensions(SVG_EXTS)), ] } @@ -60,8 +60,13 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("WebP", &["webp"]), ("TIFF", &["tif"]), ("Targa", &["tga"]), + // Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha. + ("BMP", &["bmp"]), + ("GIF", &["gif"]), + ("ICO", &["ico"]), ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), + ("DPX", &["dpx"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog @@ -77,8 +82,11 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { let mut v: Vec<(String, Vec)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect(); match v.iter().position(|(_, exts)| exts.contains(&ext)) { Some(i) => { - let f = v.remove(i); - v.insert(0, f); + let (name, mut exts) = v.remove(i); + // Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`. + exts.retain(|e| *e != ext); + exts.insert(0, ext); + v.insert(0, (name, exts)); } None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])), None => {} @@ -86,6 +94,21 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { v } +/// The name the save dialog starts with. On Windows a visible extension stays put when the user +/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed +/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog +/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A +/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case +/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the +/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked. +fn dialog_file_name(suggested: &str, windows: bool) -> Option { + let path = Path::new(suggested); + let name = path.file_name()?.to_string_lossy().into_owned(); + let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) }; + let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase(); + Some(if bare { stem.to_string_lossy().into_owned() } else { name }) +} + /// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog /// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned /// modal window elsewhere), and a helper thread waits for it and hands the answer to the app. @@ -127,8 +150,8 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, for (name, exts) in save_filters(&suggested) { dialog = dialog.add_filter(name, &exts); } - if let Some(name) = Path::new(&suggested).file_name() { - dialog = dialog.set_file_name(name.to_string_lossy()); + if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) { + dialog = dialog.set_file_name(name); } if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) { dialog = dialog.set_directory(dir); @@ -325,6 +348,43 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec)> { }) } +/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real +/// screenshot is turned away, while a broken or hostile source can't make us allocate without end. +#[cfg(target_os = "linux")] +const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384; + +/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png` +/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or +/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control +/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland. +#[cfg(target_os = "linux")] +fn wayland_clipboard_image() -> Option<(u32, u32, Vec)> { + use std::io::Read; + use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered}; + let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?; + image_from_offers(&offers, |mime| { + let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?; + let mut bytes = Vec::new(); + pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes) + }) +} + +/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image, +/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred. +#[cfg(any(target_os = "linux", test))] +fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option>) -> Option<(u32, u32, Vec)> { + offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| { + let bytes = fetch(mime)?; + // TGA has no magic number (see `image_from_files`), so trust it only when the type says so. + if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") { + return None; + } + let img = photocraft_codecs::decode(&bytes).ok()?; + Some((img.width(), img.height(), img.to_rgba8())) + }) +} + /// The shell command that starts this install under XWayland, where winit delivers file drops /// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland /// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH. @@ -425,8 +485,13 @@ pub fn native(automation: Option) -> if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) { return Some(img); } - let img = cb.get_image().ok()?; - Some((img.width as u32, img.height as u32, img.bytes.into_owned())) + if let Ok(img) = cb.get_image() { + return Some((img.width as u32, img.height as u32, img.bytes.into_owned())); + } + #[cfg(target_os = "linux")] + return wayland_clipboard_image(); + #[cfg(not(target_os = "linux"))] + None }) }), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), @@ -664,11 +729,66 @@ mod tests { .join(" "); assert_eq!(exts, want.split(' ').collect::>()); for (name, ext) in asked.iter().zip(exts) { - assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]); + // The first type's first extension is the one Windows adds to the bare name. + assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]); } assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default"); } + /// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357). + #[test] + fn save_as_offers_only_writable_formats_including_ico() { + let layered = ["psd", "psb", "pcraft", "ora"]; + for (name, exts) in SAVE_FILTERS { + for ext in exts.iter().filter(|e| !layered.contains(e)) { + let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format")); + assert!(format.caps().write, "{name}: .{ext} can't be written"); + } + } + for ext in ["ico", "gif", "bmp"] { + assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered"); + } + assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type"); + // What Save As writes there: an icon up to 256×256, and a clear error past that. + let doc = |side: u32| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": side, "height": side})).unwrap(); + s.active().unwrap().doc.clone() + }; + let opts = export_options(&photocraft_ui_egui::ExportSettings::default()); + let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap(); + assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header"); + let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string(); + assert!(err.contains("256"), "{err}"); + } + + /// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to + /// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked + /// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's. + #[test] + fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() { + for (suggested, name, first) in [ + ("/pics/image.psd", "image", "psd"), + ("/pics/image.psb", "image", "psb"), + ("/pics/image.pcraft", "image", "pcraft"), + ("/pics/sky.fit", "sky", "fit"), + ("cat.jpeg", "cat", "jpeg"), + ("swatches.aco", "swatches", "aco"), + ] { + assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}"); + assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}"); + // Elsewhere the dialogs keep the whole name (see `dialog_file_name`). + let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned(); + assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}"); + } + // Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no + // extension, and a dotfile. + for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] { + assert_eq!(dialog_file_name(name, true).as_deref(), Some(name)); + } + assert_eq!(dialog_file_name("", true), None); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); @@ -887,6 +1007,51 @@ mod tests { assert!(image_from_files(&[bad]).is_none()); } + fn encoded(format: photocraft_codecs::Format, w: u32, h: u32) -> Vec { + let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap(); + photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap() + } + + #[test] + fn non_png_clipboard_images_paste() { + // #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing. + let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2); + let offers = vec!["text/plain".to_string(), "image/bmp".to_string()]; + let mut asked = Vec::new(); + let got = image_from_offers(&offers, |mime| { + asked.push(mime.to_string()); + Some(bmp.clone()) + }); + assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6)))); + assert_eq!(asked, ["image/bmp"], "text offers are never fetched"); + + let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8); + let offers = vec!["image/jpeg".to_string()]; + assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8))); + } + + #[test] + fn undecodable_clipboard_offers_fall_through_to_the_next() { + let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2); + let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec(); + let offers: Vec = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec(); + let got = image_from_offers(&offers, |mime| match mime { + "image/webp" => None, + "image/png" => Some(cut.clone()), + "image/x-tga" => Some(b"not an image".to_vec()), + _ => Some(bmp.clone()), + }); + assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2))); + assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none()); + assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none()); + } + + #[test] + fn clipboard_tga_is_trusted_only_by_its_type() { + assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255]))); + assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none()); + } + #[test] fn tga_is_trusted_only_with_its_extension() { let dir = temp("tga"); diff --git a/assets/icons/contrast.svg b/assets/icons/contrast.svg index 334f528170..86687392f2 100644 --- a/assets/icons/contrast.svg +++ b/assets/icons/contrast.svg @@ -10,5 +10,5 @@ stroke-linejoin="round" > - + diff --git a/assets/icons/gradient.svg b/assets/icons/gradient.svg new file mode 100644 index 0000000000..8567fc675e --- /dev/null +++ b/assets/icons/gradient.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 7bca7f2fc2..2e65d51550 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -20,6 +20,15 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through +the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed +that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile; +invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains +8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency +over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use +the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink +separations, black generation or native CMYK blending, and is not a claim of Photoshop parity. + ## Implemented decode limits `DecodeOptions::default()` applies `Limits` before decoded pixel allocation: diff --git a/contributors/people.toml b/contributors/people.toml index 9377474c8e..bbda6719ec 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -133,3 +133,24 @@ sync_github_name = true [people.lucAmbr0] sync_github_name = true + +[people.Hevarh1] +sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true + +[people.deadmeu] +sync_github_name = true + +[people.megaDon101] +display_name = "megadon" +sync_github_name = false + +[people.sssehnsuchttt] +sync_github_name = true + +[people.VanTan666] +display_name = "VanTan" +sync_github_name = false diff --git a/crates/affinity/README.md b/crates/affinity/README.md index a88b85bd4d..4f0f9afe52 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -16,7 +16,7 @@ Affinity files. | Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) | | Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) | | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) | -| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers | +| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers | | Compound shapes (`Comp`, add and subtract) | one even-odd shape | | A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom | | Vector masks (`AdCh` curves and shapes) | vector masks | @@ -34,7 +34,7 @@ Affinity files. Everything else is reported in the import warnings, one line per kind with a count, never dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes, transparency gradients, fill opacity, bitmap fills, master pages, unrecognized -parametric shapes (imported as their bounding ellipses), corner types other than round, stars with +parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (one of each is used), strokes behind the fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index 5e825fa850..c7751da885 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> let class = r.s.class(shape)?; let sub = match &class.0.to_be_bytes() { b"ShNR" | b"ShRR" => { - let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty()); - let radius = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0), - _ => None, + let types = match last(r, shape, b"CTyp") { + Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), + _ => Vec::new(), }; - if let (true, Some(f)) = (rounded, radius) { - if let Some(Value::Array(types)) = last(r, shape, b"CTyp") - && types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. })) - { - r.warn("rectangles with straight, concave or other corner types (imported with round corners)"); - } - let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) { - // An absolute radius is in document pixels, whatever the node's scale. - let [a, b2, c, d, ..] = world.0; - let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); - if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) } - } else { - (f * w.min(h), f * w.min(h)) - }; - rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0)) - } else { + let radii = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), + _ => Vec::new(), + }; + // Corners follow the first one (top left) unless they were unlocked; then each has + // its own radius and type, clockwise from the top left. + let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false))); + let corners: [(f64, Option); 4] = std::array::from_fn(|i| { + let i = if locked { 0 } else { i }; + (radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten()) + }); + if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) { polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])? + } else { + if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) { + r.warn("rectangles with concave or other corner types (imported with round corners)"); + } + let absolute = r.s.bool(shape, b"AbSz") == Some(true); + let [a, b2, c, d, ..] = world.0; + let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); + cornered_rect( + b, + corners.map(|(f, t)| { + let (crx, cry) = if !absolute { + (f * w.min(h), f * w.min(h)) + } else if sx > 0.0 && sy > 0.0 { + // An absolute radius is in document pixels, whatever the node's scale. + (f / sx, f / sy) + } else { + (0.0, 0.0) + }; + (crx, cry, t == Some(1)) + }), + ) } } b"ShpE" => ellipse(cx, cy, rx, ry), @@ -115,13 +131,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> }))? } b"ShPi" => { - let start = num(r, shape, b"AngS").unwrap_or(0.0); - let mut end = num(r, shape, b"AngE").unwrap_or(TAU); - while end < start { - end += TAU; - } + let raw_start = num(r, shape, b"AngS").unwrap_or(0.0); + let raw_end = num(r, shape, b"AngE").unwrap_or(TAU); + // Normalize in constant time. Repeatedly adding TAU can hang on finite values + // large enough that floating-point addition no longer changes the endpoint. + let start = raw_start.rem_euclid(TAU); + let delta = raw_end - raw_start; + let sweep = if raw_end >= raw_start { + delta.min(TAU) + } else if delta.is_finite() { + delta.rem_euclid(TAU) + } else { + (raw_end.rem_euclid(TAU) - start).rem_euclid(TAU) + }; let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0); - pie(cx, cy, rx, ry, start, end.min(start + TAU), inner) + pie(cx, cy, rx, ry, start, start + sweep, inner) } b"ShpT" => { let p = num(r, shape, b"Pos ").unwrap_or(0.5); @@ -346,22 +370,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath { } } -fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath { - let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry); - SubPath { - start: pt(x0 + rx, y0), - segments: vec![ - [pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)], - [pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)], - [pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)], - [pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)], - [pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)], - [pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)], - [pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)], - [pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)], - ], - closed: true, +/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded, +/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along +/// one side shrink in proportion until they do. +fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath { + let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r }; + let [tl, tr, br, bl] = corners; + let (w, h) = (x1 - x0, y1 - y0); + let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)]; + let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)]; + let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r)); + // (where the corner leaves the previous side, its two handles, where it joins the next side) + let arcs = [ + [pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])], + [pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)], + [pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])], + [pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)], + ]; + let mut segments = Vec::with_capacity(8); + for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) { + segments.push([s, s, s]); + if s != e { + segments.push(if straight { [e, e, e] } else { [c1, c2, e] }); + } } + SubPath { start: pt(x0 + rx[0], y0), segments, closed: true } } /// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are), diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index f70648b42d..b28e30f3d6 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -349,3 +349,113 @@ mod archive { } } } + +mod shapes { + use crate::container::Limits; + use crate::model::{Document, Kind, Path, Point, SubPath}; + use crate::stream::Tag; + use crate::synth::{self, F, Method, tag}; + + fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))], + ) + } + + /// Reads a document whose one page holds the object `top` from `definitions`, on a thread of + /// its own, and fails the test when reading takes more than 20 s. + fn read(definitions: Vec, top: u32) -> Document { + let data = synth::stream(&[ + (tag(b"Defs"), F::Shared(definitions)), + ( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))], + )]), + )], + ), + ), + ]); + let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap() + } + + fn path(document: &Document) -> &Path { + match &document.spreads[0].nodes[0].kind { + Kind::Shape { path, .. } => path, + other => panic!("expected shape, got {other:?}"), + } + } + + #[test] + fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() { + let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100); + for subpath in &path(&document).subpaths { + assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite()); + assert!(subpath.segments.len() <= 20); + assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite())); + } + } + + #[test] + fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() { + let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100); + assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2))); + } + + /// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline. + fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec) { + let node = + F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]); + let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap(); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") }; + (path.subpaths[0].clone(), doc.warnings) + } + + /// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left). + fn corner_types(ids: [u16; 4]) -> F { + let mut raw = vec![0xaa]; + raw.extend(tag(b"CTyp").0.to_le_bytes()); + raw.extend(4u32.to_le_bytes()); + raw.extend(0u16.to_le_bytes()); + raw.extend(ids.iter().flat_map(|id| id.to_le_bytes())); + F::Raw(raw) + } + + #[test] + fn unlocked_rectangle_corners_keep_their_own_radius_and_type() { + let p = |x: f64, y: f64| Point { x, y }; + // Relative radii scale by 10. Round 5, round 1, straight 3, none. + let corners = |lock: Option| { + let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))]; + fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l)))); + rectangle(fields) + }; + + let (sub, warnings) = corners(Some(false)); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(sub.start, p(5.0, 0.0)); + let ends: Vec = sub.segments.iter().map(|s| s[2]).collect(); + assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]); + assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line"); + assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve"); + + // Locked (the default): every corner follows the top left one. + let (sub, _) = corners(None); + assert_eq!(sub.segments.len(), 8); + assert_eq!(sub.segments[3][2], p(5.0, 20.0)); + } +} diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index 7f75041b10..4d2e85ed1a 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -427,7 +427,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 { F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2, F::DarkStrokes => 9, F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3, - F::Spatter => g("sprayRadius").ceil() as i32 + 2, + F::Spatter => (g("sprayRadius") * 1.5).ceil() as i32 + 2, F::SprayedStrokes => g("sprayRadius").ceil() as i32 + (g("strokeLength") / 2.0) as i32 + 3, F::SumiE => gs(g("strokeWidth") * 0.2) + (g("strokeWidth") / 2.0) as i32 + 2, F::Glass => (g("distortion") * 2.0).ceil() as i32 + 2, @@ -768,9 +768,26 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { win.c[i] = map(win.c[i], |v| (v + (1.0 - v) * lt) * (1.0 - dk) * (1.0 - clamp01(ink))); } } - F::Spatter | F::SprayedStrokes => { + F::Spatter => { + // Smoothness smooths the random displacement: a larger feature size and, as with any + // averaged noise, a smaller reach. Low smoothness sprays specks out to the full + // radius; high smoothness leaves a torn but attached, hard edge (what Photoshop + // shows at Smoothness 15). let rad = g("sprayRadius"); - let sc = if e.filter == F::Spatter { 0.6 + g("smoothness") * 0.25 } else { 1.2 }; + let feature = 1.0 + g("smoothness") * 0.35; + let reach = (rad * 1.2 / feature).min(rad); + let src = Win { r: win.r, w, h, c: win.c.clone() }; + for i in 0..win.c.len() { + let (x, y) = win.xy(i); + let (fx, fy) = (x as f32 / feature, y as f32 / feature); + let dx = (fbm(fx, fy, 91) - 0.5) * 3.0 * reach; + let dy = (fbm(fx, fy, 92) - 0.5) * 3.0 * reach; + win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy); + } + } + F::SprayedStrokes => { + let rad = g("sprayRadius"); + let sc = 1.2; let src = Win { r: win.r, w, h, c: win.c.clone() }; for i in 0..win.c.len() { let (x, y) = win.xy(i); @@ -779,9 +796,7 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { let dy = (vnoise(fx, fy, 92) - 0.5) * 2.0 * rad; win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy); } - if e.filter == F::SprayedStrokes { - line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize); - } + line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize); } F::SumiE => { let sw = g("strokeWidth"); diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 5000ad2e7c..ecdb308624 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -662,6 +662,26 @@ mod tests { assert!(err < 0.12, "mean error {err}"); } + /// A vertical sky gradient (each row one colour) filled at every adaptation level stays the + /// gradient: no tinted blotch where the copied rows' colours were shifted per channel. + #[test] + fn color_adaptation_leaves_a_smooth_gradient_untinted() { + let (w, h) = (120, 60); + let row = |y: usize| { + let t = y as f32 / (h - 1) as f32; + [0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t] + }; + let img: Vec = (0..w * h).flat_map(|i| row(i / w)).collect(); + let hole: Vec = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect(); + let src = vec![true; w * h]; + for level in ["none", "default", "high", "veryHigh"] { + let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() }); + let (out, img) = (&out, &img); + let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max); + assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0); + } + } + /// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied. fn noise(w: usize, h: usize, seed: u64) -> Vec { let mut z = seed; diff --git a/crates/algo/src/erase.rs b/crates/algo/src/erase.rs index 6f644d1191..75b2a4246c 100644 --- a/crates/algo/src/erase.rs +++ b/crates/algo/src/erase.rs @@ -18,7 +18,8 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 }; let bbox = region.bbox; let bw = bbox.width() as usize; - if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize { + let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY }; + if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels { return Rect::EMPTY; } let fmt = target.format(); @@ -147,6 +148,16 @@ mod tests { assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]); } + #[test] + fn oversized_region_mask_is_rejected_without_overflow() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + let before = s.pixel(0, 0); + let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] }; + assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY); + assert_eq!(s.pixel(0, 0), before); + } + #[test] fn degenerate_inputs_do_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 37e91da495..d7d43c032f 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -508,6 +508,9 @@ struct Solver<'a> { ctl: photocraft_raster::Interrupt<'a>, } +/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255). +const FLAT: f32 = 0.02; + /// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)` /// over `n` samples, within the limits. #[inline] @@ -516,7 +519,20 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3 let vt = (stt / n - mt * mt).max(0.0); let vs = (sss / n - ms * ms).max(0.0); let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 }; - let g = g.clamp(a.gain.0, a.gain.1); + let (mut lo, mut hi) = a.gain; + // Nearly flat patches of about the same colour (their means within the bias of each other): + // the ratio of their deviations is noise, so match the mean first, keeping the gain where the + // bias can still bring the source's mean to the target's. Gained about zero, a gain at its + // limit moved bright means further than the bias could bring back, and each channel by a + // different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones + // whose colours differ more (Content-Aware Move's Color), are fitted as before. + if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 { + let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms); + if glo.max(lo) <= ghi.min(hi) { + (lo, hi) = (glo.max(lo), ghi.min(hi)); + } + } + let g = g.clamp(lo, hi); let b = (mt - g * ms).clamp(-a.bias, a.bias); (g, b) } @@ -1959,6 +1975,21 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve mod tests { use super::*; + /// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the + /// gain stays where the bias can still reach the target's mean. + #[test] + fn fit_matches_the_mean_before_the_contrast() { + let a = Adapt { gain: (0.9, 1.1), bias: 0.04 }; + // Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5). + let n = 25.0; + let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32); + let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n); + assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b); + // Where the mean can't be reached anyway, the limits hold as before. + let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n); + assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6); + } + /// Smooth gradient plus deterministic fine noise. fn texture(w: usize, h: usize) -> Vec { let mut rng = Rng::new(7); diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index fdbe6dd72b..9d6ba29481 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -740,7 +740,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), FilterParams::FindEdges => stylize::find_edges(src, out, ctx), - FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }), + FilterParams::Solarize => stylize::solarize(src, out, ctx), FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v), FilterParams::Desaturate => stylize::desaturate(src, out, ctx), FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle), @@ -1206,9 +1206,12 @@ fn apply_box_blur( /// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them. fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) { let w = t.width() as usize; + // The selection over `t` read once, not a lookup and an allocation per pixel (#4533). + let mask = sel.read_region(t); + let sc = sel.channels().max(1); for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() { let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32); - let k = sel.pixel(x, y)[0].clamp(0.0, 1.0); + let k = mask.get(i * sc).copied().unwrap_or(0.0).clamp(0.0, 1.0); if k >= 1.0 { continue; } diff --git a/crates/algo/src/other.rs b/crates/algo/src/other.rs index 1db629a708..e0b11aceae 100644 --- a/crates/algo/src/other.rs +++ b/crates/algo/src/other.rs @@ -128,12 +128,32 @@ pub(crate) fn edge_of(u: UndefinedAreas) -> Edge { /// Shifts the bounds' content by whole pixels. pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefined: UndefinedAreas) -> Vec { let n = src.ch; - let edge = edge_of(undefined); + // Subtraction can leave i32 even for valid command parameters. Apply the edge + // policy before narrowing: saturating first would change the wrapped pixel. + let coordinate = |position: i32, distance: i32, start: i32, end: i32| -> Option { + if start >= end { + return None; + } + let p = i64::from(position) - i64::from(distance); + let (start, end) = (i64::from(start), i64::from(end)); + let mapped = match undefined { + UndefinedAreas::Wrap => start + (p - start).rem_euclid(end - start), + UndefinedAreas::Repeat => p.clamp(start, end - 1), + UndefinedAreas::Transparent if p >= start && p < end => p, + UndefinedAreas::Transparent => return None, + }; + i32::try_from(mapped).ok() + }; let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); for y in out.y0..out.y1 { + let sy = coordinate(y, dy, ctx.bounds.y0, ctx.bounds.y1); for x in out.x0..out.x1 { + let sx = coordinate(x, dx, ctx.bounds.x0, ctx.bounds.x1); for c in 0..n { - res.push(src.get_edge(x - dx, y - dy, c, edge, ctx.bounds)); + res.push(match (sx, sy) { + (Some(sx), Some(sy)) => src.get(sx, sy, c), + _ => 0.0, + }); } } } @@ -186,3 +206,61 @@ mod tests { } } } + +#[cfg(test)] +mod offset_overflow_tests { + use super::*; + + #[test] + fn offset_limits_match_equivalent_small_offsets_for_every_edge_policy() { + for bounds in [Rect::new(0, 0, 3, 5), Rect::new(-7, 11, -4, 16)] { + let mut src = Image::new(bounds, 4); + // Include signed/HDR values and varying alpha; offset must only move samples. + for (i, sample) in src.data.iter_mut().enumerate() { + *sample = i as f32 * 0.125 - 2.0; + } + let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Rgb, alpha: true }; + for policy in [UndefinedAreas::Wrap, UndefinedAreas::Repeat, UndefinedAreas::Transparent] { + for dx in [i32::MIN, i32::MAX, -8, 0, 7] { + for dy in [i32::MIN, i32::MAX, -9, 0, 6] { + let reduce = |d: i32, size: i32| match policy { + UndefinedAreas::Wrap => d.rem_euclid(size), + _ => d.clamp(-100, 100), + }; + let (rx, ry) = (reduce(dx, 3), reduce(dy, 5)); + let mut expected = Vec::new(); + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + for c in 0..4 { + expected.push(src.get_edge(x - rx, y - ry, c, edge_of(policy), bounds)); + } + } + } + assert_eq!(offset(&src, bounds, &ctx, dx, dy, policy), expected, "{bounds:?} {policy:?} {dx} {dy}"); + } + } + } + } + } + + #[test] + fn offset_wrap_handles_bounds_at_signed_coordinate_limits() { + for start in [i32::MIN, i32::MAX - 3] { + let bounds = Rect::new(start, start, start + 3, start + 3); + let mut src = Image::new(bounds, 1); + src.data = (0..9).map(|i| i as f32).collect(); + let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false }; + assert_eq!(offset(&src, bounds, &ctx, i32::MIN, i32::MIN, UndefinedAreas::Wrap), vec![8.0, 6.0, 7.0, 2.0, 0.0, 1.0, 5.0, 3.0, 4.0]); + } + } + + #[test] + fn offset_wrap_accepts_minimum_signed_distance() { + let bounds = Rect::new(0, 0, 3, 1); + let mut src = Image::new(bounds, 1); + src.data = vec![0.1, 0.5, 0.9]; + let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false }; + let got = offset(&src, bounds, &ctx, i32::MIN, 0, UndefinedAreas::Wrap); + assert_eq!(got, vec![0.9, 0.1, 0.5]); + } +} diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index 9376102efe..c32f28c648 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -63,6 +63,7 @@ fn composite_area( coverage: impl Fn(i32, i32) -> f32 + Sync, color: impl Fn(i32, i32) -> [f32; 4] + Sync, lock_transparency: bool, + inks: Option<[f32; 4]>, ) { // Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched, // so their copy-on-write storage (and GPU residency) survives. @@ -93,6 +94,14 @@ fn composite_area( o[3] = d[3]; } from_rgba_into(&fmt, o, &mut enc); + // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. + if let Some(i) = inks + && fmt.mode == photocraft_color::ColorMode::Cmyk + && blend_mode == BlendMode::Normal + && k >= 1.0 + { + enc[..4].copy_from_slice(&i); + } px.copy_from_slice(&enc[..n]); } } @@ -144,6 +153,7 @@ pub fn paint_gradient( c }, lock_transparency, + None, ); } @@ -179,6 +189,25 @@ pub fn bucket_fill_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) -> bool { + bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None) +} + +/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target +/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK). +#[allow(clippy::too_many_arguments)] +pub fn bucket_fill_inks( + s: &mut Surface, + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) -> bool { if !area.contains(seed.0, seed.1) { return false; @@ -186,7 +215,7 @@ pub fn bucket_fill_src( let img = crate::selection::rgba8_image(s, area); let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false }; drop(img); - bucket_fill_region_src(s, ®ion, opacity, selection, lock_transparency, src); + bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks); true } @@ -199,10 +228,31 @@ pub fn bucket_fill_region_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) { + bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None); +} + +/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`]. +pub fn bucket_fill_region_inks( + s: &mut Surface, + region: &crate::selection::Region, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) { // Composite only over the filled region's box. let b = region.bbox; - composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency); + composite_area( + s, + b, + BlendMode::Normal, + |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), + src, + lock_transparency, + inks, + ); } #[cfg(test)] diff --git a/crates/algo/src/pixelate.rs b/crates/algo/src/pixelate.rs index 1a1325975a..df6edb5fff 100644 --- a/crates/algo/src/pixelate.rs +++ b/crates/algo/src/pixelate.rs @@ -115,8 +115,11 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, res } -/// Crystallize: Voronoi cells around jittered points, each filled with the -/// (premultiplied) average colour of its pixels. +/// Crystallize: Voronoi cells around jittered points, each filled with the colour at its +/// point, as Photoshop's are: cells stay solid, so a black-and-white image (a layer mask +/// being distressed) keeps hard edges and its weight instead of greying where cells straddle +/// an edge. A cell whose point falls outside the image takes the (premultiplied) average +/// colour of its pixels. pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec { let n = src.ch; let cell = cell_size.max(2.0); @@ -177,6 +180,14 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see continue; } let (cx, cy) = if win.contains(x, y) { owners[i] } else { owner(x, y) }; + let (sx, sy) = cell_point(cx, cy, cell, seed, origin); + let (sx, sy) = (sx.floor() as i32, sy.floor() as i32); + if src.rect.contains(sx, sy) && b.contains(sx, sy) { + for (c, v) in px.iter_mut().enumerate() { + *v = src.get(sx, sy, c); + } + continue; + } if cx < c0.0 || cy < c0.1 || cx > c1.0 || cy > c1.1 { continue; } diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 5e80791ff1..ae1a586c12 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -116,3 +116,147 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u let low = super::clean_mask(&low, lw, lh, 0.1, 0.002); super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg))) } + +/// Working-resolution budget (pixels) for [`object_at`]: small enough to run while the pointer +/// hovers. +pub const OBJECT_AT_WORK_PX: usize = 60_000; + +/// Object Selection's Object Finder (hover / click): the object under document point `(x, y)`. +/// +/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the +/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both +/// by the distance to the seed's running mean and by the local edge step), so it stops at the +/// object's outline. A region that grows over most of the canvas is the background: nothing is +/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// foreground, the cursor definite foreground, outside the box definite background), the part +/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the +/// rectangle mode does. +pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { + if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { + return None; + } + let (px, py) = (x.floor() as i32, y.floor() as i32); + if !canvas.contains(px, py) { + return None; + } + let step = super::scale_for(canvas, work_px.max(1024)); + let img = sampler.rgb_scaled(canvas, step); + let (w, h) = (img.w, img.h); + if w < 2 || h < 2 { + return None; + } + // Lightly smoothed, so noise and texture don't stop the growth; edges survive. + let lab: Vec<[f32; 3]> = box3(&box3(&img.px, w, h), w, h).iter().map(|c| super::slic::rgb_to_lab(*c)).collect(); + let sx = (((px - canvas.x0) as usize) / step).min(w - 1); + let sy = (((py - canvas.y0) as usize) / step).min(h - 1); + let grown = grow_similar(&lab, w, h, sx, sy); + let count = grown.iter().filter(|g| **g).count(); + // Background (or a flat image): no single object under the cursor. + if count * 10 > w * h * 6 || count < 4 { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, g) in grown.iter().enumerate() { + if *g { + let (gx, gy) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(gx), y0.min(gy), x1.max(gx + 1), y1.max(gy + 1)); + } + } + let pad = ((x1 - x0).max(y1 - y0) / 6).max(2); + let (bx0, by0, bx1, by1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h)); + let mut trimap = vec![BG; w * h]; + let mut any_bg = false; + for yy in 0..h { + for xx in 0..w { + let i = yy * w + xx; + let inside = xx >= bx0 && xx < bx1 && yy >= by0 && yy < by1; + trimap[i] = if grown[i] { + PR_FG + } else if inside { + PR_BG + } else { + any_bg = true; + BG + }; + } + } + if !any_bg { + // The box is the whole canvas: the frame is background. + for yy in 0..h { + for xx in 0..w { + if (xx == 0 || yy == 0 || xx + 1 == w || yy + 1 == h) && !(xx == sx && yy == sy) { + trimap[yy * w + xx] = BG; + } + } + } + } + trimap[sy * w + sx] = FG; + let (fg, bg) = grabcut(&img, &mut trimap, 5)?; + let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect(); + let mut seeds = vec![false; w * h]; + seeds[sy * w + sx] = true; + let low = super::keep_seeded(&low, &seeds, w, h); + let low = super::clean_mask(&low, w, h, 0.1, 0.01); + if !low.iter().any(|v| *v) { + return None; + } + super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg))) +} + +/// Region growing (4-connected) from `(sx, sy)` over pixels whose CIELAB colour is within a +/// distance of the seed area's running mean and whose step from the neighbour it was reached +/// from is small, so it stops at edges. +fn grow_similar(lab: &[[f32; 3]], w: usize, h: usize, sx: usize, sy: usize) -> Vec { + const MEAN_DIST: f32 = 32.0; + const EDGE_STEP: f32 = 10.0; + let d = |a: [f32; 3], b: [f32; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); + let mut out = vec![false; w * h]; + let Some(&seed) = lab.get(sy * w + sx) else { return out }; + let (mut sum, mut n) = ([seed[0] as f64, seed[1] as f64, seed[2] as f64], 1.0f64); + let mut stack = vec![(sx, sy)]; + out[sy * w + sx] = true; + while let Some((x, y)) = stack.pop() { + let c = lab[y * w + x]; + let mean = [(sum[0] / n) as f32, (sum[1] / n) as f32, (sum[2] / n) as f32]; + let nbrs = [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)]; + for (nx, ny) in nbrs { + if nx >= w || ny >= h { + continue; + } + let i = ny * w + nx; + if out[i] { + continue; + } + let q = lab[i]; + if d(q, c) <= EDGE_STEP && d(q, mean) <= MEAN_DIST { + out[i] = true; + sum = [sum[0] + q[0] as f64, sum[1] + q[1] as f64, sum[2] + q[2] as f64]; + n += 1.0; + stack.push((nx, ny)); + } + } + } + out +} + +/// 3×3 box blur (clamped at the edges). +fn box3(px: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> { + let mut out = vec![[0.0f32; 3]; px.len()]; + for y in 0..h { + for x in 0..w { + let (mut acc, mut n) = ([0.0f32; 3], 0.0f32); + for yy in y.saturating_sub(1)..(y + 2).min(h) { + for xx in x.saturating_sub(1)..(x + 2).min(w) { + if let Some(p) = px.get(yy * w + xx) { + acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; + n += 1.0; + } + } + } + if let Some(o) = out.get_mut(y * w + x) { + *o = acc.map(|v| v / n.max(1.0)); + } + } + } + out +} diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index dba4ebfce5..90cc2f502c 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -288,6 +288,9 @@ fn grid_cut_impl( if count == 0 { return Some(out); } + if cancel() { + return None; + } let mut g = Graph::with_capacity(count as usize, count as usize * 4); for i in 0..n { if idx[i] != u32::MAX { @@ -330,6 +333,9 @@ fn grid_cut_impl( } } } + if cancel() { + return None; + } if OPTIMIZED { g.maxflow_with(cancel)?; } else { diff --git a/crates/algo/src/segment/quick.rs b/crates/algo/src/segment/quick.rs index 859575b2cc..8306b6e662 100644 --- a/crates/algo/src/segment/quick.rs +++ b/crates/algo/src/segment/quick.rs @@ -88,6 +88,19 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3 } pub(super) fn quick_select_using(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option { + quick_select_batched::(sampler, canvas, points, size, t, SPECULATE).map(|(region, _)| region) +} + +/// [`quick_select_using`] cutting `batch` window sizes at a time (1: one after another). Also +/// returns how many window sizes, smallest first, it took to find the kept one. +pub(super) fn quick_select_batched( + sampler: &dyn Sampler, + canvas: Rect, + points: &[(f32, f32)], + size: f32, + t: &Tuning, + batch: usize, +) -> Option<(Region, usize)> { if points.is_empty() || canvas.is_empty() { return None; } @@ -107,8 +120,15 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c let step = working_step(canvas.width() as usize, canvas.height() as usize) as i32; let rw = r / step as f32; // Work in a window around the stroke; grow it while the selection runs into its border. - let mut margin = (48.0f32).max(2.0 * rw) as i32 * step; - loop { + // The window's sides are free like the canvas' edges, so a selection that stays clear of + // them is kept; one that reaches a side inside the canvas is recomputed in a larger window. + // Index of the smallest window kept so far in this batch: larger ones stop cutting. + let kept = std::sync::atomic::AtomicUsize::new(usize::MAX); + let attempt = |index: usize, margin: i32| -> Attempt { + let cancel = || kept.load(std::sync::atomic::Ordering::Relaxed) < index; + if cancel() { + return Attempt::Cancelled; + } let snap0 = |v: i32, o: i32| o + (v - o).div_euclid(step) * step; let grown = bbox.inflate(margin).intersect(&canvas); let window = Rect::new(snap0(grown.x0, canvas.x0), snap0(grown.y0, canvas.y0), grown.x1, grown.y1); @@ -118,13 +138,13 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c let pts: Vec<(f32, f32)> = points.iter().map(|&(x, y)| ((x - window.x0 as f32) / s, (y - window.y0 as f32) / s)).collect(); let seeds = stroke_mask(&pts, rw.max(0.5), lw, lh); if !seeds.iter().any(|v| *v) { - return None; + return Attempt::NoSeeds; + } + if cancel() { + return Attempt::Cancelled; } - // The window's sides are free like the canvas' edges, so a selection that stays clear of - // them is the best over the whole canvas too (outside the window it could only add cost). - // One that reaches a side inside the canvas is recomputed in a larger window. let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1]; - let low = select_working_using::(&img, &seeds, rw, t); + let Some(low) = select_working_using::(&img, &seeds, rw, t, &cancel) else { return Attempt::Cancelled }; let touches = |side: usize| match side { 0 => (0..lh).any(|y| low[y * lw]), 1 => (0..lw).any(|x| low[x]), @@ -132,21 +152,87 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c _ => (0..lw).any(|x| low[(lh - 1) * lw + x]), }; if (0..4).any(|k| inner[k] && touches(k)) { - margin = margin.saturating_mul(2); - continue; + return Attempt::Touches; } - let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize); - return super::trim_region(Region { bbox: window, mask }); + kept.fetch_min(index, std::sync::atomic::Ordering::Relaxed); + Attempt::Done { window, low, lw, lh } + }; + let mut margin = (48.0f32).max(2.0 * rw) as i32 * step; + let mut sizes = 0; + loop { + // The attempts don't depend on each other, so a few window sizes are cut at once and the + // first in growing order that stays clear of its sides is kept: the same result as trying + // them one after another, without waiting for the smaller ones to fail first. Once one is + // kept, the larger ones still cutting stop, so a stroke settled by a small window costs + // about what that window alone costs. + let batch = margins_to_try(margin, bbox, canvas, batch); + kept.store(usize::MAX, std::sync::atomic::Ordering::Relaxed); + #[cfg(not(target_arch = "wasm32"))] + let results: Vec = { + use rayon::prelude::*; + batch.par_iter().enumerate().map(|(i, &m)| attempt(i, m)).collect() + }; + #[cfg(target_arch = "wasm32")] + let results: Vec = batch.iter().enumerate().map(|(i, &m)| attempt(i, m)).collect(); + for result in results { + sizes += 1; + match result { + Attempt::NoSeeds => return None, + // Only a window larger than one already kept is cancelled, and that one comes + // first in this order, so a cancelled attempt is never reached. + Attempt::Touches | Attempt::Cancelled => continue, + Attempt::Done { window, low, lw, lh } => { + let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize); + return super::trim_region(Region { bbox: window, mask }).map(|region| (region, sizes)); + } + } + } + let &last = batch.last()?; + margin = last.saturating_mul(2); } } +/// One window size's outcome in [`quick_select_using`]. +enum Attempt { + /// The stroke misses the window. + NoSeeds, + /// The selection reaches a side of the window inside the canvas. + Touches, + /// A smaller window of the same batch was kept first. + Cancelled, + Done { + window: Rect, + low: Vec, + lw: usize, + lh: usize, + }, +} + +/// Window sizes cut together: most strokes settle within this many doublings. +const SPECULATE: usize = 4; + +/// The next `count` margins from `margin`, doubling, stopping at the first one whose window +/// already covers the whole canvas (a larger one would be the same window). +pub(super) fn margins_to_try(margin: i32, bbox: Rect, canvas: Rect, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.max(1)); + let mut m = margin; + for _ in 0..count.max(1) { + out.push(m); + if bbox.inflate(m).intersect(&canvas) == canvas { + break; + } + m = m.saturating_mul(2); + } + out +} + /// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are /// selected; returns the selection. pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { - select_working_using::(img, seeds, radius, t) + select_working_using::(img, seeds, radius, t, &|| false).unwrap_or_default() } -fn select_working_using(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { +fn select_working_using(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning, cancel: &dyn Fn() -> bool) -> Option> { let (w, h) = (img.w, img.h); let d = crate::selection::edt(seeds, w, h); let grow = (t.grow + t.grow_frac * radius).max(0.0); @@ -154,7 +240,10 @@ fn select_working_using(img: &RgbImage, seeds: &[bool], r let fixed: Vec = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect(); let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k); let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k)); - cut_levels::(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024)) + if cancel() { + return None; + } + cut_levels::(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024), cancel) } /// Band half-width (pixels) re-cut at each finer level. @@ -164,11 +253,19 @@ const BAND: f32 = 3.0; /// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two /// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds /// stay foreground. -fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec { +fn cut_levels( + img: &RgbImage, + fixed: &[u8], + cost_fg: &[f32], + weight: &dyn Fn(f32) -> f32, + scale: f32, + direct_px: usize, + cancel: &dyn Fn() -> bool, +) -> Option> { let (w, h) = (img.w, img.h); let zeros = vec![0.0f32; w * h]; if w * h <= direct_px || w < 8 || h < 8 { - return super::grid_cut_weighted::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2)); + return super::grid_cut_impl::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel); } let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let coarse_img = img.downsample(2); @@ -183,7 +280,10 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 } } } - let coarse = cut_levels::(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px); + let coarse = cut_levels::(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px, cancel)?; + if cancel() { + return None; + } let proj: Vec = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect(); // Band: within BAND of the projected boundary. let edge: Vec = (0..w * h) @@ -194,7 +294,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 }) .collect(); if !edge.iter().any(|e| *e) { - return (0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect(); + return Some((0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect()); } let near = crate::selection::edt(&edge, w, h); let banded: Vec = (0..w * h) @@ -210,7 +310,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 } }) .collect(); - super::grid_cut_weighted::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2)) + super::grid_cut_impl::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel) } /// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel. diff --git a/crates/algo/src/segment/sky.rs b/crates/algo/src/segment/sky.rs index 74fc62790d..a54bcf6772 100644 --- a/crates/algo/src/segment/sky.rs +++ b/crates/algo/src/segment/sky.rs @@ -132,9 +132,31 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option> { } } let mask = clean_mask(&mask, w, h, 0.002, 0.01); + if is_backdrop(img, &mask, top) { + return None; + } mask.iter().any(|v| *v).then_some(mask) } +/// A neutral region that also covers most of the bottom rows is a studio or product backdrop, +/// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one. +fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool { + let (w, h) = (img.w, img.h); + let bottom = (h - rows.min(h)) * w..h * w; + let Some(bottom_rows) = mask.get(bottom) else { return false }; + let covered = bottom_rows.iter().filter(|m| **m).count(); + if covered * 10 < bottom_rows.len() * 6 { + return false; + } + let (mut sat, mut n) = (0.0f32, 0usize); + for (p, _) in img.px.iter().zip(mask).filter(|(_, m)| **m) { + let (mx, mn) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2])); + sat += if mx > 1e-4 { (mx - mn) / mx } else { 0.0 }; + n += 1; + } + n > 0 && sat / (n as f32) < 0.1 +} + /// Selects the sky over `canvas`. `None` if no sky was found. pub fn select_sky(sampler: &dyn Sampler, canvas: Rect, p: SkyParams) -> Option { if canvas.width() < 4 || canvas.height() < 4 { @@ -269,6 +291,18 @@ mod tests { assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1); } + /// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky + /// reaching the bottom (a photo taken upward) still is. + #[test] + fn a_white_backdrop_is_not_sky_but_a_blue_sky_down_to_the_bottom_is() { + let disc = |x: usize, y: usize| ((x as f32 - 75.0).powi(2) + (y as f32 - 50.0).powi(2)).sqrt() < 30.0; + let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.12, 0.12] } else { [0.95, 0.95, 0.94] }); + assert!(select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).is_none()); + let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.3, 0.1] } else { [0.35, 0.6, 0.95] }); + let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).expect("blue sky"); + assert!(r.at(5, 95) > 0.9 && r.at(75, 50) < 0.1); + } + #[test] fn colour_prior_ranks_sky_over_foliage() { assert!(colour_prior([0.4, 0.6, 0.95]) > 0.8); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index c3a37dc6dd..869f3c6717 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -344,6 +344,45 @@ fn quick_select_with_points_far_off_canvas_selects_nothing() { } } +#[test] +fn quick_select_cuts_window_sizes_in_parallel_with_the_same_result() { + // Soft gradients and faint blobs let a selection spread, so most strokes need more than one + // window size; cutting several at once must keep exactly what one-after-another keeps. + let (w, h) = (1200usize, 900usize); + let img = RgbImage::from_fn(w, h, |x, y| { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let blob = (((fx * 9.0).sin() * (fy * 7.0).cos()) * 0.5 + 0.5) * 0.06; + [0.3 + 0.4 * fx + blob, 0.5 + 0.2 * fy, 0.7 - 0.3 * fx + blob] + }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + let t = quick::Tuning::default(); + let strokes: [(&[(f32, f32)], f32); 4] = [ + (&[(300.0, 300.0)], 30.0), + (&[(500.0, 400.0), (580.0, 400.0), (660.0, 420.0)], 60.0), + (&[(200.0, 700.0), (260.0, 650.0), (320.0, 700.0), (380.0, 650.0)], 30.0), + (&[(900.0, 200.0)], 120.0), + ]; + let mut most = 0; + for (pts, size) in strokes { + let one = quick::quick_select_batched::(&s, canvas, pts, size, &t, 1); + let many = quick::quick_select_batched::(&s, canvas, pts, size, &t, 4); + assert_eq!(one.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), many.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), "{pts:?} size {size}"); + most = most.max(one.map_or(0, |(_, n)| n)); + } + assert!(most > 1, "no stroke needed a larger window, so the test exercises nothing"); +} + +#[test] +fn quick_select_window_sizes_stop_at_the_canvas() { + let canvas = Rect::new(0, 0, 1000, 800); + let bbox = Rect::new(400, 300, 460, 360); + assert_eq!(quick::margins_to_try(96, bbox, canvas, 4), vec![96, 192, 384, 768]); + // 768 already covers the canvas: no larger (identical) window is cut. + assert_eq!(quick::margins_to_try(384, bbox, canvas, 4), vec![384, 768]); + assert_eq!(quick::margins_to_try(96, bbox, canvas, 0), vec![96]); +} + #[test] fn quick_select_grows_without_leaking() { let img = two_regions(140, 100); @@ -620,3 +659,24 @@ fn dump_quick_scenes() { save("thin_line", &line); save("disc_scene", &disc_scene(600, 500, 300.0, 250.0, 180.0, 4).0); } + +#[test] +fn object_at_finds_the_disc_under_the_cursor() { + let (img, truth) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 320, 240); + let reg = grabcut::object_at(&s, canvas, 165.0, 118.0, 20_000).unwrap(); + let v = iou(®, &truth, 320, 240); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn object_at_on_the_background_or_off_canvas_finds_nothing() { + let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 320, 240); + assert!(grabcut::object_at(&s, canvas, 10.0, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, -5.0, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); +} diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index ebe8a42701..d6662e75f1 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -166,7 +166,8 @@ fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool { /// around the seed. `px` covers `area`. pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Vec { let (w, h) = (area.width() as usize, area.height() as usize); - let mut m = vec![0.0f32; w * h]; + let Some(n) = w.checked_mul(h).filter(|&n| n <= px.len()) else { return Vec::new() }; + let mut m = vec![0.0f32; n]; if !area.contains(seed.0, seed.1) { return m; } @@ -245,7 +246,9 @@ impl Region { if !self.bbox.contains(x, y) { return 0.0; } - self.mask[(y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize] as f32 / 255.0 + let row = (y.abs_diff(self.bbox.y0) as usize).checked_mul(self.bbox.width() as usize); + let value = row.and_then(|i| i.checked_add(x.abs_diff(self.bbox.x0) as usize)).and_then(|i| self.mask.get(i)).copied().unwrap_or(0); + f32::from(value) / 255.0 } } @@ -257,6 +260,9 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 return None; } let (w, h) = (area.width() as usize, area.height() as usize); + if w.checked_mul(h).is_none_or(|n| n > img.len()) { + return None; + } let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize]; let tol = (tolerance + 1e-3).floor().max(0.0) as i32; // Copying a selection can leave RGB under zero alpha. Those invisible colours must not @@ -1400,6 +1406,37 @@ mod tests { .collect() } + #[test] + fn magic_wand_rejects_incomplete_and_overflowing_pixel_inputs() { + let area = Rect::new(0, 0, 4, 4); + let short = vec![[255u8, 0, 0, 255]; 4]; + let floats: Vec<[f32; 4]> = short.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect(); + assert_eq!(magic_wand(&floats, area, (1, 1), 0.0, true, false), Vec::::new()); + assert_eq!(wand_region(&short, area, (1, 1), 0.0, true, false), None); + + let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX); + assert!(magic_wand(&floats, huge, (0, 0), 0.0, false, false).is_empty()); + assert_eq!(wand_region(&short, huge, (0, 0), 0.0, false, false), None); + + let valid = Rect::new(0, 0, 2, 2); + assert_eq!(magic_wand(&floats, valid, (0, 0), 0.0, false, false), vec![1.0; 4]); + assert!(wand_region(&short, valid, (0, 0), 0.0, false, false).is_some()); + } + + #[test] + fn region_coverage_outside_a_short_mask_is_zero() { + let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] }; + assert_eq!(region.at(-2, -3), 1.0); + assert!((region.at(-1, -3) - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(region.at(0, -3), 0.0); + assert_eq!(region.at(-2, -2), 0.0); + assert_eq!(region.at(-3, -3), 0.0); + + let extreme = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![] }; + assert_eq!(extreme.at(i32::MAX - 1, i32::MAX - 1), 0.0); + assert_eq!(extreme.at(i32::MIN, i32::MIN), 0.0); + } + #[test] fn wand_region_matches_reference_magic_wand() { let (w, h) = (37, 29); diff --git a/crates/algo/src/selection_blur.rs b/crates/algo/src/selection_blur.rs index 2a4100ac29..4c64250a68 100644 --- a/crates/algo/src/selection_blur.rs +++ b/crates/algo/src/selection_blur.rs @@ -569,11 +569,14 @@ pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { let s = f64::from(sigma); let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh)); // Rows: only the bounding box's rows are non-zero. + // The run table is scratch: one buffer per worker thread, reused across rows instead of + // one allocation per row (thousands of 8-byte-per-sample Vecs on a 24 MP mask). + thread_local! { static RUNS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) } } let mut tmp = vec![0.0f32; b.h() * gw]; par_rows(&mut tmp, gw, 1, |y, row| { let o = (b.y0 + y) * w + win.x0; if let Some(s) = m.get(o..o + gw) { - kh.line(s, row, &mut Vec::new()); + RUNS.with(|runs| kh.line(s, row, &mut runs.borrow_mut())); } }); // Columns, a strip of columns at a time. diff --git a/crates/algo/src/stylize.rs b/crates/algo/src/stylize.rs index 2a88c6ed6e..750923d1a9 100644 --- a/crates/algo/src/stylize.rs +++ b/crates/algo/src/stylize.rs @@ -5,6 +5,7 @@ use photocraft_geom::Rect; use photocraft_raster::{from_rgba, to_rgba}; use crate::Ctx; +use crate::fxutil::subtractive; use crate::image::Image; fn fmt(ctx: &Ctx) -> PixelFormat { @@ -23,6 +24,16 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) { px.copy_from_slice(&v[..px.len()]); } +/// Solarize: inverts the highlights. CMYK samples are ink, so there the +/// channels below 50% ink (the light ones) invert. +pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec { + if subtractive(ctx) { + per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v }) + } else { + per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }) + } +} + /// Colour channels through `f` (alpha untouched). pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec { let n = src.ch; @@ -167,10 +178,12 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32, res } -/// Find Edges: Sobel magnitude per colour channel, dark edges on white. +/// Find Edges: Sobel magnitude per colour channel, dark edges on white +/// (CMYK samples are ink, so there the edge strength is the ink itself). pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec { let n = src.ch; let cc = if ctx.alpha { n - 1 } else { n }; + let sub = subtractive(ctx); let mut res = src.crop(out); let w = out.width() as usize; for (i, px) in res.chunks_exact_mut(n).enumerate() { @@ -179,7 +192,8 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec { let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c); let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1)); let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1)); - *v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0); + let e = ((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0); + *v = if sub { e } else { 1.0 - e }; } } res diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 763cc0bcee..3da67a5554 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -367,6 +367,30 @@ fn stylize_basics() { assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6); } +/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink) +/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched. +#[test] +fn find_edges_and_solarize_treat_cmyk_as_ink() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let f = PixelFormat::new(ColorMode::Cmyk, depth, true); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]); + let e = run(&s, &FilterParams::FindEdges).pixel(20, 15); + assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}"); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]); + let so = run(&s, &FilterParams::Solarize).pixel(3, 3); + let want = [0.9, 0.8, 0.8, 0.9, 0.6]; + assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}"); + } + // RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert. + let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]); + let e = run(&s, &FilterParams::FindEdges).pixel(20, 15); + assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}"); + let so = run(&s, &FilterParams::Solarize).pixel(3, 3); + assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}"); +} + #[test] fn distortions_are_identity_at_zero() { let s = pattern(SampleType::U8, R); @@ -463,3 +487,72 @@ fn layer_filters_repeat_the_canvas_edge() { assert!(faded.pixel(0, 0)[3] < 0.9, "{depth:?}: unclamped path should read transparency"); } } + +/// `mix_selection` before #4533, one selection lookup per pixel: the oracle for the per-tile read. +fn mix_selection_reference(data: &mut [f32], t: Rect, sel: &Surface, orig: &crate::image::Image, alpha: bool) { + let w = t.width() as usize; + for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() { + let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32); + let k = sel.pixel(x, y)[0].clamp(0.0, 1.0); + if k >= 1.0 { + continue; + } + if k <= 0.0 { + for (c, v) in px.iter_mut().enumerate() { + *v = orig.get(x, y, c); + } + continue; + } + if alpha { + let a = px.len() - 1; + let original_alpha = orig.get(x, y, a); + let filtered_alpha = px[a]; + if original_alpha != filtered_alpha { + let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k; + let original_weight = original_alpha * (1.0 - k); + let filtered_weight = filtered_alpha * k; + for (c, v) in px[..a].iter_mut().enumerate() { + *v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 }; + } + px[a] = mixed_alpha; + continue; + } + } + for (c, v) in px.iter_mut().enumerate() { + let o = orig.get(x, y, c); + *v = o + (*v - o) * k; + } + } +} + +/// Reading the selection once per tile gives exactly the per-pixel result (#4533): a soft +/// selection with fully selected, unselected and partial pixels, with and without alpha, on a +/// tile that crosses the selection's tile boundaries. +#[test] +fn mix_selection_reads_the_selection_once_per_tile() { + let noise = |seed: u32, n: usize| -> Vec { + (0..n as u32) + .map(|i| { + let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + (h >> 16) as f32 / 65535.0 + }) + .collect() + }; + let t = Rect::new(200, 180, 330, 300); + let n = (t.width() * t.height()) as usize; + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(150, 150, 260, 250), &[1.0]); + let soft = Rect::new(260, 150, 330, 250); + sel.write_region(soft, &noise(5, (soft.width() * soft.height()) as usize)); + for (alpha, fmt) in [(true, PixelFormat::RGBA8), (false, PixelFormat { alpha: false, ..PixelFormat::RGBA8 })] { + let ch = fmt.channels(); + let orig = crate::image::Image { rect: t, ch, data: noise(1, n * ch) }; + let filtered = noise(2, n * ch); + let (mut a, mut b) = (filtered.clone(), filtered); + mix_selection(&mut a, t, &sel, &orig, alpha); + mix_selection_reference(&mut b, t, &sel, &orig, alpha); + assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ"); + } +} diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 1480ec2b3e..8f841e7457 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -3,13 +3,13 @@ //! A transform is described by where the four corners of a source rectangle land (a quad). Scale, //! rotate, skew and flips are affine special cases; Distort and Perspective use the full //! homography. Warping is an inverse mapping with premultiplied-alpha sampling (nearest, bilinear -//! or Catmull-Rom bicubic), processed per destination tile (in parallel on native) so memory stays +//! or Photoshop's Keys bicubic), processed per destination tile (in parallel on native) so memory stays //! bounded by the tile working set. Large reductions are pre-filtered with a proper resize first, //! so shrinking a layer doesn't alias. use photocraft_color::PixelFormat; use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde::{Deserialize, Serialize}; use crate::resample::{Resample, resize_surface}; @@ -133,24 +133,40 @@ impl Interp { } } -fn catmull_rom(t: f64) -> [f64; 4] { - let t2 = t * t; - let t3 = t2 * t; - [-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2] +/// Photoshop's Bicubic: the Keys cubic at a = −0.75 (Catmull-Rom is −0.5), the weights of the +/// four source pixels around a sample `t` (0..1) past the second. Measured on Photoshop 27.11's +/// Free Transform: a 200 % step edge matches within 0.5 levels; Catmull-Rom was 6 off. +pub(crate) fn bicubic(t: f64) -> [f64; 4] { + const A: f64 = -0.75; + let near = |x: f64| (A + 2.0) * x * x * x - (A + 3.0) * x * x + 1.0; + let far = |x: f64| A * (x * x * x - 5.0 * x * x + 8.0 * x - 4.0); + [far(1.0 + t), near(t), near(1.0 - t), far(2.0 - t)] } /// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space). /// The result has the same format as `src`, with alpha added if `src` had none; pixels outside /// the warped quad are transparent. pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result { - warp(src, src_rect, h, interp, None) + warp(src, src_rect, h, interp, None, &Interrupt::NONE) +} + +/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled. +pub fn warp_surface_with( + src: &Surface, + src_rect: Rect, + h: &Homography, + interp: Interp, + ctl: &Interrupt, +) -> Result, photocraft_raster::AllocationError> { + let out = warp(src, src_rect, h, interp, None, ctl)?; + Ok((!ctl.cancelled()).then_some(out)) } /// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by /// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a /// layer that reaches far past the crop quad), which would make the full warped extent unbounded. pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result { - warp(src, src_rect, h, interp, Some(clip)) + warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE) } /// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every @@ -192,7 +208,39 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option { Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse() } -fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option) -> Result { +/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners +/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to +/// infinity (the result is then empty). Lets a caller price a warp before allocating it. +pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option { + if src_rect.is_empty() || h.inverse().is_none() { + return None; + } + let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] + .map(|(x, y)| h.apply(x as f64, y as f64)); + corner_bounds(&corners) +} + +fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { + if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) { + return None; + } + let lim = (1 << 20) as f64; + let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; + let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; + let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1; + let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1; + Some(Rect::new(bx0, by0, bx1, by1)) +} + +/// A cancelled `ctl` skips the remaining tiles; the caller discards the result. +fn warp( + src: &Surface, + src_rect: Rect, + h: &Homography, + interp: Interp, + clip: Option, + ctl: &Interrupt, +) -> Result { let mut fmt = src.format(); let converted; let mut src = if fmt.alpha { @@ -216,8 +264,12 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt }; let scale = h.local_scale(cx, cy); let reduced; - if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 { - let f = 2f64.powf(scale.log2().ceil()).min(1.0); // remaining scale lands in [0.5, 1) + // Photoshop reduces with its Bicubic widened by 1 / scale (as Image Size does), so the source is + // brought down by the whole local scale first and the warp samples it at about 1:1; a plain + // 4×4 kernel at 50 % aliased (Photoshop 27.11 Free Transform, 50 % of an impulse image). + // Bilinear samples the source directly, as Photoshop's does (exact at 50 % without a prefilter). + if interp == Interp::Bicubic && scale < 0.999 && scale > 0.0 { + let f = scale.max(1.0 / 64.0); reduced = resize_surface(src, f, f, Resample::Bicubic); src = &reduced; h = h.mul(&Homography([1.0 / f, 0.0, 0.0, 0.0, 1.0 / f, 0.0, 0.0, 0.0, 1.0])); @@ -234,21 +286,13 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]); let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0)); let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs())); - let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()); - let lim = 1 << 20; - let bounds = || { - let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1; - let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1; - let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1; - let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1; - Rect::new(bx0, by0, bx1, by1) - }; - let dst = match clip { + let bounds = corner_bounds(&corners); + let dst = match (clip, bounds) { // The warped corners only bound the result while the whole source is in front of the horizon. - Some(c) if finite && side.is_some() => bounds().intersect(&c), - Some(c) => c, - None if finite => bounds(), - None => return Ok(out), + (Some(c), Some(b)) if side.is_some() => b.intersect(&c), + (Some(c), _) => c, + (None, Some(b)) => b, + (None, None) => return Ok(out), }; if dst.is_empty() { return Ok(out); @@ -261,7 +305,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?; tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty())); let src_ref: &Surface = src; + // Tiles finished so far, for progress reported per tile (from any worker thread). + let finished = std::sync::atomic::AtomicUsize::new(0); + let total = tiles.len().max(1); let work = |t: &Rect| -> Result)>, photocraft_raster::AllocationError> { + if ctl.cancelled() { + return Ok(None); + } + let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + // The last few percent are the writes. + ctl.progress(0.98 * k as f32 / total as f32); // Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter. let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64)); let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]); @@ -350,7 +403,7 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt } Interp::Bicubic => { let (ix, iy) = (u.floor() as i32, v.floor() as i32); - let (wx, wy) = (catmull_rom(u - ix as f64), catmull_rom(v - iy as f64)); + let (wx, wy) = (bicubic(u - ix as f64), bicubic(v - iy as f64)); for (j, wyj) in wy.iter().enumerate() { for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; @@ -397,6 +450,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt } } let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); + if ctl.cancelled() { + return Ok(out); + } for (r, v) in done { out.try_write_region(r, &v)?; } @@ -408,6 +464,25 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt mod tests { use super::*; + #[test] + fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]); + let r = s.content_bounds(); + let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap(); + let reported = std::sync::Mutex::new(0.0f32); + let progress = |p: f32| { + let mut m = reported.lock().unwrap(); + *m = m.max(p); + }; + let never = || false; + let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled"); + assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r)); + assert!(*reported.lock().unwrap() > 0.9); + let always = || true; + assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none()); + } + /// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate, /// self-crossing, counter-clockwise and non-finite quads have none. #[test] @@ -436,6 +511,24 @@ mod tests { Surface::new(PixelFormat::RGBA8) } + /// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left + /// half is grey 64 and right half grey 192: the row across the edge, columns 26..36. + #[test] + fn bicubic_upscale_matches_photoshop() { + let mut src = rgba(); + let g = |v: f32| v / 255.0; + src.fill_rect(Rect::new(0, 0, 32, 64), &[g(64.0), g(64.0), g(64.0), 1.0]); + src.fill_rect(Rect::new(32, 0, 64, 64), &[g(192.0), g(192.0), g(192.0), 1.0]); + let q = [[-32.0, -32.0], [96.0, -32.0], [96.0, 96.0], [-32.0, 96.0]]; + let h = Homography::rect_to_quad([0.0, 0.0, 64.0, 64.0], q).unwrap(); + let out = warp_surface(&src, Rect::new(0, 0, 64, 64), &h, Interp::Bicubic).unwrap(); + let photoshop = [64.0, 64.0, 64.0, 60.0, 51.0, 93.0, 163.0, 206.0, 197.0, 192.0]; + for (i, want) in photoshop.iter().enumerate() { + let got = out.pixel(26 + i as i32, 50)[0] * 255.0; + assert!((got - want).abs() <= 1.5, "column {}: {got} vs {want}", 26 + i); + } + } + #[test] fn homography_maps_rect_corners_and_inverts() { let r = [10.0, 20.0, 110.0, 70.0]; diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 2ee8b191c9..01c54e7643 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -4,7 +4,7 @@ //! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each //! cell's two triangles are rasterized in destination space with the source position //! interpolated barycentrically, then the source is sampled there (premultiplied, nearest / -//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so +//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so //! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine //! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each //! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per @@ -15,13 +15,7 @@ use photocraft_color::PixelFormat; use photocraft_geom::Rect; use photocraft_raster::Surface; -use crate::transform::Interp; - -fn catmull_rom(t: f64) -> [f64; 4] { - let t2 = t * t; - let t3 = t2 * t; - [-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2] -} +use crate::transform::{Interp, bicubic}; /// Grid cell size (source px) used to approximate the map. const CELL: f64 = 4.0; @@ -290,7 +284,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 let snap = |t: f64| if (t - t.round()).abs() < 1e-7 { t.round() } else { t }; let (u, v) = (snap(u), snap(v)); let (ix, iy) = (u.floor() as i32, v.floor() as i32); - let (wx, wy) = (catmull_rom(u - f64::from(ix)), catmull_rom(v - f64::from(iy))); + let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy))); for (j, wyj) in wy.iter().enumerate() { for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index cd92f584a2..d6b3c17102 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -230,9 +230,11 @@ impl Headless { (None, Some(p)) => { let own = file_cmds::extension(p); let written = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| own.clone()); - if !file_cmds::saves_in_place(p) || written != own { + if !file_cmds::holds_at(&doc, p) || written != own { + let lost = file_cmds::losses_at(&doc, p); + let why = if lost.is_empty() { String::new() } else { format!(" (it can't keep: {})", lost.join("; ")) }; return Err(AutomationError::BadRequest(format!( - "pass `path`: without one, only a PSD, PSB or .pcraft file is written back, in its own format, so `{p}` was left unchanged" + "pass `path`: without one, only a PSD, PSB, .pcraft or OpenRaster file that holds the whole document is written back, in its own format, so `{p}` was left unchanged{why}" ))); } PathBuf::from(p) @@ -255,13 +257,14 @@ impl Headless { } }; warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc)); - // A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy: - // record the current revision as saved so the session stops reporting `dirty`, and make - // the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both - // alone (#1547). + // A layered document save (PSD, PSB, OpenRaster or .pcraft) that holds the whole document is a + // full write, not a flattened copy: record the current revision as saved so the session + // stops reporting `dirty`, and make the file the document's path. A flat export (PNG, + // JPEG, …), or an OpenRaster file that drops part of it, is a copy and leaves both alone + // (#1547, #3806). let ext = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase())); - if ext.as_deref().is_some_and(file_cmds::layered_extension) { + if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) { // Saving by index must not retarget the next automation command. let previously_active = self.session.active_index(); self.session.set_active(i); diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 875850460c..5d44f0e7a7 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -60,8 +60,13 @@ fn bad(msg: impl Into) -> AutomationError { AutomationError::BadRequest(msg.into()) } -fn index_of(p: &Value) -> Option { - p.get("index").and_then(Value::as_u64).map(|i| i as usize) +/// The optional `index` param: missing or null means the active document; anything else must be a +/// non-negative integer (never silently fall back to the active document, which `doc.close` would close). +fn index_of(p: &Value) -> Result, AutomationError> { + match p.get("index") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| bad("`index` must be a non-negative integer")), + } } fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> { @@ -109,20 +114,21 @@ impl Headless { let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); - self.place(index_of(&p), path, &Value::Object(opts)) + self.place(index_of(&p)?, path, &Value::Object(opts)) } "doc.new" => self.command_run("file.new", p), "doc.save" => { let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; let mut opts = photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; + opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; if let Some(q) = p.get("quality").and_then(Value::as_u64) { opts.encode.jpeg_quality = q.clamp(1, 100) as u8; opts.encode.webp_quality = q.clamp(1, 100) as u8; opts.encode.webp_lossless = false; } let path = str_of(&p, "path").map(PathBuf::from); - self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts) + self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts) } "doc.exportLayers" | "doc.exportDataSets" => { let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?; @@ -130,16 +136,16 @@ impl Headless { opts.remove("dir"); opts.remove("index"); let opts = Value::Object(opts); - if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) } + if method == "doc.exportLayers" { self.export_layers(index_of(&p)?, dir, &opts) } else { self.export_data_sets(index_of(&p)?, dir, &opts) } } "doc.importDataSets" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?; let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); - self.import_data_sets(index_of(&p), path, &Value::Object(opts)) + self.import_data_sets(index_of(&p)?, path, &Value::Object(opts)) } - "doc.inspect" => self.inspect(index_of(&p)), + "doc.inspect" => self.inspect(index_of(&p)?), "doc.render" => { let max = match p.get("maxSide") { None => 1024, @@ -147,7 +153,7 @@ impl Headless { value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))? } }; - let png = self.render_png(index_of(&p), max)?; + let png = self.render_png(index_of(&p)?, max)?; match str_of(&p, "path") { Some(path) => { self.write_render(&PathBuf::from(path), &png)?; @@ -160,10 +166,10 @@ impl Headless { } } "doc.select" => { - let i = index_of(&p).ok_or_else(|| bad("doc.select needs `index`"))?; + let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?; self.select(i) } - "doc.close" => self.close(index_of(&p)), + "doc.close" => self.close(index_of(&p)?), "batch" => self.batch(&p), "methods" => Ok(json!(METHODS)), other => Err(bad(format!("unknown method `{other}` (try `methods`)"))), @@ -829,4 +835,27 @@ mod tests { assert_eq!(v["error"], "authentication required"); assert!(v.get("result").is_none()); } + + #[test] + fn invalid_document_index_is_rejected_not_the_active_document() { + let h = session(); + for _ in 0..2 { + assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true); + } + let count = |h: &Mutex| h.lock().unwrap().session_list()["documents"].as_array().unwrap().len(); + for index in [json!("0"), json!(-1), json!(0.5), json!(true)] { + for method in ["doc.close", "doc.inspect", "doc.select"] { + let reply = respond(&h, &json!({"method": method, "params": {"index": index}}).to_string()); + assert_eq!(reply["ok"], false, "{method} {index}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains("`index` must be a non-negative integer"), "{reply}"); + } + assert_eq!(count(&h), 2, "index {index} closed a document"); + } + // Null means "the active document", like a missing `index`. + assert_eq!(respond(&h, r#"{"method":"doc.inspect","params":{"index":null}}"#)["ok"], true); + // A valid index still closes that document. + let reply = respond(&h, r#"{"method":"doc.close","params":{"index":0}}"#); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(count(&h), 1); + } } diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c2f18b8bd5..ea1b40239d 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -167,6 +167,9 @@ pub struct SaveParams { /// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24. #[serde(default, rename = "tgaBits")] pub tga_bits: Option, + /// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16. + #[serde(default, rename = "dpxBits")] + pub dpx_bits: Option, #[serde(default)] pub index: Option, } @@ -306,7 +309,7 @@ pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, - /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab, + /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab, /// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it), /// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one /// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard), @@ -320,7 +323,8 @@ pub struct UiSetParams { /// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, - /// the Content-Aware Move tool's Transform On Drop). Other fields are an error. + /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the + /// active layer's storage tiles). Other fields are an error. pub fields: Value, } @@ -987,6 +991,11 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))), }; + let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) { + None => None, + Some((_, Some(bits))) => Some(bits), + Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), + }; if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless @@ -996,6 +1005,7 @@ impl PhotocraftMcp { p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), p.tiff_layers.then_some("tiffLayers"), + dpx_bits.is_some().then_some("dpxBits"), p.index.is_some().then_some("index"), ] .into_iter() @@ -1016,6 +1026,7 @@ impl PhotocraftMcp { let Some(r) = self .headless_op(move |h| { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; + opts.encode.dpx_bits = dpx_bits; if let Some(q) = p.quality { opts.encode.jpeg_quality = q.clamp(1, 100); opts.encode.webp_quality = q.clamp(1, 100); diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index eaed0e1c65..cde3a3a702 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -858,6 +858,8 @@ mod tests { | "path.clippingPath.set" | "path.rename" | "path.stroke" + | "type.attachToPath" + | "type.detachFromPath" | "select.toWorkPath" | "layer.vectorMask.add" | "layer.vectorMask.edit" diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/krita.rs new file mode 100644 index 0000000000..85bde4f5fc --- /dev/null +++ b/crates/automation/tests/krita.rs @@ -0,0 +1,68 @@ +//! Krita documents through the agent surface (`doc.open`, `engine.execute`, `doc.save`, as MCP +//! and the CLI use it): open a .kra with its layers, edit it, and save it as OpenRaster. + +use photocraft_automation::Headless; +use serde_json::json; + +/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). +fn tiles(bgra: [u8; 4]) -> Vec { + let n = 64 * 64; + let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec(); + out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes()); + out.push(0); + for b in bgra { + out.extend(std::iter::repeat_n(b, n)); + } + out +} + +fn kra() -> Vec { + let maindoc = r#" + + + + + + + +"#; + let mut z = photocraft_format::zip::ZipWriter::new(); + z.add("mimetype", b"application/x-krita").unwrap(); + z.add("maindoc.xml", maindoc.as_bytes()).unwrap(); + z.add("art/layers/layer2", &tiles([0, 0, 255, 255])).unwrap(); + z.add("art/layers/layer4", &tiles([255, 255, 255, 255])).unwrap(); + z.finish().unwrap() +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + h.handle("engine.execute", json!({"command": command, "params": params})).unwrap() +} + +#[test] +fn krita_documents_open_with_layers_and_save_as_openraster() { + let dir = std::env::temp_dir().join(format!("pc-krita-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("art.kra"); + std::fs::write(&path, kra()).unwrap(); + let mut h = Headless::trusted_local(); + let opened = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap(); + let warnings = opened["warnings"].to_string(); + assert!(warnings.contains("vector layers") && warnings.contains("Speech bubble"), "{opened}"); + let doc = &h.session.active().unwrap().doc; + let names: Vec<&str> = doc.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Paper", "Inks"]); + let line = doc.layers[1].children().unwrap()[0].clone(); + assert_eq!((line.name.as_str(), line.blend), ("Line", photocraft_doc::BlendMode::Multiply)); + let flat = photocraft_compose::flatten(doc); + assert!((flat.px[0][1] - 0.2).abs() < 0.01, "80% red multiplied over white: {:?}", flat.px[0]); + + // Read-only source: the file itself is never overwritten as Krita. + assert!(h.handle("doc.save", json!({"path": path.to_str().unwrap()})).is_err()); + exec(&mut h, "layer.setProps", json!({"layer": line.id.0, "opacity": 0.5})); + let ora = dir.join("art.ora"); + h.handle("doc.save", json!({"path": ora.to_str().unwrap()})).unwrap(); + let back = photocraft_io::import("art.ora", &std::fs::read(&ora).unwrap()).unwrap().document; + assert_eq!(back.layers.len(), 2); + assert_eq!(back.layers[1].children().unwrap()[0].opacity, 0.5); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/openraster.rs b/crates/automation/tests/openraster.rs index fe941c50b4..428ecfffb9 100644 --- a/crates/automation/tests/openraster.rs +++ b/crates/automation/tests/openraster.rs @@ -66,3 +66,40 @@ fn a_corrupt_openraster_file_reports_an_error() { assert!(err.to_string().contains("OpenRaster"), "{err}"); std::fs::remove_dir_all(dir).unwrap(); } + +/// #3806: an OpenRaster file holds only what OpenRaster can. Once the document has an adjustment +/// layer, a pathless save refuses to overwrite the .ora (saying what it would drop), and a save to +/// an .ora path writes a copy: the document keeps its file and stays unsaved. +#[test] +fn an_openraster_file_never_overwrites_what_it_cannot_hold() { + let dir = tmp("lossy"); + let path = dir.join("art.ora"); + let path_str = path.to_str().unwrap(); + let mut h = Headless::trusted_local(); + h.handle("doc.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + exec(&mut h, "layer.new.layer", json!({})); + exec(&mut h, "paint.stroke", json!({"points": [[4, 4], [30, 20]], "size": 6, "hardness": 1.0, "color": "#ff0000"})); + h.handle("doc.save", json!({"path": path_str})).unwrap(); + let original = std::fs::read(&path).unwrap(); + // A Curves adjustment layer: OpenRaster has no adjustment layers. + exec(&mut h, "layer.newAdjustmentLayer.curves", json!({})); + assert!(h.session.active().unwrap().is_dirty()); + let err = h.handle("doc.save", json!({})).unwrap_err().to_string(); + assert!(err.contains("adjustment layers"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), original, "the original is untouched"); + assert!(h.session.active().unwrap().is_dirty(), "and the edits are still unsaved"); + // Saving to an .ora path explicitly writes a copy (with the warnings), not the document's file. + let copy = dir.join("copy.ora"); + let saved = h.handle("doc.save", json!({"path": copy.to_str().unwrap()})).unwrap(); + assert!(saved["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("adjustment layers")))), "{saved}"); + let st = h.session.active().unwrap(); + assert!(st.is_dirty(), "a copy leaves the edits unsaved"); + assert_eq!(st.path.as_deref(), Some(path_str), "the document keeps its file"); + // As PSD it holds everything, so that becomes the document's file. + let psd = dir.join("art.psd"); + h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap(); + let st = h.session.active().unwrap(); + assert!(!st.is_dirty()); + assert_eq!(st.path.as_deref(), psd.to_str()); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index b102562f4a..9d42fef3f6 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -32,6 +32,10 @@ pub enum Builtin { GrayGamma22, /// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB. SGray, + /// Gray matching Photoshop's default working gray (Dot Gain 20 %) with our own curve: a pure + /// gamma of 1.732, fitted on Photoshop 27.11's RGB → Grayscale (sRGB 64 / 128 / 192 and pure red + /// become 46 / 105 / 177 / 107, all within 1 level). Not Adobe's profile. + DotGain20Compat, /// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding). LabD50, /// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`]. @@ -39,7 +43,7 @@ pub enum Builtin { } impl Builtin { - pub const ALL: [Builtin; 10] = [ + pub const ALL: [Builtin; 11] = [ Builtin::Srgb, Builtin::DisplayP3, Builtin::AdobeRgbCompat, @@ -48,6 +52,7 @@ impl Builtin { Builtin::Rec2020, Builtin::GrayGamma22, Builtin::SGray, + Builtin::DotGain20Compat, Builtin::LabD50, Builtin::CoatedCmyk, ]; @@ -63,6 +68,7 @@ impl Builtin { Builtin::Rec2020 => "rec2020", Builtin::GrayGamma22 => "gray-gamma-2.2", Builtin::SGray => "sgray", + Builtin::DotGain20Compat => "dot-gain-20-compat", Builtin::LabD50 => "lab-d50", Builtin::CoatedCmyk => "coated-cmyk", } @@ -79,6 +85,7 @@ impl Builtin { Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)", Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)", Builtin::SGray => "sGray (sRGB tone curve, Photocraft)", + Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)", Builtin::LabD50 => "Lab D50 identity (Photocraft)", Builtin::CoatedCmyk => crate::synth::DESCRIPTION, } @@ -96,6 +103,7 @@ impl Builtin { "rec2020" | "bt2020" => Some(Builtin::Rec2020), "gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22), "sgray" | "graysrgb" => Some(Builtin::SGray), + "dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat), "lab" | "labd50" | "cielab" => Some(Builtin::LabD50), "cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk), _ => None, @@ -105,18 +113,19 @@ impl Builtin { /// The profile (built once per process). pub fn profile(self) -> &'static Profile { - static CELLS: [OnceLock; 10] = [const { OnceLock::new() }; 10]; + static CELLS: [OnceLock; 11] = [const { OnceLock::new() }; 11]; // `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`), // so the discriminant is the cell index. CELLS[self as usize].get_or_init(|| build(self)) } } -/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50. +/// The default profile for a colour space: sRGB, Dot Gain 20 %-compatible gray (Photoshop's default +/// working gray), the coated CMYK profile, Lab D50. pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> { Some(match cs { ColorSpace::Rgb => Builtin::Srgb.profile(), - ColorSpace::Gray => Builtin::SGray.profile(), + ColorSpace::Gray => Builtin::DotGain20Compat.profile(), ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(), ColorSpace::Lab => Builtin::LabD50.profile(), _ => return None, @@ -268,6 +277,7 @@ fn build(b: Builtin) -> Profile { } Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)), Builtin::SGray => gray(b.description(), srgb_trc()), + Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)), Builtin::LabD50 => lab(), // The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild // them rather than crash if they ever fail to parse. diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e44890a0d9..2409c46e07 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -40,11 +40,13 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` | | ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | +| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | | QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | +| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -66,6 +68,12 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded ## Documented asymmetries and limitations +* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks + (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), + with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are + ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats + (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided. + * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`. Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit @@ -74,6 +82,19 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with `CodecError::Unsupported`; Bayer mosaics open undebayered. +* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element, + descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits. + 10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data + packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16 + `round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No + colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and + colorimetric specification, so a log frame is reported as log. More elements than the first + give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is + big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for + U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. + Other descriptors, signed or run-length-encoded data and other bit depths return + `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. + * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs new file mode 100644 index 0000000000..efe93c7f2e --- /dev/null +++ b/crates/codecs/src/codecs/dds.rs @@ -0,0 +1,284 @@ +//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed +//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed +//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps, +//! texture arrays and volume textures are refused rather than flattened to one face or slice. + +use crate::SampleType; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image}; +use crate::options::Limits; + +/// `DDS ` plus the 124-byte DDS_HEADER. +const HEADER: usize = 128; +/// DDS_HEADER_DXT10, present when the FourCC is `DX10`. +const DX10_HEADER: usize = 20; + +const DDSD_DEPTH: u32 = 0x80_0000; +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; +const DDSCAPS2_CUBEMAP: u32 = 0x200; +const DDSCAPS2_VOLUME: u32 = 0x20_0000; +const DIMENSION_TEXTURE2D: u32 = 3; +const MISC_TEXTURECUBE: u32 = 0x4; + +fn bad(reason: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::Dds, reason) +} +fn unsupported(reason: impl Into) -> CodecError { + CodecError::unsupported(Format::Dds, reason) +} +fn le32(b: &[u8], at: usize) -> Result { + let bytes = b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated header"))?; + Ok(u32::from_le_bytes(bytes)) +} + +/// A FourCC as text when it is printable, else as the number legacy writers store there. +fn fourcc_name(fourcc: u32) -> String { + let bytes = fourcc.to_le_bytes(); + if bytes.iter().all(|c| c.is_ascii_graphic() || *c == b' ') { bytes.iter().map(|&c| char::from(c)).collect() } else { fourcc.to_string() } +} + +/// One channel of an uncompressed pixel: where it sits and its largest value. +#[derive(Clone, Copy)] +struct Field { + shift: u32, + max: u32, +} + +impl Field { + /// `None` for an empty mask; an error for one with gaps or wider than the pixel. + fn new(mask: u32, bits: u32) -> Result, CodecError> { + if mask == 0 { + return Ok(None); + } + let shift = mask.trailing_zeros(); + let max = mask >> shift; + if max & max.wrapping_add(1) != 0 || (bits < 32 && mask >> bits != 0) { + return Err(bad(format!("channel mask {mask:#x} is not a contiguous field of a {bits}-bit pixel"))); + } + Ok(Some(Field { shift, max })) + } + + fn get(self, pixel: u32) -> u8 { + let v = u64::from((pixel >> self.shift) & self.max); + let max = u64::from(self.max); + ((v * 255 + max / 2) / max) as u8 + } +} + +enum Encoding { + /// Uncompressed: bytes per pixel, then red (or luminance), green, blue and alpha. + Masks { + bytes: usize, + gray: bool, + fields: [Option; 4], + }, + Bc1, + Bc2, + Bc3, +} + +impl Encoding { + fn masks(bits: u32, masks: [u32; 4], gray: bool) -> Result { + if !matches!(bits, 8 | 16 | 24 | 32) { + return Err(bad(format!("{bits} bits per pixel is not 8, 16, 24 or 32"))); + } + let mut fields = [None; 4]; + for (field, mask) in fields.iter_mut().zip(masks) { + *field = Field::new(mask, bits)?; + } + let colour = if gray { fields[0].is_some() } else { fields[..3].iter().any(Option::is_some) }; + if !colour { + return Err(bad("the pixel format has no colour channel masks")); + } + Ok(Encoding::Masks { bytes: bits as usize / 8, gray, fields }) + } + + fn layout(&self) -> ChannelLayout { + match self { + Encoding::Masks { gray: true, fields, .. } if fields[3].is_some() => ChannelLayout::GrayA, + Encoding::Masks { gray: true, .. } => ChannelLayout::Gray, + Encoding::Masks { fields, .. } if fields[3].is_none() => ChannelLayout::Rgb, + _ => ChannelLayout::Rgba, + } + } + + /// Bytes of the top mip level. + fn level_size(&self, width: u32, height: u32) -> Option { + let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?); + let block = match self { + Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h), + Encoding::Bc1 => 8, + Encoding::Bc2 | Encoding::Bc3 => 16, + }; + w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block) + } +} + +/// The legacy pixel format: a FourCC, or channel masks. +fn legacy_encoding(b: &[u8]) -> Result { + let (flags, fourcc, bits) = (le32(b, 80)?, le32(b, 84)?, le32(b, 88)?); + let alpha = if flags & DDPF_ALPHAPIXELS != 0 { le32(b, 104)? } else { 0 }; + if flags & DDPF_FOURCC != 0 { + return match &fourcc.to_le_bytes() { + b"DXT1" => Ok(Encoding::Bc1), + b"DXT3" => Ok(Encoding::Bc2), + b"DXT5" => Ok(Encoding::Bc3), + b"DXT2" | b"DXT4" => Err(unsupported("premultiplied-alpha DXT2/DXT4 textures are not supported")), + _ => Err(unsupported(format!("compression `{}` is not supported (DXT1, DXT3 and DXT5 are)", fourcc_name(fourcc)))), + }; + } + if flags & DDPF_RGB != 0 { + return Encoding::masks(bits, [le32(b, 92)?, le32(b, 96)?, le32(b, 100)?, alpha], false); + } + if flags & DDPF_LUMINANCE != 0 { + return Encoding::masks(bits, [le32(b, 92)?, 0, 0, alpha], true); + } + Err(unsupported(format!("pixel format flags {flags:#x} are not RGB, luminance or a supported compression"))) +} + +/// The DX10 header's DXGI format, refusing anything but a single 2D texture. +fn dx10_encoding(b: &[u8]) -> Result { + let (dxgi, dimension, misc, array) = (le32(b, HEADER)?, le32(b, HEADER + 4)?, le32(b, HEADER + 8)?, le32(b, HEADER + 12)?); + if misc & MISC_TEXTURECUBE != 0 { + return Err(unsupported("cubemaps are not supported")); + } + if dimension != DIMENSION_TEXTURE2D { + return Err(unsupported(format!("only 2D textures are supported (resource dimension {dimension})"))); + } + if array > 1 { + return Err(unsupported(format!("texture arrays are not supported ({array} textures)"))); + } + const RGBA: [u32; 4] = [0xff, 0xff00, 0xff_0000, 0xff00_0000]; + const BGRA: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0xff00_0000]; + const BGRX: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0]; + match dxgi { + 27..=29 => Encoding::masks(32, RGBA, false), + 87 | 90 | 91 => Encoding::masks(32, BGRA, false), + 88 | 92 | 93 => Encoding::masks(32, BGRX, false), + 70..=72 => Ok(Encoding::Bc1), + 73..=75 => Ok(Encoding::Bc2), + 76..=78 => Ok(Encoding::Bc3), + _ => Err(unsupported(format!("DXGI format {dxgi} is not supported (BC1, BC2, BC3 and 8-bit RGBA or BGRA are)"))), + } +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !b.starts_with(b"DDS ") { + return Err(bad("missing the `DDS ` signature")); + } + if le32(b, 4)? != 124 { + return Err(bad("header size is not 124")); + } + let (flags, height, width, depth, caps2) = (le32(b, 8)?, le32(b, 12)?, le32(b, 16)?, le32(b, 24)?, le32(b, 112)?); + if caps2 & DDSCAPS2_CUBEMAP != 0 { + return Err(unsupported("cubemaps are not supported")); + } + if caps2 & DDSCAPS2_VOLUME != 0 || (flags & DDSD_DEPTH != 0 && depth > 1) { + return Err(unsupported("volume textures are not supported")); + } + let dx10 = le32(b, 80)? & DDPF_FOURCC != 0 && &le32(b, 84)?.to_le_bytes() == b"DX10"; + let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) }; + let layout = encoding.layout(); + limits.check(width, height, layout, SampleType::U8)?; + let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?; + let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?; + let (w, h) = (width as usize, height as usize); + let channels = layout.channels(); + let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?; + let mut pixels = vec![0u8; total]; + match encoding { + Encoding::Masks { bytes, gray, fields } => { + for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) { + let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); + let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); + let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; + dst.copy_from_slice(&values[..channels]); + } + } + Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { + let block_size = if matches!(encoding, Encoding::Bc1) { 8 } else { 16 }; + let blocks_x = w.div_ceil(4); + for (i, block) in data.chunks_exact(block_size).enumerate() { + let texels = match encoding { + Encoding::Bc1 => bc1(block, true), + Encoding::Bc2 => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc2_alpha(block)), + _ => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc3_alpha(block)), + }; + let (bx, by) = ((i % blocks_x) * 4, (i / blocks_x) * 4); + for (t, texel) in texels.iter().enumerate() { + let (x, y) = (bx + t % 4, by + t / 4); + if x < w && y < h { + let at = (y * w + x) * 4; + if let Some(dst) = pixels.get_mut(at..at + 4) { + dst.copy_from_slice(texel); + } + } + } + } + } + } + Image::from_u8(width, height, layout, pixels) +} + +fn rgb565(c: u16) -> [u8; 3] { + let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8; + [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)] +} + +/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's +/// `c0 <= c1` order selects three colours and transparent black. +fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] { + let byte = |i: usize| block.get(i).copied().unwrap_or(0); + let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)])); + let (a, b) = (rgb565(c0), rgb565(c1)); + let mix = |wa: u32, wb: u32| { + let d = wa + wb; + let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8; + [at(0), at(1), at(2), 255] + }; + let palette = if c0 > c1 || !one_bit_alpha { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)] + } else { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]] + }; + let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]); + std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3]) +} + +/// BC2: four explicit bits of alpha per texel. +fn bc2_alpha(block: &[u8]) -> [u8; 16] { + let bits = u64::from_le_bytes(std::array::from_fn(|i| block.get(i).copied().unwrap_or(0))); + std::array::from_fn(|t| ((bits >> (4 * t)) & 0xf) as u8 * 17) +} + +/// BC3: two alpha endpoints and a 3-bit index per texel into eight values, or into six plus +/// 0 and 255 when `a0 <= a1`. +fn bc3_alpha(block: &[u8]) -> [u8; 16] { + let byte = |i: usize| block.get(i).copied().unwrap_or(0); + let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1))); + let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255]; + if a0 > a1 { + for (k, v) in palette.iter_mut().enumerate().skip(2) { + let k = k as u32; + *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7; + } + } else { + for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) { + let k = k as u32; + *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5; + } + } + let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]); + std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8) +} + +fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] { + for (texel, a) in texels.iter_mut().zip(alpha) { + texel[3] = a; + } + texels +} diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs new file mode 100644 index 0000000000..8b772707cc --- /dev/null +++ b/crates/codecs/src/codecs/dpx.rs @@ -0,0 +1,656 @@ +//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the +//! 1664-byte generic header (file and image information, image source), the 384-byte industry +//! header (film and television), then the image data of each element. +//! +//! Reading: +//! +//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian). +//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without +//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words +//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the +//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits +//! (1, method A). Lines start on a 32-bit word boundary. +//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16 +//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth. +//! * No colour transform: the transfer characteristic and colorimetric specification are reported +//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred. +//! * The image orientation is applied on import (the caller turns the image upright). +//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a +//! DPX export writes them back. +//! +//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with +//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image +//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one. + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType}; +use crate::options::{DpxBits, EncodeOptions, Limits}; + +const F: Format = Format::Dpx; +/// Generic plus industry header: where our image data starts. +const HEADER_LEN: usize = 2048; +const GENERIC_LEN: u32 = 1664; +const INDUSTRY_LEN: u32 = 384; +/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float). +const UNDEF32: u32 = u32::MAX; +const UNDEF8: u8 = u8::MAX; +/// Offset of the first image element and the size of one. +const ELEMENT0: usize = 780; +const ELEMENT_LEN: usize = 72; +const MAX_ELEMENTS: u16 = 8; + +// Header field offsets (generic file header, image information, film and TV headers). +const CREATION_TIME: (usize, usize) = (136, 24); +const CREATOR: (usize, usize) = (160, 100); +const PROJECT: (usize, usize) = (260, 200); +const COPYRIGHT: (usize, usize) = (460, 200); +const ORIENTATION: usize = 768; +const FRAME_POSITION: usize = 1712; +const FRAME_RATE: usize = 1724; +const TIME_CODE: usize = 1920; + +/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip. +pub(crate) const KEY_CREATOR: &str = "DPX:Creator"; +pub(crate) const KEY_PROJECT: &str = "DPX:Project"; +pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright"; +pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime"; +pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer"; +pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric"; +pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition"; +pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate"; +pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode"; +pub(crate) const KEYS: [&str; 9] = + [KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE]; + +fn err(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +fn unsupported(msg: impl Into) -> CodecError { + CodecError::unsupported(F, msg) +} + +/// The file's byte order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Order { + Big, + Little, +} + +impl Order { + fn bytes(b: &[u8], at: usize) -> Option<[u8; N]> { + b.get(at..at.checked_add(N)?)?.try_into().ok() + } + + fn u16(self, b: &[u8], at: usize) -> Option { + let s = Self::bytes::<2>(b, at)?; + Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) }) + } + + fn u32(self, b: &[u8], at: usize) -> Option { + let s = Self::bytes::<4>(b, at)?; + Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) }) + } + + /// A 32-bit field that may be undefined (all ones). + fn defined_u32(self, b: &[u8], at: usize) -> Option { + self.u32(b, at).filter(|&v| v != UNDEF32) + } + + fn f32(self, b: &[u8], at: usize) -> Option { + self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite()) + } +} + +/// The byte order from the magic number, `None` when it is not DPX. +fn order(b: &[u8]) -> Option { + match b.get(..4)? { + b"SDPX" => Some(Order::Big), + b"XPDS" => Some(Order::Little), + _ => None, + } +} + +/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when +/// empty or undefined (all ones). +fn string(b: &[u8], (at, len): (usize, usize)) -> Option { + let raw = b.get(at..at.checked_add(len)?)?; + let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); + let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect(); + Some(s.trim().to_string()).filter(|s| !s.is_empty()) +} + +/// The descriptive fields of image element 0 that decoding needs. +#[derive(Debug, Clone, Copy)] +struct Element { + signed: bool, + descriptor: u8, + transfer: u8, + colorimetric: u8, + bits: u8, + packing: u16, + encoding: u16, + data_offset: usize, + eol_padding: usize, +} + +struct Header { + order: Order, + width: u32, + height: u32, + elements: u16, + element: Element, +} + +fn parse(b: &[u8]) -> Result { + let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?; + let missing = |what: &str| err(format!("the header ends before {what}")); + let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?; + o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?; + let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?; + if !(1..=MAX_ELEMENTS).contains(&elements) { + return Err(err(format!("invalid number of image elements {elements}"))); + } + let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?; + let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?; + let e = ELEMENT0; + let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1")); + let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1")); + let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1")); + let data_offset = match field(28)? { + 0 | UNDEF32 => image_offset, + v => v, + }; + let eol_padding = match field(32)? { + UNDEF32 => 0, + v => v, + }; + let element = Element { + signed: field(0)? == 1, + descriptor: byte(20)?, + transfer: byte(21)?, + colorimetric: byte(22)?, + bits: byte(23)?, + packing: half(24)?, + encoding: half(26)?, + data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?, + eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?, + }; + Ok(Header { order: o, width, height, elements, element }) +} + +/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7 +/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…). +/// Anything else, undefined included, is taken as the default (1). +pub(crate) fn orientation(b: &[u8]) -> u16 { + let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 }; + match o { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } +} + +/// Bytes in one stored line of `samples` samples, padded to a 32-bit word. +fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option { + let words = match (bits, packing) { + // Three samples to a word, or one to a 16-bit half word. + (10, 1 | 2) => samples.div_ceil(3), + (12, 1 | 2) => samples.div_ceil(2), + _ => samples.checked_mul(usize::from(bits))?.div_ceil(32), + }; + words.checked_mul(4) +} + +/// Code values of one stored line. +fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> { + let short = || err("truncated image data"); + match (bits, packing) { + (8, _) => { + let src = line.get(..out.len()).ok_or_else(short)?; + for (v, &c) in out.iter_mut().zip(src) { + *v = u16::from(c); + } + } + (16, _) => { + for (i, v) in out.iter_mut().enumerate() { + *v = o.u16(line, i * 2).ok_or_else(short)?; + } + } + (10, 1 | 2) => { + let base = if packing == 1 { 22 } else { 20 }; + for (i, v) in out.iter_mut().enumerate() { + let word = o.u32(line, i / 3 * 4).ok_or_else(short)?; + *v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16; + } + } + (12, 1 | 2) => { + let shift = if packing == 1 { 4 } else { 0 }; + for (i, v) in out.iter_mut().enumerate() { + *v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF; + } + } + // Packing 0: one bit stream through the line's 32-bit words, high bits first. + _ => { + let mask = (1u64 << bits) - 1; + let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize); + for v in out.iter_mut() { + while have < u32::from(bits) { + acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?); + word += 1; + have += 32; + } + have -= u32::from(bits); + *v = ((acc >> have) & mask) as u16; + } + } + } + Ok(()) +} + +/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`. +fn widen(c: u16, max: u64) -> u16 { + ((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16 +} + +/// The inverse of [`widen`]: `round(v × max / 65535)`. +fn narrow(v: u16, max: u64) -> u16 { + ((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16 +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let h = parse(bytes)?; + let e = h.element; + let layout = match e.descriptor { + 6 => ChannelLayout::Gray, + 50 => ChannelLayout::Rgb, + 51 => ChannelLayout::Rgba, + d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))), + }; + if e.signed { + return Err(unsupported("data sign 1 (signed samples) is not supported")); + } + if e.encoding != 0 { + return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding))); + } + let packing_ok = match e.bits { + 8 | 16 => e.packing == 0, + 10 => e.packing <= 2, + 12 => e.packing <= 1, + b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))), + }; + if !packing_ok { + return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits))); + } + let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 }; + limits.check(h.width, h.height, layout, sample)?; + + let nc = layout.channels(); + let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + let height = h.height as usize; + let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?; + let too_large = || CodecError::LimitExceeded("image too large".into()); + let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?; + let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?; + // Lines run `stride` apart; the last one needs no padding after it. + let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len); + // Some writers pack 8- and 16-bit lines without padding them to a word; take that layout + // when the file is too short for padded lines but holds the unpadded ones. + let tight = per_line * usize::from(e.bits / 8); + if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) { + (line_len, stride) = (tight, tight); + } + if span(stride, line_len).ok_or_else(too_large)? > data.len() { + return Err(err("truncated image data")); + } + + let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?]; + for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() { + let start = y * stride; + let line = data.get(start..).ok_or_else(|| err("truncated image data"))?; + unpack_line(line, h.order, e.bits, e.packing, row)?; + } + + let mut img = match e.bits { + 8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?, + 16 => Image::from_u16(h.width, h.height, layout, &codes)?, + b => { + let max = (1u64 << b) - 1; + let wide: Vec = codes.iter().map(|&c| widen(c, max)).collect(); + Image::from_u16(h.width, h.height, layout, &wide)? + } + }; + if h.elements > 1 { + img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) }); + } + if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 { + img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric }); + } + img.meta.text = header_text(bytes, h.order, e); + Ok(img) +} + +/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. +fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { + let mut text = Vec::new(); + let mut put = |k: &str, v: Option| { + if let Some(v) = v { + text.push((k.to_string(), v)); + } + }; + put(KEY_CREATOR, string(b, CREATOR)); + put(KEY_PROJECT, string(b, PROJECT)); + put(KEY_COPYRIGHT, string(b, COPYRIGHT)); + put(KEY_CREATION_TIME, string(b, CREATION_TIME)); + put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string())); + put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string())); + // The industry header is there when the generic header says so and the file holds it. + let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN; + if industry { + put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string())); + put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string())); + put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code)); + } + text +} + +/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD. +fn format_time_code(v: u32) -> String { + let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF); + if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") } +} + +/// The inverse of [`format_time_code`]; `None` for anything else. +fn parse_time_code(s: &str) -> Option { + if let Some(hex) = s.strip_prefix("0x") { + return u32::from_str_radix(hex, 16).ok(); + } + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) { + return None; + } + parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0'))) +} + +/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise. +pub(crate) fn bits_for(opt: Option, sample: SampleType) -> u8 { + match opt { + Some(b) => b.bits(), + None if sample == SampleType::U8 => 8, + None => 16, + } +} + +/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16. +pub(crate) fn plan_sample(opt: Option, src: SampleType) -> SampleType { + match opt { + Some(DpxBits::Bits8) => SampleType::U8, + Some(_) => SampleType::U16, + None if src == SampleType::U8 => SampleType::U8, + None => SampleType::U16, + } +} + +struct Writer { + h: Vec, +} + +impl Writer { + fn u8(&mut self, at: usize, v: u8) { + if let Some(b) = self.h.get_mut(at) { + *b = v; + } + } + + fn u16(&mut self, at: usize, v: u16) { + if let Some(s) = self.h.get_mut(at..at + 2) { + s.copy_from_slice(&v.to_be_bytes()); + } + } + + fn u32(&mut self, at: usize, v: u32) { + if let Some(s) = self.h.get_mut(at..at + 4) { + s.copy_from_slice(&v.to_be_bytes()); + } + } + + /// Marks a run of numeric fields undefined (all ones). + fn undefined(&mut self, from: usize, to: usize) { + if let Some(s) = self.h.get_mut(from..to) { + s.fill(0xFF); + } + } + + /// An ASCII string field, cut to leave room for a terminating NUL. + fn string(&mut self, (at, len): (usize, usize), v: &str) { + let ascii: Vec = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect(); + if let Some(s) = self.h.get_mut(at..at + ascii.len()) { + s.copy_from_slice(&ascii); + } + } +} + +fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> { + img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty()) +} + +/// Appends one line's code values, padded to a 32-bit word. +fn pack_line(out: &mut Vec, codes: &[u16], bits: u8) { + let start = out.len(); + match bits { + 8 => out.extend(codes.iter().map(|&c| c as u8)), + 10 => { + for chunk in codes.chunks(3) { + let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32))); + out.extend_from_slice(&word.to_be_bytes()); + } + } + 12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())), + _ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())), + } + let written = out.len() - start; + out.resize(start + written.div_ceil(4) * 4, 0); +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = src.converted(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + let (descriptor, layout) = match img.layout() { + ChannelLayout::Gray => (6u8, ChannelLayout::Gray), + ChannelLayout::Rgb => (50, ChannelLayout::Rgb), + ChannelLayout::Rgba => (51, ChannelLayout::Rgba), + l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))), + }; + let bits = bits_for(opts.dpx_bits, img.sample_type()); + let codes: Vec = match (img.sample_type(), bits) { + (SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(), + (SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(), + (SampleType::U16, 10 | 12) => { + let max = (1u64 << bits) - 1; + img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect() + } + (s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))), + }; + let per_line = w as usize * layout.channels(); + if codes.len() != per_line * h as usize { + return Err(CodecError::encode(F, "the image has the wrong number of samples")); + } + let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 }) + .and_then(|l| l.checked_mul(h as usize)) + .ok_or_else(|| CodecError::encode(F, "image too large"))?; + let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?; + + let mut wr = Writer { h: vec![0; HEADER_LEN] }; + // Generic file header. + wr.h[..4].copy_from_slice(b"SDPX"); + wr.u32(4, HEADER_LEN as u32); + wr.string((8, 8), "V2.0"); + wr.u32(16, total); + wr.u32(20, 1); + wr.u32(24, GENERIC_LEN); + wr.u32(28, INDUSTRY_LEN); + wr.u32(32, 0); + let keep = |key| if opts.embed_metadata { text(&img, key) } else { None }; + wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or("")); + wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft")); + wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or("")); + wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or("")); + wr.u32(660, UNDEF32); + // Image information: default orientation, one element. + wr.u16(ORIENTATION, 0); + wr.u16(ORIENTATION + 2, 1); + wr.u32(772, w); + wr.u32(776, h); + let e = ELEMENT0; + wr.u32(e, 0); + wr.u32(e + 4, 0); + wr.u32(e + 8, UNDEF32); + wr.u32(e + 12, ((1u32 << bits) - 1).min(65535)); + wr.u32(e + 16, UNDEF32); + wr.u8(e + 20, descriptor); + // No claim about the encoding unless the document came with one. + let code = |key| keep(key).and_then(|v| v.parse::().ok()).unwrap_or(UNDEF8); + wr.u8(e + 21, code(KEY_TRANSFER)); + wr.u8(e + 22, code(KEY_COLORIMETRIC)); + wr.u8(e + 23, bits); + wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 }); + wr.u16(e + 26, 0); + wr.u32(e + 28, HEADER_LEN as u32); + wr.u32(e + 32, 0); + wr.u32(e + 36, 0); + // The seven unused elements, the image source header's numbers and the industry header's + // numbers are undefined; their strings stay empty. + wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS)); + wr.undefined(1408, 1432); + wr.undefined(1620, 1644); + wr.undefined(FRAME_POSITION, 1732); + wr.undefined(TIME_CODE, 1972); + if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::().ok()) { + wr.u32(FRAME_POSITION, v); + } + if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { + wr.u32(FRAME_RATE, v.to_bits()); + } + if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) { + wr.u32(TIME_CODE, v); + } + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?; + for row in codes.chunks_exact(per_line.max(1)) { + pack_line(&mut out, row, bits); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal header for `w`×`h` with element 0 set; image data at 2048. + fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec { + let mut b = vec![0u8; HEADER_LEN]; + let put32 = |b: &mut Vec, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() }); + let put16 = |b: &mut Vec, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() }); + b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" }); + put32(&mut b, 4, HEADER_LEN as u32); + put32(&mut b, 28, INDUSTRY_LEN); + put16(&mut b, ORIENTATION, 0); + put16(&mut b, ORIENTATION + 2, 1); + put32(&mut b, 772, w); + put32(&mut b, 776, h); + b[ELEMENT0 + 20] = descriptor; + b[ELEMENT0 + 21] = UNDEF8; + b[ELEMENT0 + 22] = UNDEF8; + b[ELEMENT0 + 23] = bits; + put16(&mut b, ELEMENT0 + 24, packing); + put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32); + b[FRAME_POSITION..1732].fill(0xFF); + b[TIME_CODE..1972].fill(0xFF); + b + } + + fn words(o: Order, ws: &[u32]) -> Vec { + ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect() + } + + #[test] + fn ten_bit_method_a_and_b_unpack_three_to_a_word() { + for o in [Order::Big, Order::Little] { + for (packing, base) in [(1u16, 22u32), (2, 20)] { + let mut b = header(o, 1, 1, 50, 10, packing); + b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))])); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}"); + } + } + } + + #[test] + fn ten_bit_packing_0_is_one_bit_stream_per_line() { + // Four luma samples: 40 bits over two words, the line padded to the second word. + let samples = [1u64, 2, 1022, 1023]; + let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24; + for o in [Order::Big, Order::Little] { + let mut b = header(o, 4, 2, 6, 10, 0); + let line = words(o, &[(stream >> 32) as u32, stream as u32]); + b.extend(&line); + b.extend(&line); + let img = decode(&b, &Limits::default()).unwrap(); + let want: Vec = samples.iter().map(|&s| widen(s as u16, 1023)).collect(); + assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}"); + } + } + + #[test] + fn twelve_bit_method_a_keeps_the_top_bits() { + let mut b = header(Order::Big, 2, 1, 6, 12, 1); + b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]); + } + + #[test] + fn widen_and_narrow_round_trip_every_code() { + for bits in [10u8, 12] { + let max = (1u64 << bits) - 1; + for c in 0..=max as u16 { + assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}"); + } + } + assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16); + } + + #[test] + fn orientation_codes_map_to_exif() { + let mut b = header(Order::Big, 1, 1, 6, 8, 0); + for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] { + b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes()); + assert_eq!(orientation(&b), exif, "DPX {dpx}"); + } + } + + #[test] + fn time_codes_round_trip() { + assert_eq!(format_time_code(0x0102_0304), "01:02:03:04"); + assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304)); + assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000)); + assert_eq!(parse_time_code("1:2:3:4"), None); + } + + #[test] + fn eight_bit_lines_without_word_padding_still_open() { + // 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8. + let mut b = header(Order::Big, 1, 2, 50, 8, 0); + b.extend_from_slice(&[1, 2, 3, 4, 5, 6]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]); + } +} diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs index 2fabd0c6c5..5f13f98621 100644 --- a/crates/codecs/src/codecs/exr_cryptomatte.rs +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -1,7 +1,8 @@ //! Cryptomatte (specification 1.2): per-pixel object/material IDs with coverage in EXR //! channels, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles. Layers are //! announced by `cryptomatte//name|hash|conversion|manifest` header attributes; the -//! samples live in `NN.red` (ID) / `NN.green` (coverage) channel pairs. +//! samples live in `NN` RGBA channel sets, each holding two (ID, coverage) ranks: +//! `.red`/`.green` (rank 2·NN) and `.blue`/`.alpha` (rank 2·NN+1). //! //! IDs are the float *values*: MurmurHash3 x86-32 (seed 0) of the object name, its //! exponent fixed away from 0/255 so no ID is denormal, infinite or NaN, read as an f32 @@ -20,21 +21,26 @@ use crate::options::Limits; const F: Format = Format::OpenExr; /// A Cryptomatte stream channel (`crypto_asset00.red`) splits into its stream name -/// (`crypto_asset00`), the layer prefix without digits (`crypto_asset`), the stream index, -/// and whether it is the coverage (green) channel instead of the ID (red). +/// (`crypto_asset00`), the stream index, whether it is a coverage channel (green/alpha) +/// instead of an ID (red/blue), and its rank within the set (0 for red/green, 1 for +/// blue/alpha). pub(crate) struct StreamChannel { pub stream: String, pub index: u32, pub coverage: bool, + pub rank: usize, } /// `NN` where NN is one or more digits (the reference readers match `\d+`), with a -/// `.red`/`.r` (ID) or `.green`/`.g` (coverage) suffix. +/// `.red`/`.r` or `.blue`/`.b` (ID) or `.green`/`.g` or `.alpha`/`.a` (coverage) suffix, +/// in any case (Blender writes `.R`/`.G`/`.B`/`.A`). pub(crate) fn split_stream(channel: &str) -> Option { let (stream, suffix) = channel.rsplit_once('.')?; - let coverage = match suffix { - "red" | "r" => false, - "green" | "g" => true, + let (coverage, rank) = match suffix.to_ascii_lowercase().as_str() { + "red" | "r" => (false, 0), + "green" | "g" => (true, 0), + "blue" | "b" => (false, 1), + "alpha" | "a" => (true, 1), _ => return None, }; let digits = stream.chars().rev().take_while(|c| c.is_ascii_digit()).count(); @@ -43,7 +49,7 @@ pub(crate) fn split_stream(channel: &str) -> Option { } let split = stream.len() - digits; let index = stream[split..].parse().ok()?; - Some(StreamChannel { stream: stream.to_string(), index, coverage }) + Some(StreamChannel { stream: stream.to_string(), index, coverage, rank }) } /// MurmurHash3, x86-32 bit variant, seed 0 (public-domain algorithm, Austin Appleby). @@ -210,14 +216,17 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< let npx = w.checked_mul(h).ok_or_else(|| CodecError::malformed(F, "the layer is too large"))?; let too_large = || CodecError::malformed(F, "the layer is too large"); let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?); - // Each pixel also gets a pair list, so count its header with the channel samples. - let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::>() as u64); + // Each pixel also gets a pair list, so count its header with the channel samples (four + // f32 channels per set: two ranks of ID and coverage). + let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::>() as u64); limits.check_bytes(w32, h32, per_pixel)?; let ex_layer = super::exr::read_layer(bytes, layer.part)?; - let mut ids: Vec> = vec![Vec::new(); layer.channels.len()]; - let mut coverages: Vec> = vec![Vec::new(); layer.channels.len()]; + // Two (ID, coverage) ranks per set: rank `slot * 2 + stream.rank`. + let ranks = layer.channels.len().saturating_mul(2); + let mut ids: Vec> = vec![Vec::new(); ranks]; + let mut coverages: Vec> = vec![Vec::new(); ranks]; for c in &ex_layer.channel_data.list { let Some(stream) = split_stream(&c.name.to_string()) else { continue }; let Some(slot) = layer.channels.iter().position(|s| *s == stream.stream) else { continue }; @@ -226,7 +235,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< return Err(CodecError::malformed(F, "a Cryptomatte channel is smaller than its layer")); } let bucket = if stream.coverage { &mut coverages } else { &mut ids }; - bucket[slot] = values; + let Some(dst) = slot.checked_mul(2).and_then(|r| r.checked_add(stream.rank)).and_then(|r| bucket.get_mut(r)) else { continue }; + *dst = values; } let mut pixels = vec![Vec::new(); npx]; diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 8da3849207..3aa769fb01 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -14,10 +14,12 @@ //! 65535 when they fit in it (DeepSkyStacker, most stackers) and by the maximum otherwise. NaN //! and blank pixels read as 0. //! * The other header cards are kept as text: `KEY` → value (strings unquoted), `COMMENT` and -//! `HISTORY` by their text, `HIERARCH` keywords by their long name. +//! `HISTORY` by their text, `HIERARCH` keywords by their long name. Long strings continued +//! on `CONTINUE` cards are joined. //! //! Writing: gray or RGB, U8 (`BITPIX = 8`), U16 (`BITPIX = 16`, `BZERO = 32768`) or F32 -//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards. +//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards +//! (strings longer than one card on `CONTINUE` cards). //! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused. use crate::Format; @@ -149,7 +151,20 @@ fn parse_header(b: &[u8], start: usize) -> Result { if raw.starts_with(b"END") && raw.get(3..).is_some_and(|r| r.iter().all(|&c| c == b' ')) { break; } - cards.push(parse_card(raw)); + let card = parse_card(raw); + // Long strings (FITS 4.0 section 4.2.1.2): a string ending in '&' goes on in the next + // `CONTINUE` record. An orphaned `CONTINUE` stays a card of its own (commentary). + if let Some(Card { value: Some(Value::Str(s)), .. }) = cards.last_mut() + && s.ends_with('&') + && card.key == "CONTINUE" + && card.text.starts_with(" ") + && let Value::Str(more) = parse_value(&card.text) + { + s.pop(); + s.push_str(&more); + continue; + } + cards.push(card); } let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?; Ok(Header { cards, data_start }) @@ -390,9 +405,22 @@ impl Writer { fn value(&mut self, key: &str, v: &str) { if literal(v) { self.card(&format!("{key:<8}= {v:>20}")); - } else { - self.card(&format!("{key:<8}= {}", quoted(v, CARD - 10))); + return; } + // A string too long for one card goes on in `CONTINUE` cards, every piece but the last + // ending in '&' (FITS 4.0 section 4.2.1.2). A doubled quote is never split, and trailing + // blanks (not significant) are dropped so none are kept before an '&'. + let escaped = ascii(v).trim_end().replace('\'', "''"); + let mut rest = escaped.as_str(); + let mut head = format!("{key:<8}= "); + while rest.len() > CARD - 12 { + let cut = CARD - 13 - rest.get(..CARD - 13).unwrap_or("").bytes().rev().take_while(|&b| b == b'\'').count() % 2; + let (piece, tail) = rest.split_at_checked(cut).unwrap_or((rest, "")); + self.card(&format!("{head}'{piece}&'")); + head = "CONTINUE ".to_string(); + rest = tail; + } + self.card(&format!("{head}'{rest:<8}'")); } fn commentary(&mut self, key: &str, text: &str) { @@ -410,7 +438,8 @@ impl Writer { if matches!(upper.as_str(), "COMMENT" | "HISTORY") { self.commentary(&upper, v); } else if standard_key(key) { - if !STRUCTURAL.contains(&key) && !is_naxis(key) { + // `CONTINUE` only ever carries a string's continuation, written by `value`. + if !STRUCTURAL.contains(&key) && !is_naxis(key) && key != "CONTINUE" { self.value(key, v); } } else { @@ -526,6 +555,40 @@ mod tests { out } + #[test] + fn continue_strings_decode() { + let mut b = header(&[ + "SIMPLE = T", + "BITPIX = 8", + "NAXIS = 2", + "NAXIS1 = 1", + "NAXIS2 = 1", + "OBJECT = 'aaa&'", + "CONTINUE 'bbb&' / a comment", + "CONTINUE '&'", + "CONTINUE ''", + "OBSERVER= 'R&'", + "COMMENT hi", + "CONTINUE 'orphan'", + ]); + b.push(0); + let img = decode(&b, &Limits::default()).unwrap(); + let text = [("OBJECT", "aaabbb"), ("OBSERVER", "R&"), ("COMMENT", "hi"), ("CONTINUE", "'orphan'")]; + assert_eq!(img.meta.text, text.map(|(k, v)| (k.to_string(), v.to_string()))); + } + + #[test] + fn continue_strings_round_trip() { + for value in ["a".repeat(100), format!("{}' {}", "a".repeat(65), "b".repeat(100)), "'".repeat(100), "a".repeat(68)] { + let mut img = Image::from_u8(1, 1, ChannelLayout::Gray, vec![0]).unwrap(); + img.meta.text = vec![("OBJECT".into(), value.clone()), ("CONTINUE".into(), "orphan".into())]; + let b = crate::encode(&img, F, &EncodeOptions::default()).unwrap(); + assert!(!b.chunks(CARD).any(|c| c.starts_with(b"CONTINUE="))); + let decoded = decode(&b, &Limits::default()).unwrap(); + assert_eq!(decoded.meta.text, vec![("OBJECT".into(), value)]); + } + } + #[test] fn camera_u16_with_bzero_reads_exactly_and_bottom_up() { let mut b = header(&[ diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 22845d930b..b0b3ca46e9 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,4 +1,6 @@ +pub(crate) mod dds; pub(crate) mod deep_exr; +pub(crate) mod dpx; pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; @@ -13,3 +15,4 @@ pub(crate) mod tiff_ifd; pub(crate) mod via_image; pub(crate) mod vp8; pub(crate) mod webp; +pub(crate) mod xpm; diff --git a/crates/codecs/src/codecs/xpm.rs b/crates/codecs/src/codecs/xpm.rs new file mode 100644 index 0000000000..82543d8867 --- /dev/null +++ b/crates/codecs/src/codecs/xpm.rs @@ -0,0 +1,221 @@ +//! X PixMap reader for XPM2 plain-text icons and XPM3 C-source icon arrays. +//! Supports transparency (None), RGB hex at 4/8/16 bits per channel and +//! common X11 colour names. Does not execute C code. + +use std::collections::HashMap; + +mod x11_colors; + +use crate::SampleType; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image}; +use crate::options::Limits; + +const MAX_SOURCE: usize = 256 << 20; +const MAX_COLOURS: usize = 65_536; +const MAX_CPP: usize = 8; + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::Xpm, msg) +} + +/// Extract C string literals without treating quoted text or // in a string as a comment. +/// XPM uses one literal for the header, each palette entry and each row. +fn c_strings(bytes: &[u8]) -> Result>, CodecError> { + let mut lines = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i..].starts_with(b"/*") { + let tail = &bytes[i + 2..]; + let Some(end) = tail.windows(2).position(|p| p == b"*/") else { return Err(bad("unterminated C comment")) }; + i += end + 4; + } else if bytes[i..].starts_with(b"//") { + i += bytes[i..].iter().position(|&c| c == b'\n').unwrap_or(bytes.len() - i); + } else if bytes[i] == b'"' { + i += 1; + let mut s = Vec::new(); + let mut closed = false; + while i < bytes.len() { + let c = bytes[i]; + i += 1; + match c { + b'"' => { + closed = true; + break; + } + b'\\' => { + let next = *bytes.get(i).ok_or_else(|| bad("truncated C escape"))?; + i += 1; + s.push(match next { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'0' => 0, + _ => next, // quoted backslash or quote + }); + } + _ => s.push(c), + } + } + if !closed { + return Err(bad("unterminated XPM string")); + } + lines.push(s); + } else { + i += 1; + } + } + Ok(lines) +} + +/// Extract XPM2's plain-text lines without trimming pixel rows: leading and +/// trailing spaces can be palette keys, and physical CRLF is not image data. +fn xpm2_lines(bytes: &[u8]) -> Result>, CodecError> { + let mut input = bytes.split(|&b| b == b'\n'); + let header = input.next().ok_or_else(|| bad("missing XPM2 marker"))?; + if header.strip_suffix(b"\r").unwrap_or(header) != b"! XPM2" { + return Err(bad("not an XPM2 image")); + } + let mut lines = Vec::new(); + for raw in input { + let line = raw.strip_suffix(b"\r").unwrap_or(raw); + // The header may be preceded by comments and blank lines. After the + // header, a leading '!' is allowed as a palette key or pixel value. + if lines.is_empty() && (line.is_empty() || line.starts_with(b"!")) { + continue; + } + lines.push(line.to_vec()); + } + Ok(lines) +} + +fn channel(hex: &str) -> Option { + let v = u32::from_str_radix(hex, 16).ok()?; + let max = (1u32 << (hex.len() * 4)) - 1; + Some(((v * 255 + max / 2) / max) as u8) +} + +fn named(name: &str) -> Option<[u8; 3]> { + let s = name.to_ascii_lowercase().replace([' ', '-', '_'], ""); + // XPM imports X11 names, whose RGB values differ from CSS for names + // such as gray, maroon and purple. Keep these exact rather than guessing. + if let Ok(index) = x11_colors::X11_COLORS.binary_search_by_key(&s.as_str(), |(name, _)| *name) { + return Some(x11_colors::X11_COLORS[index].1); + } + // Retain useful web color aliases not present in the X11 database. + Some(match s.as_str() { + "silver" => [192, 192, 192], + "olive" => [128, 128, 0], + "teal" => [0, 128, 128], + "fuchsia" => [255, 0, 255], + "navyblue" => [0, 0, 128], + _ => return None, + }) +} + +fn color(name: &str) -> Option<[u8; 4]> { + if name.eq_ignore_ascii_case("none") { + return Some([0, 0, 0, 0]); + } + let rgb = if let Some(hex) = name.strip_prefix('#') { + // UTF-8 byte lengths can match a supported hex width while slicing would panic. + // `from_str_radix` would also take a leading `+`: accept hex digits only. + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let digits = match hex.len() { + 3 => 1, + 6 => 2, + 12 => 4, + _ => return None, + }; + let rgb = (0..3).map(|i| channel(&hex[i * digits..(i + 1) * digits])).collect::>>()?; + [rgb[0], rgb[1], rgb[2]] + } else { + named(name)? + }; + Some([rgb[0], rgb[1], rgb[2], 255]) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + if bytes.len() > MAX_SOURCE { + return Err(CodecError::LimitExceeded("XPM source exceeds 256 MiB".into())); + } + let lines = if bytes.starts_with(b"/* XPM */") { + c_strings(bytes)? + } else if bytes.starts_with(b"! XPM2\n") || bytes.starts_with(b"! XPM2\r\n") { + xpm2_lines(bytes)? + } else { + return Err(bad("not an XPM2/XPM3 image")); + }; + decode_lines(&lines, limits) +} + +fn decode_lines(lines: &[Vec], limits: &Limits) -> Result { + let header = lines.first().ok_or_else(|| bad("missing image header"))?; + let parts = std::str::from_utf8(header).map_err(|_| bad("non-UTF-8 XPM header"))?; + let mut parts = parts.split_ascii_whitespace(); + let number = |key: &str, s: Option<&str>| -> Result { + s.ok_or_else(|| bad(format!("missing {key}")))?.parse::().map_err(|_| bad(format!("invalid {key}"))) + }; + let width = number("width", parts.next())?; + let height = number("height", parts.next())?; + let colours = number("palette size", parts.next())?; + let cpp = number("chars per pixel", parts.next())?; + if width == 0 || height == 0 || colours == 0 || colours > MAX_COLOURS || !(1..=MAX_CPP).contains(&cpp) { + return Err(bad("invalid XPM dimensions, palette size or chars per pixel")); + } + let (w, h) = (u32::try_from(width).map_err(|_| bad("width overflow"))?, u32::try_from(height).map_err(|_| bad("height overflow"))?); + limits.check(w, h, ChannelLayout::Rgba, SampleType::U8)?; + let required = 1usize.checked_add(colours).and_then(|n| n.checked_add(height)).ok_or_else(|| bad("row count overflow"))?; + if lines.len() < required { + return Err(bad("truncated palette or image rows")); + } + let mut palette = HashMap::, [u8; 4]>::with_capacity(colours); + for line in &lines[1..1 + colours] { + if line.len() < cpp { + return Err(bad("truncated colour key")); + } + let fields = std::str::from_utf8(&line[cpp..]).map_err(|_| bad("invalid colour name"))?; + // XPM priority is colour, gray, four-level gray, then monochrome, + // regardless of the order of keys in the palette entry. + let terms: Vec<&str> = fields.split_ascii_whitespace().collect(); + let mut picked: Option<(u8, String)> = None; + let mut i = 0; + while i < terms.len() { + let priority = match terms[i] { + "c" => 4, + "g" => 3, + "g4" => 2, + "m" => 1, + _ => 0, // Symbolic names ('s') are not colour values. + }; + let start = i + 1; + i = start; + while i < terms.len() && !matches!(terms[i], "c" | "g" | "g4" | "m" | "s") { + i += 1; + } + if priority > 0 && i > start && picked.as_ref().is_none_or(|(current, _)| priority > *current) { + picked = Some((priority, terms[start..i].join(" "))); + } + } + let value = picked.as_ref().and_then(|(_, name)| color(name)).ok_or_else(|| bad("unsupported or missing XPM colour"))?; + if palette.insert(line[..cpp].to_vec(), value).is_some() { + return Err(bad("duplicate XPM palette key")); + } + } + let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?; + let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?]; + for (y, line) in lines[1 + colours..required].iter().enumerate() { + if line.len() != row_len { + return Err(bad("XPM row has the wrong width")); + } + for x in 0..width { + let key = &line[x * cpp..(x + 1) * cpp]; + let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?; + pixels[(y * width + x) * 4..(y * width + x + 1) * 4].copy_from_slice(rgba); + } + } + Image::from_u8(w, h, ChannelLayout::Rgba, pixels) +} diff --git a/crates/codecs/src/codecs/xpm/x11_colors.rs b/crates/codecs/src/codecs/xpm/x11_colors.rs new file mode 100644 index 0000000000..1d47b37061 --- /dev/null +++ b/crates/codecs/src/codecs/xpm/x11_colors.rs @@ -0,0 +1,666 @@ +//! X11 color-name database (X.Org rgb.txt) normalized to lowercase without +//! spaces, underscores, or hyphens. RGB values are *X11* values, not CSS values. +//! Source: https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt +//! License: MIT (X.Org Foundation), see the upstream app/rgb COPYING file. +//! Aliases differing only in case or spaces have been collapsed. +//! Keep sorted: decoder uses binary search without allocation per candidate. + +pub(super) const X11_COLORS: &[(&str, [u8; 3])] = &[ + ("aliceblue", [240, 248, 255]), + ("antiquewhite", [250, 235, 215]), + ("antiquewhite1", [255, 239, 219]), + ("antiquewhite2", [238, 223, 204]), + ("antiquewhite3", [205, 192, 176]), + ("antiquewhite4", [139, 131, 120]), + ("aquamarine", [127, 255, 212]), + ("aquamarine1", [127, 255, 212]), + ("aquamarine2", [118, 238, 198]), + ("aquamarine3", [102, 205, 170]), + ("aquamarine4", [69, 139, 116]), + ("azure", [240, 255, 255]), + ("azure1", [240, 255, 255]), + ("azure2", [224, 238, 238]), + ("azure3", [193, 205, 205]), + ("azure4", [131, 139, 139]), + ("beige", [245, 245, 220]), + ("bisque", [255, 228, 196]), + ("bisque1", [255, 228, 196]), + ("bisque2", [238, 213, 183]), + ("bisque3", [205, 183, 158]), + ("bisque4", [139, 125, 107]), + ("black", [0, 0, 0]), + ("blanchedalmond", [255, 235, 205]), + ("blue", [0, 0, 255]), + ("blue1", [0, 0, 255]), + ("blue2", [0, 0, 238]), + ("blue3", [0, 0, 205]), + ("blue4", [0, 0, 139]), + ("blueviolet", [138, 43, 226]), + ("brown", [165, 42, 42]), + ("brown1", [255, 64, 64]), + ("brown2", [238, 59, 59]), + ("brown3", [205, 51, 51]), + ("brown4", [139, 35, 35]), + ("burlywood", [222, 184, 135]), + ("burlywood1", [255, 211, 155]), + ("burlywood2", [238, 197, 145]), + ("burlywood3", [205, 170, 125]), + ("burlywood4", [139, 115, 85]), + ("cadetblue", [95, 158, 160]), + ("cadetblue1", [152, 245, 255]), + ("cadetblue2", [142, 229, 238]), + ("cadetblue3", [122, 197, 205]), + ("cadetblue4", [83, 134, 139]), + ("chartreuse", [127, 255, 0]), + ("chartreuse1", [127, 255, 0]), + ("chartreuse2", [118, 238, 0]), + ("chartreuse3", [102, 205, 0]), + ("chartreuse4", [69, 139, 0]), + ("chocolate", [210, 105, 30]), + ("chocolate1", [255, 127, 36]), + ("chocolate2", [238, 118, 33]), + ("chocolate3", [205, 102, 29]), + ("chocolate4", [139, 69, 19]), + ("coral", [255, 127, 80]), + ("coral1", [255, 114, 86]), + ("coral2", [238, 106, 80]), + ("coral3", [205, 91, 69]), + ("coral4", [139, 62, 47]), + ("cornflowerblue", [100, 149, 237]), + ("cornsilk", [255, 248, 220]), + ("cornsilk1", [255, 248, 220]), + ("cornsilk2", [238, 232, 205]), + ("cornsilk3", [205, 200, 177]), + ("cornsilk4", [139, 136, 120]), + ("cyan", [0, 255, 255]), + ("cyan1", [0, 255, 255]), + ("cyan2", [0, 238, 238]), + ("cyan3", [0, 205, 205]), + ("cyan4", [0, 139, 139]), + ("darkblue", [0, 0, 139]), + ("darkcyan", [0, 139, 139]), + ("darkgoldenrod", [184, 134, 11]), + ("darkgoldenrod1", [255, 185, 15]), + ("darkgoldenrod2", [238, 173, 14]), + ("darkgoldenrod3", [205, 149, 12]), + ("darkgoldenrod4", [139, 101, 8]), + ("darkgray", [169, 169, 169]), + ("darkgreen", [0, 100, 0]), + ("darkgrey", [169, 169, 169]), + ("darkkhaki", [189, 183, 107]), + ("darkmagenta", [139, 0, 139]), + ("darkolivegreen", [85, 107, 47]), + ("darkolivegreen1", [202, 255, 112]), + ("darkolivegreen2", [188, 238, 104]), + ("darkolivegreen3", [162, 205, 90]), + ("darkolivegreen4", [110, 139, 61]), + ("darkorange", [255, 140, 0]), + ("darkorange1", [255, 127, 0]), + ("darkorange2", [238, 118, 0]), + ("darkorange3", [205, 102, 0]), + ("darkorange4", [139, 69, 0]), + ("darkorchid", [153, 50, 204]), + ("darkorchid1", [191, 62, 255]), + ("darkorchid2", [178, 58, 238]), + ("darkorchid3", [154, 50, 205]), + ("darkorchid4", [104, 34, 139]), + ("darkred", [139, 0, 0]), + ("darksalmon", [233, 150, 122]), + ("darkseagreen", [143, 188, 143]), + ("darkseagreen1", [193, 255, 193]), + ("darkseagreen2", [180, 238, 180]), + ("darkseagreen3", [155, 205, 155]), + ("darkseagreen4", [105, 139, 105]), + ("darkslateblue", [72, 61, 139]), + ("darkslategray", [47, 79, 79]), + ("darkslategray1", [151, 255, 255]), + ("darkslategray2", [141, 238, 238]), + ("darkslategray3", [121, 205, 205]), + ("darkslategray4", [82, 139, 139]), + ("darkslategrey", [47, 79, 79]), + ("darkturquoise", [0, 206, 209]), + ("darkviolet", [148, 0, 211]), + ("deeppink", [255, 20, 147]), + ("deeppink1", [255, 20, 147]), + ("deeppink2", [238, 18, 137]), + ("deeppink3", [205, 16, 118]), + ("deeppink4", [139, 10, 80]), + ("deepskyblue", [0, 191, 255]), + ("deepskyblue1", [0, 191, 255]), + ("deepskyblue2", [0, 178, 238]), + ("deepskyblue3", [0, 154, 205]), + ("deepskyblue4", [0, 104, 139]), + ("dimgray", [105, 105, 105]), + ("dimgrey", [105, 105, 105]), + ("dodgerblue", [30, 144, 255]), + ("dodgerblue1", [30, 144, 255]), + ("dodgerblue2", [28, 134, 238]), + ("dodgerblue3", [24, 116, 205]), + ("dodgerblue4", [16, 78, 139]), + ("firebrick", [178, 34, 34]), + ("firebrick1", [255, 48, 48]), + ("firebrick2", [238, 44, 44]), + ("firebrick3", [205, 38, 38]), + ("firebrick4", [139, 26, 26]), + ("floralwhite", [255, 250, 240]), + ("forestgreen", [34, 139, 34]), + ("gainsboro", [220, 220, 220]), + ("ghostwhite", [248, 248, 255]), + ("gold", [255, 215, 0]), + ("gold1", [255, 215, 0]), + ("gold2", [238, 201, 0]), + ("gold3", [205, 173, 0]), + ("gold4", [139, 117, 0]), + ("goldenrod", [218, 165, 32]), + ("goldenrod1", [255, 193, 37]), + ("goldenrod2", [238, 180, 34]), + ("goldenrod3", [205, 155, 29]), + ("goldenrod4", [139, 105, 20]), + ("gray", [190, 190, 190]), + ("gray0", [0, 0, 0]), + ("gray1", [3, 3, 3]), + ("gray10", [26, 26, 26]), + ("gray100", [255, 255, 255]), + ("gray11", [28, 28, 28]), + ("gray12", [31, 31, 31]), + ("gray13", [33, 33, 33]), + ("gray14", [36, 36, 36]), + ("gray15", [38, 38, 38]), + ("gray16", [41, 41, 41]), + ("gray17", [43, 43, 43]), + ("gray18", [46, 46, 46]), + ("gray19", [48, 48, 48]), + ("gray2", [5, 5, 5]), + ("gray20", [51, 51, 51]), + ("gray21", [54, 54, 54]), + ("gray22", [56, 56, 56]), + ("gray23", [59, 59, 59]), + ("gray24", [61, 61, 61]), + ("gray25", [64, 64, 64]), + ("gray26", [66, 66, 66]), + ("gray27", [69, 69, 69]), + ("gray28", [71, 71, 71]), + ("gray29", [74, 74, 74]), + ("gray3", [8, 8, 8]), + ("gray30", [77, 77, 77]), + ("gray31", [79, 79, 79]), + ("gray32", [82, 82, 82]), + ("gray33", [84, 84, 84]), + ("gray34", [87, 87, 87]), + ("gray35", [89, 89, 89]), + ("gray36", [92, 92, 92]), + ("gray37", [94, 94, 94]), + ("gray38", [97, 97, 97]), + ("gray39", [99, 99, 99]), + ("gray4", [10, 10, 10]), + ("gray40", [102, 102, 102]), + ("gray41", [105, 105, 105]), + ("gray42", [107, 107, 107]), + ("gray43", [110, 110, 110]), + ("gray44", [112, 112, 112]), + ("gray45", [115, 115, 115]), + ("gray46", [117, 117, 117]), + ("gray47", [120, 120, 120]), + ("gray48", [122, 122, 122]), + ("gray49", [125, 125, 125]), + ("gray5", [13, 13, 13]), + ("gray50", [127, 127, 127]), + ("gray51", [130, 130, 130]), + ("gray52", [133, 133, 133]), + ("gray53", [135, 135, 135]), + ("gray54", [138, 138, 138]), + ("gray55", [140, 140, 140]), + ("gray56", [143, 143, 143]), + ("gray57", [145, 145, 145]), + ("gray58", [148, 148, 148]), + ("gray59", [150, 150, 150]), + ("gray6", [15, 15, 15]), + ("gray60", [153, 153, 153]), + ("gray61", [156, 156, 156]), + ("gray62", [158, 158, 158]), + ("gray63", [161, 161, 161]), + ("gray64", [163, 163, 163]), + ("gray65", [166, 166, 166]), + ("gray66", [168, 168, 168]), + ("gray67", [171, 171, 171]), + ("gray68", [173, 173, 173]), + ("gray69", [176, 176, 176]), + ("gray7", [18, 18, 18]), + ("gray70", [179, 179, 179]), + ("gray71", [181, 181, 181]), + ("gray72", [184, 184, 184]), + ("gray73", [186, 186, 186]), + ("gray74", [189, 189, 189]), + ("gray75", [191, 191, 191]), + ("gray76", [194, 194, 194]), + ("gray77", [196, 196, 196]), + ("gray78", [199, 199, 199]), + ("gray79", [201, 201, 201]), + ("gray8", [20, 20, 20]), + ("gray80", [204, 204, 204]), + ("gray81", [207, 207, 207]), + ("gray82", [209, 209, 209]), + ("gray83", [212, 212, 212]), + ("gray84", [214, 214, 214]), + ("gray85", [217, 217, 217]), + ("gray86", [219, 219, 219]), + ("gray87", [222, 222, 222]), + ("gray88", [224, 224, 224]), + ("gray89", [227, 227, 227]), + ("gray9", [23, 23, 23]), + ("gray90", [229, 229, 229]), + ("gray91", [232, 232, 232]), + ("gray92", [235, 235, 235]), + ("gray93", [237, 237, 237]), + ("gray94", [240, 240, 240]), + ("gray95", [242, 242, 242]), + ("gray96", [245, 245, 245]), + ("gray97", [247, 247, 247]), + ("gray98", [250, 250, 250]), + ("gray99", [252, 252, 252]), + ("green", [0, 255, 0]), + ("green1", [0, 255, 0]), + ("green2", [0, 238, 0]), + ("green3", [0, 205, 0]), + ("green4", [0, 139, 0]), + ("greenyellow", [173, 255, 47]), + ("grey", [190, 190, 190]), + ("grey0", [0, 0, 0]), + ("grey1", [3, 3, 3]), + ("grey10", [26, 26, 26]), + ("grey100", [255, 255, 255]), + ("grey11", [28, 28, 28]), + ("grey12", [31, 31, 31]), + ("grey13", [33, 33, 33]), + ("grey14", [36, 36, 36]), + ("grey15", [38, 38, 38]), + ("grey16", [41, 41, 41]), + ("grey17", [43, 43, 43]), + ("grey18", [46, 46, 46]), + ("grey19", [48, 48, 48]), + ("grey2", [5, 5, 5]), + ("grey20", [51, 51, 51]), + ("grey21", [54, 54, 54]), + ("grey22", [56, 56, 56]), + ("grey23", [59, 59, 59]), + ("grey24", [61, 61, 61]), + ("grey25", [64, 64, 64]), + ("grey26", [66, 66, 66]), + ("grey27", [69, 69, 69]), + ("grey28", [71, 71, 71]), + ("grey29", [74, 74, 74]), + ("grey3", [8, 8, 8]), + ("grey30", [77, 77, 77]), + ("grey31", [79, 79, 79]), + ("grey32", [82, 82, 82]), + ("grey33", [84, 84, 84]), + ("grey34", [87, 87, 87]), + ("grey35", [89, 89, 89]), + ("grey36", [92, 92, 92]), + ("grey37", [94, 94, 94]), + ("grey38", [97, 97, 97]), + ("grey39", [99, 99, 99]), + ("grey4", [10, 10, 10]), + ("grey40", [102, 102, 102]), + ("grey41", [105, 105, 105]), + ("grey42", [107, 107, 107]), + ("grey43", [110, 110, 110]), + ("grey44", [112, 112, 112]), + ("grey45", [115, 115, 115]), + ("grey46", [117, 117, 117]), + ("grey47", [120, 120, 120]), + ("grey48", [122, 122, 122]), + ("grey49", [125, 125, 125]), + ("grey5", [13, 13, 13]), + ("grey50", [127, 127, 127]), + ("grey51", [130, 130, 130]), + ("grey52", [133, 133, 133]), + ("grey53", [135, 135, 135]), + ("grey54", [138, 138, 138]), + ("grey55", [140, 140, 140]), + ("grey56", [143, 143, 143]), + ("grey57", [145, 145, 145]), + ("grey58", [148, 148, 148]), + ("grey59", [150, 150, 150]), + ("grey6", [15, 15, 15]), + ("grey60", [153, 153, 153]), + ("grey61", [156, 156, 156]), + ("grey62", [158, 158, 158]), + ("grey63", [161, 161, 161]), + ("grey64", [163, 163, 163]), + ("grey65", [166, 166, 166]), + ("grey66", [168, 168, 168]), + ("grey67", [171, 171, 171]), + ("grey68", [173, 173, 173]), + ("grey69", [176, 176, 176]), + ("grey7", [18, 18, 18]), + ("grey70", [179, 179, 179]), + ("grey71", [181, 181, 181]), + ("grey72", [184, 184, 184]), + ("grey73", [186, 186, 186]), + ("grey74", [189, 189, 189]), + ("grey75", [191, 191, 191]), + ("grey76", [194, 194, 194]), + ("grey77", [196, 196, 196]), + ("grey78", [199, 199, 199]), + ("grey79", [201, 201, 201]), + ("grey8", [20, 20, 20]), + ("grey80", [204, 204, 204]), + ("grey81", [207, 207, 207]), + ("grey82", [209, 209, 209]), + ("grey83", [212, 212, 212]), + ("grey84", [214, 214, 214]), + ("grey85", [217, 217, 217]), + ("grey86", [219, 219, 219]), + ("grey87", [222, 222, 222]), + ("grey88", [224, 224, 224]), + ("grey89", [227, 227, 227]), + ("grey9", [23, 23, 23]), + ("grey90", [229, 229, 229]), + ("grey91", [232, 232, 232]), + ("grey92", [235, 235, 235]), + ("grey93", [237, 237, 237]), + ("grey94", [240, 240, 240]), + ("grey95", [242, 242, 242]), + ("grey96", [245, 245, 245]), + ("grey97", [247, 247, 247]), + ("grey98", [250, 250, 250]), + ("grey99", [252, 252, 252]), + ("honeydew", [240, 255, 240]), + ("honeydew1", [240, 255, 240]), + ("honeydew2", [224, 238, 224]), + ("honeydew3", [193, 205, 193]), + ("honeydew4", [131, 139, 131]), + ("hotpink", [255, 105, 180]), + ("hotpink1", [255, 110, 180]), + ("hotpink2", [238, 106, 167]), + ("hotpink3", [205, 96, 144]), + ("hotpink4", [139, 58, 98]), + ("indianred", [205, 92, 92]), + ("indianred1", [255, 106, 106]), + ("indianred2", [238, 99, 99]), + ("indianred3", [205, 85, 85]), + ("indianred4", [139, 58, 58]), + ("ivory", [255, 255, 240]), + ("ivory1", [255, 255, 240]), + ("ivory2", [238, 238, 224]), + ("ivory3", [205, 205, 193]), + ("ivory4", [139, 139, 131]), + ("khaki", [240, 230, 140]), + ("khaki1", [255, 246, 143]), + ("khaki2", [238, 230, 133]), + ("khaki3", [205, 198, 115]), + ("khaki4", [139, 134, 78]), + ("lavender", [230, 230, 250]), + ("lavenderblush", [255, 240, 245]), + ("lavenderblush1", [255, 240, 245]), + ("lavenderblush2", [238, 224, 229]), + ("lavenderblush3", [205, 193, 197]), + ("lavenderblush4", [139, 131, 134]), + ("lawngreen", [124, 252, 0]), + ("lemonchiffon", [255, 250, 205]), + ("lemonchiffon1", [255, 250, 205]), + ("lemonchiffon2", [238, 233, 191]), + ("lemonchiffon3", [205, 201, 165]), + ("lemonchiffon4", [139, 137, 112]), + ("lightblue", [173, 216, 230]), + ("lightblue1", [191, 239, 255]), + ("lightblue2", [178, 223, 238]), + ("lightblue3", [154, 192, 205]), + ("lightblue4", [104, 131, 139]), + ("lightcoral", [240, 128, 128]), + ("lightcyan", [224, 255, 255]), + ("lightcyan1", [224, 255, 255]), + ("lightcyan2", [209, 238, 238]), + ("lightcyan3", [180, 205, 205]), + ("lightcyan4", [122, 139, 139]), + ("lightgoldenrod", [238, 221, 130]), + ("lightgoldenrod1", [255, 236, 139]), + ("lightgoldenrod2", [238, 220, 130]), + ("lightgoldenrod3", [205, 190, 112]), + ("lightgoldenrod4", [139, 129, 76]), + ("lightgoldenrodyellow", [250, 250, 210]), + ("lightgray", [211, 211, 211]), + ("lightgreen", [144, 238, 144]), + ("lightgrey", [211, 211, 211]), + ("lightpink", [255, 182, 193]), + ("lightpink1", [255, 174, 185]), + ("lightpink2", [238, 162, 173]), + ("lightpink3", [205, 140, 149]), + ("lightpink4", [139, 95, 101]), + ("lightsalmon", [255, 160, 122]), + ("lightsalmon1", [255, 160, 122]), + ("lightsalmon2", [238, 149, 114]), + ("lightsalmon3", [205, 129, 98]), + ("lightsalmon4", [139, 87, 66]), + ("lightseagreen", [32, 178, 170]), + ("lightskyblue", [135, 206, 250]), + ("lightskyblue1", [176, 226, 255]), + ("lightskyblue2", [164, 211, 238]), + ("lightskyblue3", [141, 182, 205]), + ("lightskyblue4", [96, 123, 139]), + ("lightslateblue", [132, 112, 255]), + ("lightslategray", [119, 136, 153]), + ("lightslategrey", [119, 136, 153]), + ("lightsteelblue", [176, 196, 222]), + ("lightsteelblue1", [202, 225, 255]), + ("lightsteelblue2", [188, 210, 238]), + ("lightsteelblue3", [162, 181, 205]), + ("lightsteelblue4", [110, 123, 139]), + ("lightyellow", [255, 255, 224]), + ("lightyellow1", [255, 255, 224]), + ("lightyellow2", [238, 238, 209]), + ("lightyellow3", [205, 205, 180]), + ("lightyellow4", [139, 139, 122]), + ("limegreen", [50, 205, 50]), + ("linen", [250, 240, 230]), + ("magenta", [255, 0, 255]), + ("magenta1", [255, 0, 255]), + ("magenta2", [238, 0, 238]), + ("magenta3", [205, 0, 205]), + ("magenta4", [139, 0, 139]), + ("maroon", [176, 48, 96]), + ("maroon1", [255, 52, 179]), + ("maroon2", [238, 48, 167]), + ("maroon3", [205, 41, 144]), + ("maroon4", [139, 28, 98]), + ("mediumaquamarine", [102, 205, 170]), + ("mediumblue", [0, 0, 205]), + ("mediumorchid", [186, 85, 211]), + ("mediumorchid1", [224, 102, 255]), + ("mediumorchid2", [209, 95, 238]), + ("mediumorchid3", [180, 82, 205]), + ("mediumorchid4", [122, 55, 139]), + ("mediumpurple", [147, 112, 219]), + ("mediumpurple1", [171, 130, 255]), + ("mediumpurple2", [159, 121, 238]), + ("mediumpurple3", [137, 104, 205]), + ("mediumpurple4", [93, 71, 139]), + ("mediumseagreen", [60, 179, 113]), + ("mediumslateblue", [123, 104, 238]), + ("mediumspringgreen", [0, 250, 154]), + ("mediumturquoise", [72, 209, 204]), + ("mediumvioletred", [199, 21, 133]), + ("midnightblue", [25, 25, 112]), + ("mintcream", [245, 255, 250]), + ("mistyrose", [255, 228, 225]), + ("mistyrose1", [255, 228, 225]), + ("mistyrose2", [238, 213, 210]), + ("mistyrose3", [205, 183, 181]), + ("mistyrose4", [139, 125, 123]), + ("moccasin", [255, 228, 181]), + ("navajowhite", [255, 222, 173]), + ("navajowhite1", [255, 222, 173]), + ("navajowhite2", [238, 207, 161]), + ("navajowhite3", [205, 179, 139]), + ("navajowhite4", [139, 121, 94]), + ("navy", [0, 0, 128]), + ("navyblue", [0, 0, 128]), + ("oldlace", [253, 245, 230]), + ("olivedrab", [107, 142, 35]), + ("olivedrab1", [192, 255, 62]), + ("olivedrab2", [179, 238, 58]), + ("olivedrab3", [154, 205, 50]), + ("olivedrab4", [105, 139, 34]), + ("orange", [255, 165, 0]), + ("orange1", [255, 165, 0]), + ("orange2", [238, 154, 0]), + ("orange3", [205, 133, 0]), + ("orange4", [139, 90, 0]), + ("orangered", [255, 69, 0]), + ("orangered1", [255, 69, 0]), + ("orangered2", [238, 64, 0]), + ("orangered3", [205, 55, 0]), + ("orangered4", [139, 37, 0]), + ("orchid", [218, 112, 214]), + ("orchid1", [255, 131, 250]), + ("orchid2", [238, 122, 233]), + ("orchid3", [205, 105, 201]), + ("orchid4", [139, 71, 137]), + ("palegoldenrod", [238, 232, 170]), + ("palegreen", [152, 251, 152]), + ("palegreen1", [154, 255, 154]), + ("palegreen2", [144, 238, 144]), + ("palegreen3", [124, 205, 124]), + ("palegreen4", [84, 139, 84]), + ("paleturquoise", [175, 238, 238]), + ("paleturquoise1", [187, 255, 255]), + ("paleturquoise2", [174, 238, 238]), + ("paleturquoise3", [150, 205, 205]), + ("paleturquoise4", [102, 139, 139]), + ("palevioletred", [219, 112, 147]), + ("palevioletred1", [255, 130, 171]), + ("palevioletred2", [238, 121, 159]), + ("palevioletred3", [205, 104, 137]), + ("palevioletred4", [139, 71, 93]), + ("papayawhip", [255, 239, 213]), + ("peachpuff", [255, 218, 185]), + ("peachpuff1", [255, 218, 185]), + ("peachpuff2", [238, 203, 173]), + ("peachpuff3", [205, 175, 149]), + ("peachpuff4", [139, 119, 101]), + ("peru", [205, 133, 63]), + ("pink", [255, 192, 203]), + ("pink1", [255, 181, 197]), + ("pink2", [238, 169, 184]), + ("pink3", [205, 145, 158]), + ("pink4", [139, 99, 108]), + ("plum", [221, 160, 221]), + ("plum1", [255, 187, 255]), + ("plum2", [238, 174, 238]), + ("plum3", [205, 150, 205]), + ("plum4", [139, 102, 139]), + ("powderblue", [176, 224, 230]), + ("purple", [160, 32, 240]), + ("purple1", [155, 48, 255]), + ("purple2", [145, 44, 238]), + ("purple3", [125, 38, 205]), + ("purple4", [85, 26, 139]), + ("red", [255, 0, 0]), + ("red1", [255, 0, 0]), + ("red2", [238, 0, 0]), + ("red3", [205, 0, 0]), + ("red4", [139, 0, 0]), + ("rosybrown", [188, 143, 143]), + ("rosybrown1", [255, 193, 193]), + ("rosybrown2", [238, 180, 180]), + ("rosybrown3", [205, 155, 155]), + ("rosybrown4", [139, 105, 105]), + ("royalblue", [65, 105, 225]), + ("royalblue1", [72, 118, 255]), + ("royalblue2", [67, 110, 238]), + ("royalblue3", [58, 95, 205]), + ("royalblue4", [39, 64, 139]), + ("saddlebrown", [139, 69, 19]), + ("salmon", [250, 128, 114]), + ("salmon1", [255, 140, 105]), + ("salmon2", [238, 130, 98]), + ("salmon3", [205, 112, 84]), + ("salmon4", [139, 76, 57]), + ("sandybrown", [244, 164, 96]), + ("seagreen", [46, 139, 87]), + ("seagreen1", [84, 255, 159]), + ("seagreen2", [78, 238, 148]), + ("seagreen3", [67, 205, 128]), + ("seagreen4", [46, 139, 87]), + ("seashell", [255, 245, 238]), + ("seashell1", [255, 245, 238]), + ("seashell2", [238, 229, 222]), + ("seashell3", [205, 197, 191]), + ("seashell4", [139, 134, 130]), + ("sienna", [160, 82, 45]), + ("sienna1", [255, 130, 71]), + ("sienna2", [238, 121, 66]), + ("sienna3", [205, 104, 57]), + ("sienna4", [139, 71, 38]), + ("skyblue", [135, 206, 235]), + ("skyblue1", [135, 206, 255]), + ("skyblue2", [126, 192, 238]), + ("skyblue3", [108, 166, 205]), + ("skyblue4", [74, 112, 139]), + ("slateblue", [106, 90, 205]), + ("slateblue1", [131, 111, 255]), + ("slateblue2", [122, 103, 238]), + ("slateblue3", [105, 89, 205]), + ("slateblue4", [71, 60, 139]), + ("slategray", [112, 128, 144]), + ("slategray1", [198, 226, 255]), + ("slategray2", [185, 211, 238]), + ("slategray3", [159, 182, 205]), + ("slategray4", [108, 123, 139]), + ("slategrey", [112, 128, 144]), + ("snow", [255, 250, 250]), + ("snow1", [255, 250, 250]), + ("snow2", [238, 233, 233]), + ("snow3", [205, 201, 201]), + ("snow4", [139, 137, 137]), + ("springgreen", [0, 255, 127]), + ("springgreen1", [0, 255, 127]), + ("springgreen2", [0, 238, 118]), + ("springgreen3", [0, 205, 102]), + ("springgreen4", [0, 139, 69]), + ("steelblue", [70, 130, 180]), + ("steelblue1", [99, 184, 255]), + ("steelblue2", [92, 172, 238]), + ("steelblue3", [79, 148, 205]), + ("steelblue4", [54, 100, 139]), + ("tan", [210, 180, 140]), + ("tan1", [255, 165, 79]), + ("tan2", [238, 154, 73]), + ("tan3", [205, 133, 63]), + ("tan4", [139, 90, 43]), + ("thistle", [216, 191, 216]), + ("thistle1", [255, 225, 255]), + ("thistle2", [238, 210, 238]), + ("thistle3", [205, 181, 205]), + ("thistle4", [139, 123, 139]), + ("tomato", [255, 99, 71]), + ("tomato1", [255, 99, 71]), + ("tomato2", [238, 92, 66]), + ("tomato3", [205, 79, 57]), + ("tomato4", [139, 54, 38]), + ("turquoise", [64, 224, 208]), + ("turquoise1", [0, 245, 255]), + ("turquoise2", [0, 229, 238]), + ("turquoise3", [0, 197, 205]), + ("turquoise4", [0, 134, 139]), + ("violet", [238, 130, 238]), + ("violetred", [208, 32, 144]), + ("violetred1", [255, 62, 150]), + ("violetred2", [238, 58, 140]), + ("violetred3", [205, 50, 120]), + ("violetred4", [139, 34, 82]), + ("wheat", [245, 222, 179]), + ("wheat1", [255, 231, 186]), + ("wheat2", [238, 216, 174]), + ("wheat3", [205, 186, 150]), + ("wheat4", [139, 126, 102]), + ("white", [255, 255, 255]), + ("whitesmoke", [245, 245, 245]), + ("yellow", [255, 255, 0]), + ("yellow1", [255, 255, 0]), + ("yellow2", [238, 238, 0]), + ("yellow3", [205, 205, 0]), + ("yellow4", [139, 139, 0]), + ("yellowgreen", [154, 205, 50]), +]; diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index 720f9421a2..d5654d27a0 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -145,13 +145,19 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch layouts[0] } -pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan { +pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan { let c = caps(format); let (layout, sample) = (image.layout(), image.sample_type()); match format { // PNM: integer data can use any layout (PAM); float goes to PFM // which only has gray and RGB. Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 }, + // DPX has no luma + alpha descriptor: gray with alpha is written as luma, alpha dropped. + // The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16). + Format::Dpx => Plan { + layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) }, + sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample), + }, _ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) }, } } @@ -237,7 +243,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if m.dpi.is_some() && !c.dpi { w.push(W::DpiDropped); } - if !m.text.is_empty() && !c.text { + // DPX keeps its own header fields, not free-form text. + let text_kept = |k: &str| c.text && (format != Format::Dpx || crate::codecs::dpx::KEYS.contains(&k)); + if m.text.iter().any(|(k, _)| !text_kept(k)) { w.push(W::TextDropped); } } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index ddb7587141..dca235f80f 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -17,6 +17,10 @@ pub enum Format { Pnm, /// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB. Pcx, + /// X PixMap icons (XPM2 plain text and XPM3 C-string arrays). + Xpm, + /// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3. + Dds, Qoi, OpenExr, /// Radiance RGBE. @@ -27,6 +31,8 @@ pub enum Format { Heif, /// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data. Fits, + /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. + Dpx, } /// What a format can hold **and** what this crate reads/writes for it. @@ -58,10 +64,15 @@ pub struct FormatCaps { /// Formats that are enabled but intentionally not symmetric, with the reason. /// The test-suite asserts that every other enabled format is read+write. pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ + (Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."), ( Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", ), + ( + Format::Dds, + "DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.", + ), ( Format::Avif, "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ @@ -84,7 +95,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 16] = [ + pub const ALL: [Format; 19] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -95,12 +106,15 @@ impl Format { Format::Ico, Format::Pnm, Format::Pcx, + Format::Xpm, + Format::Dds, Format::Qoi, Format::OpenExr, Format::Hdr, Format::Avif, Format::Heif, Format::Fits, + Format::Dpx, ]; pub fn caps(self) -> FormatCaps { @@ -125,12 +139,15 @@ impl Format { Format::Ico => "ICO", Format::Pnm => "Netpbm", Format::Pcx => "PCX", + Format::Xpm => "XPM", + Format::Dds => "DDS", Format::Qoi => "QOI", Format::OpenExr => "OpenEXR", Format::Hdr => "Radiance HDR", Format::Avif => "AVIF", Format::Heif => "HEIF", Format::Fits => "FITS", + Format::Dpx => "DPX", } } @@ -148,12 +165,15 @@ impl Format { Format::Ico => &["ico"], Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], Format::Pcx => &["pcx"], + Format::Xpm => &["xpm"], + Format::Dds => &["dds"], Format::Qoi => &["qoi"], Format::OpenExr => &["exr"], Format::Hdr => &["hdr"], Format::Avif => &["avif"], Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], + Format::Dpx => &["dpx"], } } @@ -169,12 +189,15 @@ impl Format { Format::Ico => "image/x-icon", Format::Pnm => "image/x-portable-anymap", Format::Pcx => "image/x-pcx", + Format::Xpm => "image/x-xpixmap", + Format::Dds => "image/vnd-ms.dds", Format::Qoi => "image/qoi", Format::OpenExr => "image/x-exr", Format::Hdr => "image/vnd.radiance", Format::Avif => "image/avif", Format::Heif => "image/heif", Format::Fits => "image/fits", + Format::Dpx => "image/x-dpx", } } @@ -218,6 +241,8 @@ pub fn caps(format: Format) -> FormatCaps { Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base }, Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, + Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base }, + Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -226,6 +251,8 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base } } Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, + // `text`: the DPX header fields only (see `fidelity_warnings_with`). + Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, } } @@ -251,6 +278,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"qoif") { return Some(Format::Qoi); } + if b.starts_with(b"DDS ") { + return Some(Format::Dds); + } if b.starts_with(&[0x76, 0x2F, 0x31, 0x01]) { return Some(Format::OpenExr); } @@ -274,9 +304,15 @@ pub fn detect(bytes: &[u8]) -> Option { if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() { return Some(Format::Pnm); } + if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { + return Some(Format::Dpx); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } + if b.starts_with(b"/* XPM */") || b.starts_with(b"! XPM2\n") || b.starts_with(b"! XPM2\r\n") { + return Some(Format::Xpm); + } if crate::codecs::pcx::looks_like_pcx(b) { return Some(Format::Pcx); } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 92d8fb5823..c1f9d7b75f 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -156,6 +156,32 @@ pub enum DecodeWarning { /// Deep image data (per-pixel sample lists) was composited into the flat image; the samples /// themselves (Z and other deep channels) are not part of the flat result. DeepFlattened { format: Format, max_samples_per_pixel: u32 }, + /// Only the first image element of a DPX file was decoded; `total` counts them all. + MoreElements { total: u32 }, + /// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes, + /// 255 when undefined). The code values are imported as stored, without a colour transform. + DpxEncoding { transfer: u8, colorimetric: u8 }, +} + +/// The SMPTE 268M name of a transfer characteristic or colorimetric code. +fn dpx_code_name(code: u8) -> &'static str { + match code { + 0 => "user-defined", + 1 => "printing density", + 2 => "linear", + 3 => "logarithmic", + 4 => "unspecified video", + 5 => "SMPTE 274M", + 6 => "ITU-R BT.709", + 7 => "ITU-R BT.601 625-line", + 8 => "ITU-R BT.601 525-line", + 9 => "NTSC composite video", + 10 => "PAL composite video", + 11 => "Z linear", + 12 => "Z homogeneous", + 255 => "undefined", + _ => "unknown", + } } impl fmt::Display for DecodeWarning { @@ -175,6 +201,13 @@ impl fmt::Display for DecodeWarning { "{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept", format = format.name() ), + DecodeWarning::MoreElements { total } => write!(f, "only the first of {total} image elements was imported"), + DecodeWarning::DpxEncoding { transfer, colorimetric } => write!( + f, + "DPX transfer characteristic: {} ({transfer}), colorimetric: {} ({colorimetric}); the code values were imported as stored, without a colour transform", + dpx_code_name(*transfer), + dpx_code_name(*colorimetric) + ), } } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 16025a5a46..ec663ace78 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -37,13 +37,13 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; -pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; +pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; +use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -75,7 +75,10 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::WebP => webp::decode(bytes, l), Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), + Format::Xpm => xpm::decode(bytes, l), + Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), + Format::Dpx => dpx::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -93,6 +96,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res let o = match format { Format::Tiff => tiff::orientation(bytes, None), Format::Heif => 1, + Format::Dpx => dpx::orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; finish_decode(img, o, opts) @@ -223,7 +227,10 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result webp::encode(image, plan, opts), Format::Pnm => pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), + Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")), + Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), + Format::Dpx => dpx::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 9337f1e6f1..b955e42d3f 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -121,6 +121,38 @@ pub enum ExrCompression { Piz, } +/// Bits per sample of a DPX file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpxBits { + Bits8, + Bits10, + Bits12, + Bits16, +} + +impl DpxBits { + /// `8`, `10`, `12` or `16` as [`DpxBits`]; any other number is `None`. + pub fn from_bits(bits: u64) -> Option { + match bits { + 8 => Some(DpxBits::Bits8), + 10 => Some(DpxBits::Bits10), + 12 => Some(DpxBits::Bits12), + 16 => Some(DpxBits::Bits16), + _ => None, + } + } + + /// The bits per sample. + pub fn bits(self) -> u8 { + match self { + DpxBits::Bits8 => 8, + DpxBits::Bits10 => 10, + DpxBits::Bits12 => 12, + DpxBits::Bits16 => 16, + } + } +} + /// Options for [`crate::encode`]. #[derive(Debug, Clone)] pub struct EncodeOptions { @@ -141,6 +173,8 @@ pub struct EncodeOptions { /// only when it could pass the 4 GiB a classic TIFF can address. pub tiff_bigtiff: bool, pub exr_compression: ExrCompression, + /// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise. + pub dpx_bits: Option, /// Embed the ICC profile when the format supports it. pub embed_icc: bool, /// Embed EXIF/XMP/DPI/text when the format supports it. @@ -159,6 +193,7 @@ impl Default for EncodeOptions { tiff_compression: TiffCompression::Deflate, tiff_bigtiff: false, exr_compression: ExrCompression::Zip16, + dpx_bits: None, embed_icc: true, embed_metadata: true, } diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/dds.rs new file mode 100644 index 0000000000..766555f6e5 --- /dev/null +++ b/crates/codecs/tests/dds.rs @@ -0,0 +1,268 @@ +use photocraft_codecs::{ + ChannelLayout, CodecError, DecodeOptions, Format, Limits, SampleType, caps, decode, decode_as, decode_as_with, detect, encode, from_extension, +}; + +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; + +/// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature. +fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec { + let mut b = vec![0u8; 128]; + b[0..4].copy_from_slice(b"DDS "); + let mut put = |at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + put(4, 124); + put(8, 0x1007); // CAPS | HEIGHT | WIDTH | PIXELFORMAT + put(12, height); + put(16, width); + put(76, 32); + put(80, pf_flags); + put(88, bits); + for (i, m) in masks.into_iter().enumerate() { + put(92 + 4 * i, m); + } + put(108, 0x1000); // DDSCAPS_TEXTURE + b[84..88].copy_from_slice(fourcc); + b +} + +fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec { + header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4]) +} + +/// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size. +fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec { + let mut b = compressed(width, height, b"DX10"); + for v in [dxgi, dimension, misc, array, 0] { + b.extend(v.to_le_bytes()); + } + b +} + +/// BC1 colour endpoints as RGB565, then one 2-bit index per pixel, row by row. +fn bc1_block(c0: u16, c1: u16, rows: [u8; 4]) -> Vec { + let mut b = Vec::new(); + b.extend(c0.to_le_bytes()); + b.extend(c1.to_le_bytes()); + b.extend(rows); + b +} + +const RED: u16 = 0xf800; +const GREEN: u16 = 0x07e0; +const BLUE: u16 = 0x001f; +const BLACK: u16 = 0x0000; +const WHITE: u16 = 0xffff; + +/// Indices 0, 1 on the top row and 2, 3 below them, for the top-left 2x2 pixels. +const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0]; + +#[test] +fn dds_is_readable_but_not_writable() { + assert_eq!(from_extension("texture.DDS"), Some(Format::Dds)); + assert!(caps(Format::Dds).read && !caps(Format::Dds).write); + let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); + assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. }))); +} + +#[test] +fn decodes_bc1_four_colour_blocks() { + // c0 > c1: four opaque colours, the two in between at thirds. + let mut b = compressed(2, 2, b"DXT1"); + b.extend(bc1_block(RED, BLUE, INDICES_0123)); + assert_eq!(detect(&b), Some(Format::Dds)); + let im = decode(&b).unwrap(); + assert_eq!((im.dimensions(), im.layout(), im.sample_type()), ((2, 2), ChannelLayout::Rgba, SampleType::U8)); + assert_eq!(im.data(), &[255, 0, 0, 255, 0, 0, 255, 255, 170, 0, 85, 255, 85, 0, 170, 255]); +} + +#[test] +fn decodes_bc1_one_bit_alpha_blocks() { + // c0 <= c1: three colours (the third halfway) and index 3 is transparent black. + let mut b = compressed(2, 2, b"DXT1"); + b.extend(bc1_block(BLUE, RED, INDICES_0123)); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]); +} + +#[test] +fn decodes_bc2_explicit_alpha_with_four_colour_blocks() { + let mut b = compressed(2, 2, b"DXT3"); + // Four bits of alpha per pixel: 15, 0 on the top row, 8, 1 below. + b.extend([0x0f, 0x00, 0x18, 0x00, 0, 0, 0, 0]); + // c0 < c1 would mean transparency in BC1; BC2 colour blocks always have four colours. + b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0])); + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgba); + assert_eq!(im.data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 136, 85, 85, 85, 17]); +} + +#[test] +fn decodes_bc3_interpolated_alpha() { + // a0 > a1: eight alpha values, six interpolated at sevenths. Indices 0, 1 / 2, 7. + let mut b = compressed(2, 2, b"DXT5"); + b.extend([255, 0, 0x08, 0xa0, 0x03, 0, 0, 0]); + b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0])); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 219, 85, 85, 85, 36]); + + // a0 <= a1: six values, four interpolated at fifths, then 0 and 255. Indices 2, 5 / 6, 7. + let mut b = compressed(2, 2, b"DXT5"); + let bits: u64 = 2 | 5 << 3 | 6 << 12 | 7 << 15; + b.extend([0, 255]); + b.extend(&bits.to_le_bytes()[..6]); + b.extend(bc1_block(GREEN, GREEN, [0; 4])); + assert_eq!(decode(&b).unwrap().data(), &[0, 255, 0, 51, 0, 255, 0, 204, 0, 255, 0, 0, 0, 255, 0, 255]); +} + +#[test] +fn bc_textures_need_not_be_a_multiple_of_four() { + // 5x3 is two blocks wide and one high; the right block's first column is x = 4. + let mut b = compressed(5, 3, b"DXT1"); + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + let im = decode(&b).unwrap(); + assert_eq!(im.dimensions(), (5, 3)); + let px = |x: usize, y: usize| &im.data()[(y * 5 + x) * 4..(y * 5 + x + 1) * 4]; + assert_eq!((px(3, 2), px(4, 0), px(4, 2)), (&[255, 0, 0, 255][..], &[0, 0, 255, 255][..], &[0, 0, 255, 255][..])); +} + +#[test] +fn decodes_uncompressed_rgba_and_bgra_masks() { + let rgba = [0xff, 0xff00, 0xff_0000, 0xff00_0000]; + let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, rgba); + b.extend([1, 2, 3, 4, 250, 251, 252, 253]); + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgba); + assert_eq!(im.data(), &[1, 2, 3, 4, 250, 251, 252, 253]); + + let bgra = [0xff_0000, 0xff00, 0xff, 0xff00_0000]; + let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, bgra); + b.extend([3, 2, 1, 4, 252, 251, 250, 253]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 250, 251, 252, 253]); + + // An alpha mask without DDPF_ALPHAPIXELS is not alpha: X8R8G8B8 opens as RGB. + let mut b = header(1, 1, DDPF_RGB, &[0; 4], 32, bgra); + b.extend([3, 2, 1, 0]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..])); +} + +#[test] +fn decodes_packed_masks_and_luminance() { + // R5G6B5: each field scaled to 0..255; 3 of 31 is 25. + let mut b = header(4, 1, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]); + for v in [RED, GREEN, BLUE, 3 << 11] { + b.extend(v.to_le_bytes()); + } + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgb); + assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 25, 0, 0]); + + // 24-bit rows are packed three bytes a pixel, without padding. + let mut b = header(2, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]); + b.extend([3, 2, 1, 6, 5, 4, 9, 8, 7, 12, 11, 10]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + + let mut b = header(2, 1, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]); + b.extend([10, 20, 30, 40]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); +} + +#[test] +fn decodes_dx10_headers() { + let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM + b.extend([1, 2, 3, 4]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]); + + let mut b = dx10(1, 1, 91, 3, 0, 1); // B8G8R8A8_UNORM_SRGB + b.extend([3, 2, 1, 4]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]); + + let mut b = dx10(2, 2, 71, 3, 0, 1); // BC1_UNORM + b.extend(bc1_block(BLUE, RED, INDICES_0123)); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]); +} + +#[test] +fn only_the_top_mip_level_is_read() { + let mut b = compressed(4, 4, b"DXT1"); + b[28..32].copy_from_slice(&3u32.to_le_bytes()); + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + b.extend(bc1_block(GREEN, GREEN, [0; 4])); + let im = decode(&b).unwrap(); + assert_eq!(im.dimensions(), (4, 4)); + assert!(im.data().chunks(4).all(|p| p == [255, 0, 0, 255])); +} + +#[test] +fn refuses_cubemaps_arrays_and_volumes() { + let unsupported = |b: &[u8], what: &str| match decode_as(Format::Dds, b) { + Err(CodecError::Unsupported { reason, .. }) => assert!(reason.contains(what), "{reason}"), + other => panic!("{what}: {other:?}"), + }; + let mut cube = compressed(4, 4, b"DXT1"); + cube[112..116].copy_from_slice(&0xfe00u32.to_le_bytes()); // DDSCAPS2_CUBEMAP and all faces + cube.extend([0; 8 * 6]); + unsupported(&cube, "cubemap"); + let mut volume = compressed(4, 4, b"DXT1"); + volume[8..12].copy_from_slice(&(0x1007u32 | 0x80_0000).to_le_bytes()); // DDSD_DEPTH + volume[24..28].copy_from_slice(&4u32.to_le_bytes()); + volume[112..116].copy_from_slice(&0x20_0000u32.to_le_bytes()); // DDSCAPS2_VOLUME + volume.extend([0; 8 * 4]); + unsupported(&volume, "volume"); + unsupported(&dx10(4, 4, 71, 3, 0x4, 1), "cubemap"); + unsupported(&dx10(4, 4, 71, 3, 0, 6), "array"); + unsupported(&dx10(4, 4, 71, 4, 0, 1), "2D"); + unsupported(&dx10(4, 4, 98, 3, 0, 1), "98"); // BC7_UNORM + unsupported(&compressed(4, 4, b"ATI2"), "ATI2"); + unsupported(&compressed(4, 4, b"DXT2"), "premultiplied"); +} + +#[test] +fn rejects_truncated_files_at_every_length_without_panicking() { + let mut b = dx10(5, 3, 77, 3, 0, 1); // BC3_UNORM: two 16-byte blocks + b.extend([0x5a; 32]); + assert!(decode(&b).is_ok()); + for n in 0..b.len() { + assert!(decode_as(Format::Dds, &b[..n]).is_err(), "decoded {n} of {} bytes", b.len()); + } + let mut b = header(3, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]); + b.extend([7; 18]); + assert!(decode(&b).is_ok()); + assert!(decode_as(Format::Dds, &b[..b.len() - 1]).is_err(), "one byte short"); +} + +#[test] +fn rejects_bogus_sizes_and_pixel_formats() { + // Huge dimensions are refused before anything is allocated. + let mut huge = compressed(u32::MAX, u32::MAX, b"DXT1"); + huge.extend([0; 8]); + assert!(matches!(decode_as(Format::Dds, &huge), Err(CodecError::LimitExceeded(_)))); + let opts = DecodeOptions { limits: Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() }; + let mut tight = header(64, 64, DDPF_RGB, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]); + tight.extend(vec![0; 64 * 64 * 4]); + assert!(matches!(decode_as_with(Format::Dds, &tight, &opts), Err(CodecError::LimitExceeded(_)))); + assert!(decode_as(Format::Dds, &compressed(0, 4, b"DXT1")).is_err(), "zero width"); + + let mut bad_size = compressed(4, 4, b"DXT1"); + bad_size[4..8].copy_from_slice(&100u32.to_le_bytes()); + bad_size.extend([0; 8]); + assert!(matches!(decode_as(Format::Dds, &bad_size), Err(CodecError::Malformed { .. })), "header size must be 124"); + + for (bits, masks) in [ + (12, [0xf00, 0xf0, 0xf, 0]), // not a whole number of bytes + (16, [0xff_0000, 0xff00, 0xff, 0]), // a mask wider than the pixel + (32, [0xf0f, 0xf0f0, 0xf_0000, 0]), // a mask with a gap + (32, [0, 0, 0, 0]), // no colour at all + (0, [0xff, 0xff00, 0xff_0000, 0]), // zero bits per pixel + ] { + let mut b = header(1, 1, DDPF_RGB, &[0; 4], bits, masks); + b.extend([0; 4]); + assert!(decode_as(Format::Dds, &b).is_err(), "{bits} bits, masks {masks:x?}"); + } + let mut no_format = header(1, 1, 0, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]); + no_format.extend([0; 4]); + assert!(matches!(decode_as(Format::Dds, &no_format), Err(CodecError::Unsupported { .. }))); +} diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 367a8bd813..22bf227eb8 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -59,6 +59,7 @@ fn default_build_formats_all_symmetric() { Format::OpenExr, Format::Hdr, Format::Fits, + Format::Dpx, ]; for f in expected { assert!(caps(f).read && caps(f).write, "{f:?}"); diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/dpx.rs new file mode 100644 index 0000000000..8fdeb398c3 --- /dev/null +++ b/crates/codecs/tests/dpx.rs @@ -0,0 +1,386 @@ +//! DPX (SMPTE 268M) read and write, against fixtures packed here independently of the codec: +//! every descriptor, bit depth and packing it reads, both byte orders, the header fields kept +//! through a round trip, the bit-depth choice on export and the files it refuses (#1049). + +use photocraft_codecs::*; + +const HEADER: usize = 2048; +const ELEMENT0: usize = 780; + +/// A DPX file built field by field. +#[derive(Clone)] +struct Fixture { + big: bool, + width: u32, + height: u32, + descriptor: u8, + bits: u8, + packing: u16, + encoding: u16, + signed: bool, + transfer: u8, + colorimetric: u8, + orientation: u16, + elements: u16, + strings: Vec<((usize, usize), &'static str)>, + frame_position: u32, + frame_rate: f32, + time_code: u32, + /// Code values, interleaved, top line first. + codes: Vec, +} + +impl Fixture { + fn new(descriptor: u8, bits: u8, packing: u16, width: u32, height: u32) -> Self { + let channels = channels(descriptor); + let max = (1u32 << bits) - 1; + // Spread over the whole range, ends included. + let n = (width * height) as usize * channels; + let codes = (0..n) + .map(|i| { + if i == 0 { + 0 + } else if i == 1 { + max as u16 + } else { + ((i as u64 * 2654435761) % (u64::from(max) + 1)) as u16 + } + }) + .collect(); + Fixture { + big: true, + width, + height, + descriptor, + bits, + packing, + encoding: 0, + signed: false, + transfer: 255, + colorimetric: 255, + orientation: 0, + elements: 1, + strings: Vec::new(), + frame_position: u32::MAX, + frame_rate: f32::from_bits(u32::MAX), + time_code: u32::MAX, + codes, + } + } + + fn u16(&self, v: u16) -> [u8; 2] { + if self.big { v.to_be_bytes() } else { v.to_le_bytes() } + } + + fn u32(&self, v: u32) -> [u8; 4] { + if self.big { v.to_be_bytes() } else { v.to_le_bytes() } + } + + fn line(&self, codes: &[u16]) -> Vec { + let mut out = Vec::new(); + match (self.bits, self.packing) { + (8, _) => out.extend(codes.iter().map(|&c| c as u8)), + (16, _) => out.extend(codes.iter().flat_map(|&c| self.u16(c))), + (10, 1 | 2) => { + let base = if self.packing == 1 { 22 } else { 20 }; + for chunk in codes.chunks(3) { + let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (base - 10 * i as u32))); + out.extend(self.u32(w)); + } + } + (12, 1) => out.extend(codes.iter().flat_map(|&c| self.u16(c << 4))), + _ => { + // One bit stream, high bits first, cut into 32-bit words. + let mut bits: Vec = codes.iter().flat_map(|&c| (0..self.bits).rev().map(move |b| (c >> b) & 1 == 1)).collect(); + bits.resize(bits.len().div_ceil(32) * 32, false); + for word in bits.chunks(32) { + out.extend(self.u32(word.iter().fold(0u32, |w, &b| (w << 1) | u32::from(b)))); + } + } + } + out.resize(out.len().div_ceil(4) * 4, 0); + out + } + + fn bytes(&self) -> Vec { + let mut b = vec![0u8; HEADER]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, if self.big { b"SDPX" } else { b"XPDS" }); + put(4, &self.u32(HEADER as u32)); + put(8, b"V2.0"); + put(24, &self.u32(1664)); + put(28, &self.u32(384)); + put(768, &self.u16(self.orientation)); + put(770, &self.u16(self.elements)); + put(772, &self.u32(self.width)); + put(776, &self.u32(self.height)); + for el in 0..usize::from(self.elements.min(8)) { + let e = ELEMENT0 + el * 72; + put(e, &self.u32(u32::from(self.signed))); + put(e + 20, &[self.descriptor, self.transfer, self.colorimetric, self.bits]); + put(e + 24, &self.u16(self.packing)); + put(e + 26, &self.u16(self.encoding)); + put(e + 28, &self.u32(HEADER as u32)); + } + for &((at, len), s) in &self.strings { + assert!(s.len() < len); + put(at, s.as_bytes()); + } + put(1712, &self.u32(self.frame_position)); + put(1724, &self.u32(self.frame_rate.to_bits())); + put(1920, &self.u32(self.time_code)); + let per_line = self.width as usize * channels(self.descriptor); + for line in self.codes.chunks(per_line) { + b.extend(self.line(line)); + } + b + } +} + +fn channels(descriptor: u8) -> usize { + match descriptor { + 6 => 1, + 50 => 3, + _ => 4, + } +} + +fn layout(descriptor: u8) -> ChannelLayout { + match descriptor { + 6 => ChannelLayout::Gray, + 50 => ChannelLayout::Rgb, + _ => ChannelLayout::Rgba, + } +} + +/// The 16-bit sample a code value opens as: `round(c × 65535 / (2^b − 1))`. +fn expected(c: u16, bits: u8) -> u16 { + (f64::from(c) * 65535.0 / f64::from((1u32 << bits) - 1)).round() as u16 +} + +/// Reads the code values of a big-endian file we wrote (packing 0 at 8/16 bits, 1 at 10/12). +fn written_codes(b: &[u8]) -> (u8, u16, Vec) { + let be16 = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]); + let be32 = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]); + let (w, h) = (be32(772) as usize, be32(776) as usize); + let nc = channels(b[ELEMENT0 + 20]); + let (bits, packing) = (b[ELEMENT0 + 23], be16(ELEMENT0 + 24)); + let off = be32(ELEMENT0 + 28) as usize; + let per_line = w * nc; + let stride = match bits { + 10 => per_line.div_ceil(3) * 4, + _ => (per_line * if bits == 8 { 1 } else { 2 }).div_ceil(4) * 4, + }; + let mut codes = Vec::new(); + for y in 0..h { + let line = &b[off + y * stride..]; + for i in 0..per_line { + codes.push(match bits { + 8 => u16::from(line[i]), + 10 => ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16, + 12 => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]) >> 4, + _ => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]), + }); + } + } + (bits, packing, codes) +} + +fn be32_at(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) +} + +fn opts(bits: Option) -> EncodeOptions { + EncodeOptions { dpx_bits: bits, ..EncodeOptions::default() } +} + +const COMBOS: [(u8, u16); 7] = [(8, 0), (10, 0), (10, 1), (10, 2), (12, 0), (12, 1), (16, 0)]; + +#[test] +fn every_descriptor_depth_and_packing_opens_with_its_model_and_values() { + for descriptor in [6, 50, 51] { + for (bits, packing) in COMBOS { + // Widths that leave partly filled words at the end of a line. + let fx = Fixture::new(descriptor, bits, packing, 5, 3); + let img = decode(&fx.bytes()).unwrap_or_else(|e| panic!("{descriptor} {bits}/{packing}: {e}")); + let what = format!("descriptor {descriptor}, {bits} bits, packing {packing}"); + assert_eq!(detect(&fx.bytes()), Some(Format::Dpx)); + assert_eq!((img.width(), img.height()), (5, 3), "{what}"); + assert_eq!(img.layout(), layout(descriptor), "{what}"); + assert_eq!(img.layout().has_alpha(), descriptor == 51, "{what}"); + match bits { + 8 => assert_eq!(img.as_u8().unwrap(), fx.codes.iter().map(|&c| c as u8).collect::>(), "{what}"), + 16 => assert_eq!(img.to_u16_samples().unwrap(), fx.codes, "{what}"), + _ => assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, bits)).collect::>(), "{what}"), + } + } + } +} + +#[test] +fn big_and_little_endian_files_open_identically() { + for (bits, packing) in COMBOS { + let be = Fixture::new(50, bits, packing, 4, 2); + let le = Fixture { big: false, ..be.clone() }; + assert_eq!(&le.bytes()[..4], b"XPDS"); + assert_eq!(decode(&be.bytes()).unwrap(), decode(&le.bytes()).unwrap(), "{bits} bits, packing {packing}"); + } +} + +#[test] +fn ten_bit_code_value_opens_as_the_rounded_16_bit_value() { + let mut fx = Fixture::new(6, 10, 1, 1, 1); + fx.codes = vec![512]; + assert_eq!(decode(&fx.bytes()).unwrap().to_u16_samples().unwrap(), vec![(512.0f64 * 65535.0 / 1023.0).round() as u16]); +} + +#[test] +fn ten_and_twelve_bit_frames_written_back_at_their_depth_keep_their_code_values() { + for (bits, choice) in [(10, DpxBits::Bits10), (12, DpxBits::Bits12)] { + for packing in [0, 1] { + let fx = Fixture::new(50, bits, packing, 7, 3); + let out = encode(&decode(&fx.bytes()).unwrap(), Format::Dpx, &opts(Some(choice))).unwrap(); + let (wbits, wpacking, codes) = written_codes(&out); + assert_eq!((wbits, wpacking), (bits, 1)); + assert_eq!(codes, fx.codes, "{bits} bits, packing {packing}"); + } + } +} + +#[test] +fn sixteen_bit_frames_round_trip_losslessly_and_files_start_with_sdpx() { + let fx = Fixture { big: false, ..Fixture::new(51, 16, 0, 6, 4) }; + let img = decode(&fx.bytes()).unwrap(); + let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap(); + assert_eq!(&out[..4], b"SDPX"); + let (bits, packing, codes) = written_codes(&out); + assert_eq!((bits, packing, codes), (16, 0, fx.codes)); + assert_eq!(decode(&out).unwrap().to_u16_samples(), img.to_u16_samples()); +} + +#[test] +fn log_frames_report_their_encoding_and_keep_the_plain_mapping() { + let mut fx = Fixture::new(50, 10, 1, 3, 2); + (fx.transfer, fx.colorimetric) = (3, 1); + let img = decode(&fx.bytes()).unwrap(); + assert_eq!(img.warnings, vec![DecodeWarning::DpxEncoding { transfer: 3, colorimetric: 1 }]); + let note = img.warnings[0].to_string(); + assert!(note.contains("logarithmic (3)") && note.contains("printing density (1)"), "{note}"); + assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, 10)).collect::>()); +} + +#[test] +fn only_the_first_of_several_image_elements_is_imported() { + let mut fx = Fixture::new(6, 8, 0, 2, 2); + fx.elements = 2; + let img = decode(&fx.bytes()).unwrap(); + assert_eq!(img.warnings.iter().map(ToString::to_string).collect::>(), ["only the first of 2 image elements was imported"]); +} + +#[test] +fn listed_header_fields_survive_a_round_trip() { + let mut fx = Fixture::new(50, 10, 1, 2, 2); + fx.strings = vec![((160, 100), "Resolve 20.1"), ((260, 200), "Reel 7"), ((460, 200), "(c) 2026 Studio"), ((136, 24), "2026:10:12:09:30:00UTC")]; + (fx.transfer, fx.colorimetric, fx.frame_position, fx.frame_rate, fx.time_code) = (3, 1, 86400, 23.976, 0x0102_0304); + let img = decode(&fx.bytes()).unwrap(); + for (k, v) in [ + ("DPX:Creator", "Resolve 20.1"), + ("DPX:Project", "Reel 7"), + ("DPX:Copyright", "(c) 2026 Studio"), + ("DPX:CreationTime", "2026:10:12:09:30:00UTC"), + ("DPX:Transfer", "3"), + ("DPX:Colorimetric", "1"), + ("DPX:FramePosition", "86400"), + ("DPX:FrameRate", "23.976"), + ("DPX:TimeCode", "01:02:03:04"), + ] { + assert!(img.meta.text.contains(&(k.into(), v.into())), "{k} = {v} in {:?}", img.meta.text); + } + let out = encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits10))).unwrap(); + let src = fx.bytes(); + // Field by field: the strings, the element's encoding codes, film and TV fields. + for (at, len) in [(160, 100), (260, 200), (460, 200), (136, 24), (ELEMENT0 + 21, 2), (1712, 4), (1724, 4), (1920, 4)] { + assert_eq!(out[at..at + len], src[at..at + len], "field at {at}"); + } + assert_eq!(decode(&out).unwrap().meta.text, img.meta.text); +} + +#[test] +fn chosen_bit_depth_sets_the_header_and_packing() { + let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000).collect::>()).unwrap(); + for (choice, bits, packing) in [(DpxBits::Bits8, 8, 0), (DpxBits::Bits10, 10, 1), (DpxBits::Bits12, 12, 1), (DpxBits::Bits16, 16, 0)] { + let out = encode(&img, Format::Dpx, &opts(Some(choice))).unwrap(); + assert_eq!((out[ELEMENT0 + 23], u16::from_be_bytes([out[ELEMENT0 + 24], out[ELEMENT0 + 25]])), (bits, packing), "{choice:?}"); + let back = decode(&out).unwrap(); + assert_eq!((back.width(), back.height(), back.layout()), (5, 2, ChannelLayout::Rgb)); + } + // Without a choice the depth follows the image. + let gray8 = Image::from_u8(3, 1, ChannelLayout::Gray, vec![0, 128, 255]).unwrap(); + assert_eq!(encode(&gray8, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 8); + assert_eq!(encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16); + let float = img.convert(ChannelLayout::Rgb, SampleType::F32); + assert_eq!(encode(&float, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16); + assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 })); +} + +#[test] +fn gray_with_alpha_is_written_as_luma_with_a_warning() { + let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap(); + let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap(); + assert_eq!(out[ELEMENT0 + 20], 6); + assert_eq!(decode(&out).unwrap().as_u8().unwrap(), &[10, 200]); + assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::AlphaDiscarded)); +} + +#[test] +fn only_free_form_text_is_reported_as_dropped() { + let mut img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![1, 2, 3]).unwrap(); + img.meta.text = vec![("DPX:Project".into(), "Reel 7".into())]; + assert!(!fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped)); + img.meta.text.push(("Comment".into(), "from a PNG".into())); + assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped)); +} + +#[test] +fn stored_orientation_is_turned_upright() { + // Orientation 4: the stored lines are the image's columns, left to right. + let mut fx = Fixture::new(6, 8, 0, 3, 1); + fx.codes = vec![1, 2, 3]; + fx.orientation = 4; + let img = decode(&fx.bytes()).unwrap(); + assert_eq!((img.width(), img.height(), img.as_u8().unwrap()), (1, 3, &[1u8, 2, 3][..])); +} + +#[test] +fn unsupported_and_damaged_files_error_instead_of_panicking() { + let unsupported = |bytes: Vec, what: &str| { + assert!(matches!(decode(&bytes), Err(CodecError::Unsupported { format: Format::Dpx, .. })), "{what}"); + }; + unsupported(Fixture { descriptor: 100, ..Fixture::new(50, 8, 0, 2, 2) }.bytes(), "ABGR descriptor"); + unsupported(Fixture { encoding: 1, ..Fixture::new(50, 10, 1, 2, 2) }.bytes(), "run-length encoding"); + unsupported(Fixture { signed: true, ..Fixture::new(50, 16, 0, 2, 2) }.bytes(), "signed samples"); + let mut float = Fixture::new(50, 16, 0, 2, 2).bytes(); + float[ELEMENT0 + 23] = 32; + unsupported(float, "32-bit float"); + let mut method_b = Fixture::new(50, 12, 1, 2, 2).bytes(); + method_b[ELEMENT0 + 24..ELEMENT0 + 26].copy_from_slice(&2u16.to_be_bytes()); + unsupported(method_b, "12-bit method B"); + + let good = Fixture::new(51, 10, 1, 9, 9).bytes(); + let mut no_elements = good.clone(); + no_elements[770..772].copy_from_slice(&0u16.to_be_bytes()); + let mut past_end = good.clone(); + past_end[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes()); + let mut huge = good.clone(); + huge[772..780].copy_from_slice(&[0xFF; 8]); + for (what, bytes) in [ + ("header cut short", good[..900].to_vec()), + ("truncated element", good[..good.len() - 1].to_vec()), + ("no image elements", no_elements), + ("data offset past the end", past_end), + ("absurd size", huge), + ("magic alone", b"SDPX".to_vec()), + ] { + assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}"); + } +} diff --git a/crates/codecs/tests/exr_cryptomatte.rs b/crates/codecs/tests/exr_cryptomatte.rs index f5f76c3adb..28ad71e191 100644 --- a/crates/codecs/tests/exr_cryptomatte.rs +++ b/crates/codecs/tests/exr_cryptomatte.rs @@ -164,6 +164,56 @@ fn short_channel_suffixes_decode_too() { assert_eq!(buf.at(3, 3), &[(cube, 1.0)]); } +/// A 4x4 layer `crypto` of one RGBA set `crypto00.///` with the given suffixes: +/// rank 0 (red/green) holds `cube` at 0.6 everywhere, rank 1 (blue/alpha) holds `sphere` +/// at 0.4 everywhere, the second (ID, coverage) pair the specification stores per set. +fn cryptomatte_file_rgba(suffixes: [&str; 4]) -> Vec { + let (w, h) = (4usize, 4usize); + let [r, g, b, a] = suffixes; + let mk = |s: &str, v: f32| AnyChannel::new(format!("crypto00.{s}").as_str(), FlatSamples::F32(vec![v; w * h])); + let list = vec![mk(r, cryptomatte_id("cube")), mk(g, 0.6), mk(b, cryptomatte_id("sphere")), mk(a, 0.4)]; + let key = cryptomatte_key("crypto"); + let mut attrs = LayerAttributes::named("crypto"); + attrs.other.insert(text(&format!("cryptomatte/{key}/name")), AttributeValue::Text(text("crypto"))); + let layer = Layer::new( + (w, h), + attrs, + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).expect("synthetic cryptomatte exr writes"); + out.into_inner() +} + +#[test] +fn blue_alpha_rank_of_a_set_decodes_too() { + // Each RGBA set holds two ranks: (red, green) and (blue, alpha). Both must come back. + let bytes = cryptomatte_file_rgba(["red", "green", "blue", "alpha"]); + let found = cryptomatte_layers(&bytes, &Limits::default()).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].channels, ["crypto00"], "one set, listed once"); + let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap(); + let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere")); + for y in 0..4 { + for x in 0..4 { + assert_eq!(buf.at(x, y), &[(cube, 0.6), (sphere, 0.4)], "({x}, {y})"); + } + } +} + +#[test] +fn uppercase_channel_suffixes_decode_both_ranks() { + // Blender and others write `.R/.G/.B/.A`; suffixes match regardless of case. + for suffixes in [["R", "G", "B", "A"], ["Red", "Green", "Blue", "Alpha"], ["r", "g", "b", "a"]] { + let bytes = cryptomatte_file_rgba(suffixes); + let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap(); + let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere")); + assert_eq!(buf.at(2, 1), &[(cube, 0.6), (sphere, 0.4)], "{suffixes:?}"); + } +} + #[test] fn unknown_layer_name_is_unsupported() { let bytes = cryptomatte_file("crypto_asset"); diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/malformed.rs index fea7aedf3a..b6da84b072 100644 --- a/crates/codecs/tests/malformed.rs +++ b/crates/codecs/tests/malformed.rs @@ -60,7 +60,7 @@ fn truncated_files_mostly_error() { // Chopping off the second half must not silently succeed for formats // with length-checked rasters. for (f, bytes) in samples() { - if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) { + if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Dpx | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) { let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight()); assert!(r.is_err(), "{f:?} decoded half a file"); } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 742de21598..4cb50c0057 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -134,6 +134,9 @@ mod pnm { mod fits { cases!(Format::Fits); } +mod dpx { + cases!(Format::Dpx); +} mod qoi { cases!(Format::Qoi); } diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/xpm.rs new file mode 100644 index 0000000000..aa0b3bf0b0 --- /dev/null +++ b/crates/codecs/tests/xpm.rs @@ -0,0 +1,123 @@ +use photocraft_codecs::{ChannelLayout, CodecError, Format, Image, caps, decode, decode_as, detect, encode, from_extension}; + +const SAMPLE: &[u8] = br#"/* XPM */ +static char *icon[] = { +"2 2 3 1", +". c #ff0000", +" c None", +"+ c #00ff00", +".+", +" +" +}; +"#; + +#[test] +fn xpm3_decodes_palette_transparency_and_spaces_in_keys() { + assert_eq!(detect(SAMPLE), Some(Format::Xpm)); + assert_eq!(from_extension("icon.XPM"), Some(Format::Xpm)); + let img = decode(SAMPLE).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!(img.dimensions(), (2, 2)); + assert_eq!(img.data(), &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 255, 0, 255]); +} + +#[test] +fn rgb_precisions_and_x11_name() { + let xpm = br#"/* XPM */ +static char *i[] = { +"3 1 3 1", +"a c #f00", +"b c #ffff00000000", +"c c gray70", +"abc" +};"#; + let img = decode(xpm).unwrap(); + assert_eq!(img.data(), &[255, 0, 0, 255, 255, 0, 0, 255, 179, 179, 179, 255]); +} + +#[test] +fn rejects_truncation_unsupported_colours_and_unknown_keys() { + for bytes in [ + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c None\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c NotAColor\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #a\xC3\xA9\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #+f0000\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"z\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"aa\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 2 1\", \"a c black\", \"a c white\", \"a\" };"[..], + ] { + assert!(decode_as(Format::Xpm, bytes).is_err(), "{:?}", bytes); + } + assert!(decode_as(Format::Xpm, b"/* XPM */\n\"4294967295 4294967295 1 1\"").is_err()); + assert!(decode_as(Format::Xpm, b"/* XPM */\n\"2 1 1 1\" \"a c black\" \"aa\" /* bad").is_err()); +} + +#[test] +fn x11_names_and_field_priority() { + let xpm = br#"/* XPM */ +static char *i[] = { +"4 1 4 1", +"a c LightGray", +"b c DarkBlue", +"c m black g white c light blue", +"d m black g4 red g green", +"abcd" +};"#; + let image = decode(xpm).unwrap(); + assert_eq!(image.data(), &[211, 211, 211, 255, 0, 0, 139, 255, 173, 216, 230, 255, 0, 255, 0, 255]); +} + +#[test] +fn named_x11_colors_use_x11_not_css_values() { + let bytes = br#"/* XPM */ +static char *icon[] = { +"6 1 6 1", +"a c gray", +"b c maroon", +"c c purple", +"d c LightGoldenrodYellow", +"e c DarkSlateGray4", +"f c Ghost White", +"abcdef" +};"#; + let image = decode(bytes).unwrap(); + assert_eq!(image.data(), &[190, 190, 190, 255, 176, 48, 96, 255, 160, 32, 240, 255, 250, 250, 210, 255, 82, 139, 139, 255, 248, 248, 255, 255,]); +} + +#[test] +fn xpm2_decodes_crlf_comments_spaces_and_multichar_keys() { + let bytes = b"! XPM2\r\n! exported by an icon tool\r\n2 2 3 2\r\n.a c #f00\r\n!_ c None\r\nzz c DarkBlue\r\n.a!_\r\nzz.a\r\n"; + assert_eq!(detect(bytes), Some(Format::Xpm)); + let image = decode(bytes).unwrap(); + assert_eq!(image.dimensions(), (2, 2)); + assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 139, 255, 255, 0, 0, 255]); +} + +#[test] +fn xpm2_allows_exclamation_as_a_pixel_key_and_preserves_space_keys() { + let bytes = b"! XPM2\n2 1 2 1\n! c red\n c None\n! \n"; + let image = decode(bytes).unwrap(); + assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0]); +} + +#[test] +fn xpm2_malformed_inputs_return_errors() { + for bytes in [ + &b"! XPM2\n1 1 1 1\na c black\n"[..], + &b"! XPM2\n1 1 1 1\na c black\nb\n"[..], + &b"! XPM2\n1 1 1 1\na c #a\xc3\xa9\na\n"[..], + &b"! XPM2 invalid\n1 1 1 1\na c black\na\n"[..], + &b"! XPM2\n100000 100000 1 1\na c red\na\n"[..], + ] { + assert!(decode_as(Format::Xpm, bytes).is_err(), "{bytes:?}"); + } + assert_ne!(detect(b"! XPM23\n1 1 1 1\na c black\na\n"), Some(Format::Xpm)); +} + +#[test] +fn xpm_is_import_only() { + assert!(caps(Format::Xpm).read); + assert!(!caps(Format::Xpm).write); + let image = Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); + assert!(matches!(encode(&image, Format::Xpm, &Default::default()), Err(CodecError::Unsupported { .. }))); +} diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index edba488262..4366ab0a0d 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -158,10 +158,11 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth map_rgb(buf, |c| { let (dh, ds, dl) = match &table { Some(t) => { - // Range edits follow the pixel's original hue, faded out towards grey (greys have no hue). + // Range edits follow the pixel's original hue, in full however dull the colour + // (Photoshop's merged images, #4089). Greys stay grey: no hue to shift, no + // saturation to scale, and range Lightness moves towards a channel they share. let h0 = rgb_to_hsl(c).0; - let chroma = ((c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])) * 4.0).min(1.0); - (lut(&t[0], h0) * chroma, lut(&t[1], h0) * chroma, lut(&t[2], h0) * chroma) + (lut(&t[0], h0), lut(&t[1], h0), lut(&t[2], h0)) } None => (0.0, 0.0, 0.0), }; @@ -248,11 +249,18 @@ fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> } /// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab -/// documents the adjustments Photoshop evaluates on the L, a and b channels do so (see -/// [`lab_channel_adjustment`]). +/// and CMYK documents the adjustments Photoshop evaluates on the document's own channels do so +/// (see [`lab_channel_adjustment`] and [`cmyk_channel_adjustment`]). pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option, mode: ColorMode) { if mode == ColorMode::Lab && lab_channel_adjustment(adj) { - apply_lab(adj, buf); + apply_lab(adj, buf, depth); + } else if mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj) { + use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk}; + map_rgb(buf, |c| { + let mut ink = rgb_to_cmyk(c); + stored_channels(adj, mode, depth, &mut ink, rgb_to_gray(c)); + cmyk_to_rgb(ink) + }); } else { apply_depth(adj, buf, transfer, depth); } @@ -273,6 +281,51 @@ pub fn lab_channel_adjustment(adj: &Adjustment) -> bool { ) } +/// Whether a CMYK document evaluates `adj` on its inks rather than on the screen colour: Invert +/// and Posterize (each ink, black included, so paper inverts to 400% ink) and Threshold (paper or +/// 100% black ink alone). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4 and threshold-128 +/// follow these rules on the files' own inks on every pixel; here the inks are the screen colour +/// separated through the document's CMYK profile, so neutrals carry the black that profile +/// generates rather than the file's. +pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool { + stored_channel_adjustment(adj) +} + +/// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold. +pub fn stored_channel_adjustment(adj: &Adjustment) -> bool { + matches!(adj, Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. }) +} + +/// Invert, Posterize or Threshold on one pixel's own channels in a Lab or CMYK document of +/// `depth` (`None`: unknown, treated as 8-bit): `ch` holds L, a, b normalised as [`lab_norm`] +/// gives them, or the C, M, Y, K inks (0: none); `gray` is the pixel's luma, which CMYK Threshold +/// compares. Lab Invert turns 8-bit bytes `v` into `255 - v` (a becomes -1 - a) and mirrors a and +/// b around neutral at 16 bits (photoshop corpus lab16 invert.psd: neutral stays neutral). +pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option, ch: &mut [f32], gray: f32) { + let lab = mode == ColorMode::Lab; + match adj { + Adjustment::Invert => { + let mirror = if lab && depth.is_some_and(|d| d != SampleType::U8) { 256.0 / 255.0 } else { 1.0 }; + for (i, v) in ch.iter_mut().enumerate() { + *v = if i > 0 { mirror } else { 1.0 } - v.clamp(0.0, 1.0); + } + } + Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize(*v, *levels)), + Adjustment::Threshold { level } => { + // On the 8-bit grid, like the RGB threshold. + let t = (level * 255.0).round(); + let out = if lab { + let white = ch.first().is_some_and(|l| (l.clamp(0.0, 1.0) * 255.0).round() >= t); + [if white { 1.0 } else { 0.0 }, 128.0 / 255.0, 128.0 / 255.0, 0.0] + } else { + [0.0, 0.0, 0.0, if (gray * 255.0).round() < t { 1.0 } else { 0.0 }] + }; + ch.iter_mut().zip(out).for_each(|(v, o)| *v = o); + } + _ => {} + } +} + /// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255, /// (b+128)/255. fn lab_norm(c: [f32; 3]) -> [f32; 3] { @@ -285,12 +338,16 @@ fn lab_denorm(n: [f32; 3]) -> [f32; 3] { lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]) } -/// The Lab evaluation of a [`lab_channel_adjustment`] (no-op for any other adjustment). -fn apply_lab(adj: &Adjustment, buf: &mut Buffer) { +/// The Lab evaluation of a [`lab_channel_adjustment`] in a document of `depth` (no-op for any +/// other adjustment). +fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { match adj { - // n → 1 - n on every channel: L → 100 - L and a, b mirrored about neutral. - Adjustment::Invert => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| 1.0 - v.clamp(0.0, 1.0)))), - Adjustment::Posterize { levels } => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| posterize(v, *levels)))), + // L → 100 - L and a, b mirrored about neutral; each channel posterized (see `stored_channels`). + Adjustment::Invert | Adjustment::Posterize { .. } => map_rgb(buf, |c| { + let mut n = lab_norm(c); + stored_channels(adj, ColorMode::Lab, depth, &mut n, 0.0); + lab_denorm(n) + }), Adjustment::Threshold { level } => { // Split on L alone (on the 8-bit grid); the result is neutral black or white. let t = (level * 255.0).round(); @@ -493,6 +550,10 @@ pub fn bayer4(x: i32, y: i32) -> f32 { /// Samples a 3D LUT (`n`³ RGB triplets, red fastest) at `c`, trilinear or tetrahedral. pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f32; 3] { + // A degenerate or short table can't be sampled: leave the colour alone. + if n < 2 || n.checked_pow(3).and_then(|c| c.checked_mul(3)).is_none_or(|len| lut.len() < len) { + return c; + } let m = (n - 1) as f32; let pos = c.map(|v| v.clamp(0.0, 1.0) * m); let i0 = pos.map(|p| (p.floor() as usize).min(n - 2)); @@ -1377,6 +1438,37 @@ mod tone_tests { } } + /// Photoshop's own render (merged image of psd-tools `adjustments/huesaturation_lightness_rgb.psd`, + /// layer "Hue/Saturation 1"): a dull magenta gets the whole range edit, as a vivid one does (#4089). + #[test] + fn hue_ranges_apply_in_full_to_dull_colours() { + let range = |lightness: f32, bounds: [f32; 4]| HueRange { hue: 0.0, saturation: 0.0, lightness, bounds }; + let ranges = [ + range(-11.0, [-57.0, 4.0, 82.0, 175.0]), + range(100.0, [12.0, 45.0, 75.0, 105.0]), + HueRange::neutral(2), + HueRange::neutral(3), + HueRange::neutral(4), + range(100.0, [-117.0, -50.0, 39.0, 104.0]), + ]; + let a = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 17.0, colorize: false, ranges }; + let measured = [([126u8, 97, 120], [148u8, 148, 148]), ([131, 103, 125], [152, 152, 152]), ([95, 69, 90], [122, 122, 122])]; + for (input, want) in measured { + let c = input.map(|v| v as f32 / 255.0); + let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c[0], c[1], c[2], 1.0]] }; + apply(&a, &mut b); + for (i, w) in want.iter().enumerate() { + let got = b.px[0][i] * 255.0; + assert!((got - *w as f32).abs() <= 2.0, "{input:?} channel {i}: {got} vs {w}"); + } + } + // Greys have no hue, so the ranges leave them alone: only the master Lightness moves them. + let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.4, 0.4, 0.4, 1.0]] }; + apply(&a, &mut b); + let p = b.px[0]; + assert!((p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6 && (p[0] - (0.4 + 0.6 * 0.17)).abs() < 1e-4, "{p:?}"); + } + #[test] fn black_white_weights() { let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0]; @@ -1453,6 +1545,41 @@ mod tone_tests { assert!(!near(srgb_to_lab([rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]]), got, 1.0)); } + #[test] + fn sixteen_bit_lab_invert_keeps_greys_neutral() { + // 16-bit Lab Invert mirrors a and b around neutral (photoshop corpus lab16 invert.psd); + // 8-bit bytes land one step off it. + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]); + let c = lab(40.0, 0.0, 0.0); + b.px[0] = [c[0], c[1], c[2], 1.0]; + apply_in(&Adjustment::Invert, &mut b, Transfer::Srgb, Some(SampleType::U16), ColorMode::Lab); + let got = srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]]); + assert!(near(got, [60.0, 0.0, 0.0], 0.05), "{got:?}"); + assert!(near(in_lab(&Adjustment::Invert, c), [60.0, -1.0, -1.0], 0.1)); + } + + /// `adj` applied in a CMYK document to one encoded sRGB colour. + fn in_cmyk(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Cmyk); + [b.px[0][0], b.px[0][1], b.px[0][2]] + } + + #[test] + fn cmyk_invert_posterize_and_threshold_work_on_the_inks() { + use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk}; + // Paper inverts to all four inks at 100%, not to RGB black (photoshop corpus cmyk8 invert.psd). + assert!(near(in_cmyk(&Adjustment::Invert, [1.0; 3]), cmyk_to_rgb([1.0; 4]), 2e-3)); + let red = [0.9, 0.2, 0.1]; + assert!(near(in_cmyk(&Adjustment::Invert, red), cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - v)), 2e-3)); + let two = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| if v >= 0.5 { 1.0 } else { 0.0 })); + assert!(near(in_cmyk(&Adjustment::Posterize { levels: 2 }, red), two, 2e-3)); + // Threshold's black is the black ink alone (threshold-128.psd), its white the paper. + let t = Adjustment::Threshold { level: 0.5 }; + assert!(near(in_cmyk(&t, [0.2; 3]), cmyk_to_rgb([0.0, 0.0, 0.0, 1.0]), 2e-3)); + assert!(near(in_cmyk(&t, [0.8; 3]), cmyk_to_rgb([0.0; 4]), 2e-3)); + } + #[test] fn lab_posterize_steps_l_a_and_b() { let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0)); @@ -1511,15 +1638,18 @@ mod tone_tests { let (mut a, mut b) = (Buffer::filled(crate::Rect::new(0, 0, 1, 1), c), Buffer::filled(crate::Rect::new(0, 0, 1, 1), c)); apply_in(adj, &mut a, Transfer::Srgb, None, mode); apply_depth(adj, &mut b, Transfer::Srgb, None); - assert_eq!(a.px == b.px, mode != ColorMode::Lab || !lab_channel_adjustment(adj), "{adj:?} in {mode:?}"); + let own = (mode == ColorMode::Lab && lab_channel_adjustment(adj)) || (mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj)); + assert_eq!(a.px == b.px, !own, "{adj:?} in {mode:?}"); } } // Transparent pixels are left alone and odd values never panic. let mut t = Buffer::filled(crate::Rect::new(0, 0, 2, 1), [f32::NAN, 2.0, -1.0, 0.0]); for adj in lab_only.iter().chain([&Adjustment::GradientMap { stops: vec![], reverse: true, dither: true }]) { - apply_in(adj, &mut t, Transfer::Srgb, None, ColorMode::Lab); - let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]); - apply_in(adj, &mut odd, Transfer::Srgb, None, ColorMode::Lab); + for mode in [ColorMode::Lab, ColorMode::Cmyk] { + apply_in(adj, &mut t, Transfer::Srgb, None, mode); + let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]); + apply_in(adj, &mut odd, Transfer::Srgb, Some(SampleType::U16), mode); + } } assert!(t.px[0][1] == 2.0); } @@ -1528,3 +1658,19 @@ mod tone_tests { /// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128, /// measured on Photoshop 27.11; a 0.5 pivot moved it to 130). pub const LEGACY_PIVOT: f32 = 128.0 / 255.0; + +#[cfg(test)] +mod lut3d_guard_tests { + use super::lut3d_sample; + + #[test] + fn degenerate_or_short_tables_leave_the_colour_alone() { + let c = [0.2, 0.4, 0.6]; + for tetra in [false, true] { + assert_eq!(lut3d_sample(&[], 0, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 3], 1, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 3], 2, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 24], usize::MAX, c, tetra), c); + } + } +} diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 0ca3bdadb9..c19b0dc92f 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -246,6 +246,26 @@ fn blend_interior_effects_as_group_puts_overlays_under_fill_opacity() { } } +#[test] +fn interior_effects_blend_over_the_layer_mode_unless_grouped() { + // #3996: #888888 under a black Color-mode layer with a red Overlay colour overlay. The + // layer's mode applies to its own pixels (grey stays grey), then the overlay blends onto + // that result and tints it; as a group the overlay of black is black, so grey remains. + let grey = 136.0 / 255.0; + let lift = 1.0 - 2.0 * (1.0 - grey); + for depth in DEPTHS { + let mut d = doc(depth, false); + d.layers.push(solid(&d, "grey", full(&d), [grey, grey, grey, 1.0])); + let mut l = solid(&d, "black", full(&d), [0.0, 0.0, 0.0, 1.0]); + l.blend = BlendMode::Color; + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }]; + d.layers.push(l); + check(&d, 2, 2, [1.0, lift, lift, 1.0], "default: overlay over the Color blend"); + d.layers[1].advanced.blend_interior = true; + check(&d, 2, 2, [grey, grey, grey, 1.0], "as group: overlay inside the Color blend"); + } +} + #[test] fn transparency_shapes_off_spreads_effects_over_the_whole_layer() { for depth in DEPTHS { @@ -406,6 +426,26 @@ fn vector_mask_hides_effects() { } } +/// The per-render vector-mask cache must key masks by content: `hiding_mask` and +/// `effects_source` hand it temporary clones whose addresses get reused (#4426). +#[test] +fn vector_mask_cache_does_not_mix_up_layers() { + use photocraft_doc::{Path, Subpath, VectorMask}; + for depth in DEPTHS { + let mut d = doc(depth, true); + for (name, color, x0, x1) in [("a", BLUE, -4.0, 6.0), ("b", GREEN, 6.0, 16.0)] { + let mut l = solid(&d, name, full(&d), color); + l.effects.items = vec![inside_stroke()]; + l.advanced.vector_mask_hides_effects = true; + let path = Path::new(vec![Subpath::polygon(&[(x0, -4.0), (x1, -4.0), (x1, 12.0), (x0, 12.0)])]); + l.vector_mask = Some(VectorMask::new(path)); + d.layers.push(l); + } + check(&d, 2, 4, BLUE, "layer A inside its own mask"); + check(&d, 9, 4, GREEN, "layer B inside its own mask"); + } +} + #[test] fn defaults_take_the_plain_paths() { let d = doc(SampleType::U8, true); diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index 1055487f13..985a400e08 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, Weak}; -use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_geom::Rect; use photocraft_raster::{Surface, Tile}; struct Entry { @@ -15,25 +15,23 @@ struct Entry { bounds: Option<(u16, u16, u16, u16)>, } -fn cache() -> &'static Mutex> { - static C: std::sync::OnceLock>> = std::sync::OnceLock::new(); - C.get_or_init(Default::default) +/// The entries, and the size past which dead ones are dropped (see [`next_sweep`]). +struct Cache { + entries: HashMap, + sweep_at: usize, } -/// Pixels of `t` that differ from `dp` (tile-local half-open bounds). -fn scan(t: &Tile, dp: &[u8]) -> Option<(u16, u16, u16, u16)> { - let bpp = dp.len(); - let ts = TILE_SIZE as usize; - let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in t.bytes().chunks_exact(ts * bpp).enumerate() { - let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; - let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); - x0 = x0.min(first); - x1 = x1.max(last + 1); - y0 = y0.min(y); - y1 = y + 1; - } - (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) +const MIN_SWEEP: usize = 1 << 16; + +/// The size at which to sweep again after a sweep left `survivors` live entries: twice that (at +/// least [`MIN_SWEEP`]), so a cache full of live tiles isn't swept again on every call. +fn next_sweep(survivors: usize) -> usize { + survivors.saturating_mul(2).max(MIN_SWEEP) +} + +fn cache() -> &'static Mutex { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP })) } /// `Surface::content_bounds`, cached per tile. @@ -45,18 +43,19 @@ pub fn content_bounds(s: &Surface) -> Rect { let mut dp = vec![0u8; fmt.bytes_per_pixel()]; photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut dp); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); - if c.len() > 1 << 16 { - c.retain(|_, e| e.tile.strong_count() > 0); + if c.entries.len() > c.sweep_at { + c.entries.retain(|_, e| e.tile.strong_count() > 0); + c.sweep_at = next_sweep(c.entries.len()); } let mut out = Rect::EMPTY; for (coord, t) in s.tiles() { let key = Arc::as_ptr(t) as usize; - let hit = c.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds); + let hit = c.entries.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds); let b = match hit { Some(b) => b, None => { - let b = scan(t, &dp); - c.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + let b = t.content_bounds(&dp); + c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); b } }; @@ -68,3 +67,25 @@ pub fn content_bounds(s: &Surface) -> Rect { } out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sweeps_again_only_past_twice_the_survivors() { + // A cache of live tiles past the minimum isn't swept on every call (it used to be). + assert_eq!(next_sweep(0), MIN_SWEEP); + assert_eq!(next_sweep(MIN_SWEEP / 2), MIN_SWEEP); + assert_eq!(next_sweep(MIN_SWEEP), 2 * MIN_SWEEP); + assert_eq!(next_sweep(usize::MAX), usize::MAX); + } + + #[test] + fn a_shared_tile_reads_the_same_bounds_at_every_coordinate() { + let mut s = Surface::new(photocraft_color::PixelFormat::RGBA8); + s.fill_rect(Rect::new(-300, 10, 1300, 900), &[0.2, 0.4, 0.6, 1.0]); + assert_eq!(content_bounds(&s), Rect::new(-300, 10, 1300, 900)); + assert_eq!(content_bounds(&s), s.content_bounds(), "cached"); + } +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 05bd1f10da..97d08f00da 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -4,8 +4,9 @@ //! drop shadows, outer glows, outer bevel, then the layer itself (content at //! fill opacity) with pattern/gradient/colour overlays, satin, inner glows, //! inner shadows, strokes and the inner bevel on top. Exterior effects blend -//! straight into the backdrop with their own modes; the layer and its -//! interior effects blend with the layer's mode. Emboss and pillow emboss then +//! straight into the backdrop with their own modes; the layer blends with its +//! mode and the interior effects paint over the result, or blend with it as a +//! group (Blend Interior Effects as Group, [`premerges_layer`]). Emboss and pillow emboss then //! shade the composited result (inside and outside halves). Layer opacity //! applies to the whole stack; fill opacity only to the layer's own pixels. //! @@ -94,6 +95,10 @@ impl Map { /// Shifted copy (bilinear), outside reads `outside`. fn shifted(&self, dx: f32, dy: f32, outside: f32) -> Map { let mut out = Map::new(self.w, self.h, 0.0); + // A hostile distance (±1e30, inf, NaN) would overflow the i64 offsets below; no shift beyond + // 2^30 px can land inside a map, so clamp (non-finite shifts read as none). + let reach = |d: f32| if d.is_finite() { d.clamp(-1.0e9, 1.0e9) } else { 0.0 }; + let (dx, dy) = (reach(dx), reach(dy)); let (fx, fy) = (dx.floor(), dy.floor()); let (ax, ay) = (dx - fx, dy - fy); let s = |x: i64, y: i64| if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { outside } else { self.v[y as usize * self.w + x as usize] }; @@ -1791,6 +1796,14 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) { } } +/// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its +/// own pixels only, and the interior effects paint over the blended result. The layer is merged +/// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer +/// tints the grey beneath it (#3996). Normal-like modes give the same result either way. +pub fn premerges_layer(layer: &Layer) -> bool { + !layer.advanced.blend_interior && !matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough | BlendMode::Dissolve) +} + /// Composites `content` (the layer's own pixels over `big`, alpha already /// masked, clipped layers applied) plus its effects into `backdrop`. pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) { @@ -1889,6 +1902,13 @@ pub(crate) fn composite_with_effects_prepared( } }; let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() }; + let gamma = crate::text_gamma(layer); + let premerge = premerges_layer(layer); + if premerge { + for (lp, wp) in lay.px.iter_mut().zip(&work.px) { + *lp = psblend::composite_gamma(layer.blend, *wp, *lp, 1.0, gamma); + } + } let rel = |m: Map| -> Map { let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect(); Map { w: m.w, h: m.h, v } @@ -2051,8 +2071,7 @@ pub(crate) fn composite_with_effects_prepared( } // Layer (with interior effects) onto the exterior result, in the layer's mode. - let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; - let gamma = crate::text_gamma(layer); + let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; for (wp, lp) in work.px.iter_mut().zip(&lay.px) { if lp[3] > 0.0 { *wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma); @@ -3107,4 +3126,13 @@ mod tests { let diff: f32 = plain.v.iter().zip(&noisy.v).map(|(a, b)| (a - b).abs()).sum(); assert!(diff > 5.0, "glow noise changes the coverage: {diff}"); } + + #[test] + fn shifting_by_hostile_distances_does_not_overflow() { + let m = square_shape(12); + for d in [1.0e30, -1.0e30, f32::INFINITY, f32::NAN] { + let s = m.shifted(d, d, 0.0); + assert_eq!(s.v.len(), 144); + } + } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cb88d002d9..f1625b7a22 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -88,6 +88,11 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer { render_tiled(doc, rect, RENDER_TILE) } +/// Most pixels one [`render`] call will produce (2^30, a 16 GiB buffer; 32768² px). Larger rects +/// (a hostile canvas size is up to 300000 px a side) return an empty buffer instead of aborting +/// on allocation; callers wanting more render in pieces. +pub const MAX_RENDER_PIXELS: u64 = 1 << 30; + /// [`render`] with an explicit tile size (tests check tile independence). pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); @@ -107,8 +112,12 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer if rect.is_empty() { return Buffer::transparent(rect); } + // Refuse rects too large to hold: an empty buffer, not an allocation failure. + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } cx.set_fx_clip(rect); - if rect.width() as i32 <= tile && rect.height() as i32 <= tile { + if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) { return photocraft_color::convert::with_cmyk_space(cmyk, || { let mut buf = multichannel::backdrop(doc, rect); in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT)); @@ -451,18 +460,27 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti Buffer { rect: out, px: acc } } +type VectorMaskSlot = std::sync::Arc>; + #[derive(Default)] struct RenderVectorMasks { - masks: std::sync::Mutex>>>, + /// Compiled masks by content. Callers pass temporary clones (see `advanced.rs`), so an + /// address cannot identify a mask: a dropped clone's address is reused by the next one. + masks: std::sync::Mutex>, } impl RenderVectorMasks { fn values(&self, mask: &photocraft_doc::VectorMask, rect: Rect) -> Vec { - // Addresses identify immutable mask instances only for this render's lifetime. - let key = std::ptr::from_ref(mask) as usize; let slot = { let mut masks = self.masks.lock().unwrap_or_else(|e| e.into_inner()); - masks.entry(key).or_default().clone() + match masks.iter().find(|(m, _)| m == mask) { + Some((_, slot)) => slot.clone(), + None => { + let slot = VectorMaskSlot::default(); + masks.push((mask.clone(), slot.clone())); + slot + } + } }; // Compilation is sequential. Rendering can enter Rayon and must happen after // initialization, so waiting tiles cannot re-enter a slot still being built. @@ -1103,7 +1121,7 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio let mut k = 1.0; for (side, px) in [this, under].into_iter().enumerate() { let Some(p) = px else { continue }; - let v = |c: usize| p[c].clamp(0.0, 1.0) * 255.0; + let v = |c: usize| blend_if_level(p[c]); match mode { // Gray is the colour channels' luma (Rec. 601 weights), then R, G, B. M::Rgb => { @@ -1124,6 +1142,17 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio k } +/// A channel value on Blend If's 0..=255 scale. Compositing can leave an 8- or 16-bit level a few +/// millionths off (a level-20 backdrop composited at partial alpha reads 19.999998), which would +/// put it on the wrong side of a hard point at that level: a value that close to a level is that +/// level. Values of those depths that aren't on a level are at least 1/257 away from one. The GPU +/// compositor (`compose.wgsl` `blend_if_weight`) snaps alike. +fn blend_if_level(x: f32) -> f32 { + let v = x.clamp(0.0, 1.0) * 255.0; + let r = v.round(); + if (v - r).abs() < 1e-4 { r } else { v } +} + /// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn) /// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer. /// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since @@ -1387,7 +1416,8 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O // --------------------------------------------------------------------------------------------- // Layer-effect map cache -/// Order-independent identity of a layer's pixels, masks, effects and (for groups) children. +/// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If +/// and (for groups) children. /// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the /// layer so those tiles (and their addresses) stay alive while the entry exists. fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { @@ -1445,6 +1475,10 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash format!("{vm:?}").hash(h); } format!("{:?}", layer.effects).hash(h); + // Transparency Shapes Layer picks the shape (content or the whole layer); a group child's + // Blend If and knockout change the composite a styled group's shape comes from. + layer.advanced.hash(h); + layer.blend_if.hash(h); h.write_u8(0xfe); } @@ -1527,7 +1561,8 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) { fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc { use std::hash::{Hash, Hasher}; let (region, clipped) = effect_region(layer, rect, cx); - if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() { + static NO_CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + if *NO_CACHE.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some()) { let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns)); } diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index c14f73e083..d70947ec7c 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -344,7 +344,15 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface { // mask's painted tiles when the vector mask lets them through outside the path, each grown // by its feather. let mut area = match vm.and_then(|vm| vm.path.control_bounds()) { - Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv), + Some((x0, y0, x1, y1)) => grow( + Rect::new( + (x0.floor() as i32).saturating_sub(2), + (y0.floor() as i32).saturating_sub(2), + (x1.ceil() as i32).saturating_add(2), + (y1.ceil() as i32).saturating_add(2), + ), + sv, + ), None => Rect::EMPTY, }; if let Some(m) = pixel @@ -761,4 +769,14 @@ mod tests { l.vector_mask = None; assert!(combined_mask(&l, canvas).is_none()); } + + #[test] + fn hostile_path_coordinates_do_not_overflow() { + let mut l = Layer::raster("l", PixelFormat::RGBA8); + let knots = [(-1.0e30, -1.0e30), (1.0e30, 0.0), (0.0, 1.0e30)].iter().map(|&(x, y)| Knot::corner(x, y)).collect(); + let mut path = Path::default(); + path.subpaths.push(Subpath { knots, closed: true, ..Default::default() }); + l.vector_mask = Some(VectorMask::new(path)); + let _ = combined_mask(&l, Rect::new(0, 0, 8, 8)); + } } diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index a2c5c52fb9..d107ecf4e7 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -159,7 +159,7 @@ impl Tile { if wx == 0.0 { continue; } - let p = self.at(x0 + dx, y0 + dy); + let p = self.at(x0.saturating_add(dx), y0.saturating_add(dy)); let k = wx * wy; for c in 0..4 { acc[c] += p[c] * k; @@ -287,4 +287,14 @@ mod tests { let (u, v) = rot.map(1.0, 3.0); assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}"); } + + #[test] + fn sampling_at_hostile_phases_does_not_overflow() { + let patterns = [checker()]; + let prepared = PreparedPatterns::new(&patterns, PREPARED_PATTERN_BYTES); + let t = prepared.get(&patterns[0].id, "").unwrap(); + for u in [1.0e30, -1.0e30, f64::INFINITY, f64::NAN] { + let _ = t.sample(u, u); + } + } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 7fe7fc3983..84c9348029 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -20,12 +20,15 @@ pub fn downsample(s: &Surface, k: u32) -> Surface { let bpp = s.format().bytes_per_pixel(); let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); - let w = (x1 - x0).max(0) as usize; + let w = x1.saturating_sub(x0).max(0) as usize; let mut row = vec![0u8; w * bpp]; for oy in y0..y1 { - let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1)); + let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); for (i, px) in row.chunks_exact_mut(bpp).enumerate() { - px.copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]); + // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. + if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { + px.copy_from_slice(from); + } } out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index aca7a10c92..69504e4a93 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -592,6 +592,20 @@ fn render_is_tile_independent() { } } +/// A Lab Invert layer in a 16-bit document keeps a grey neutral (the compositor passes the +/// document's depth to `adjust::apply_in`). +#[test] +fn a_16_bit_lab_invert_layer_keeps_greys_neutral() { + let mut d = Document::new("lab", Size::new(2, 2), ColorMode::Lab, SampleType::U16); + let mut bg = Layer::raster("bg", d.pixel_format()); + bg.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + d.layers.push(bg); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + let p = px(&d, 1, 1); + let lab = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]]); + assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}"); +} + #[test] fn cmyk_document_renders_via_rgb() { let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8); @@ -1499,6 +1513,22 @@ fn blend_if_underlying_layer_hides_by_the_backdrop_value() { assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0])); } +/// An 8-bit backdrop at level `k` counts as `k` for a hard Underlying black point at `k`, also +/// over a semi-transparent backdrop, where compositing leaves the value a few millionths off the +/// level (19.999998 for 20 before #4395); the GPU compositor agrees. +#[test] +fn blend_if_hard_point_includes_its_own_level() { + for k in 0..=255u8 { + let v = f32::from(k) / 255.0; + let mut d = Document::new("t", Size::new(2, 1), ColorMode::Rgb, SampleType::U8); + d.layers.push(solid_layer("under", Rect::new(0, 0, 2, 1), [v, v, v, 184.0 / 255.0])); + let mut l = solid_layer("top", Rect::new(0, 0, 2, 1), [1.0, 0.0, 0.0, 1.0]); + l.blend_if.set(1, [FULL, range([k, k], [255, 255])]); + d.layers.push(l); + assert!(close4(px(&d, 0, 0), [1.0, 0.0, 0.0, 1.0]), "level {k} is hidden by a black point at {k}"); + } +} + #[test] fn blend_if_split_point_fades_like_opacity() { // A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which @@ -2388,7 +2418,9 @@ fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() { }; let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol); let inv = render(Adjustment::Invert); - assert!(near(inv, [70.0, -13.0, 17.0], 1.0), "{depth:?}: {inv:?}"); + // 8-bit bytes become 255 - v (a, b one step past the mirror); 16 bits mirror them exactly. + let want = if depth == SampleType::U8 { [70.0, -13.0, 17.0] } else { [70.0, -12.0, 18.0] }; + assert!(near(inv, want, 0.5), "{depth:?}: {inv:?}"); let th = render(Adjustment::Threshold { level: 0.25 }); assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}"); let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false }); @@ -2416,10 +2448,10 @@ const MUTED: [f32; 3] = [0.40, 0.42, 0.60]; fn lab_documents_invert_and_posterize_l_a_and_b() { let lab = photocraft_color::convert::srgb_to_lab(MUTED); for depth in SampleType::ALL { - // Invert: every normalised channel n -> 1 - n, so L -> 100 - L and a, b -> their opposites - // (-a - 1 on the 0..255 grid). + // Invert: L -> 100 - L and a, b -> their opposites (-a - 1 on the 8-bit 0..255 grid). let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth); - near3(inv, [100.0 - lab[0], -lab[1] - 1.0, -lab[2] - 1.0], 1.0, &format!("{depth:?} invert")); + let step = if depth == SampleType::U8 { 1.0 } else { 0.0 }; + near3(inv, [100.0 - lab[0], -lab[1] - step, -lab[2] - step], 1.0, &format!("{depth:?} invert")); // An RGB invert would land elsewhere: lightness 100 - L is the Lab rule. let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v)); assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}"); @@ -2487,3 +2519,54 @@ fn lab_documents_gradient_map_follows_lightness() { adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb); assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } + +#[test] +fn render_refuses_rects_beyond_the_pixel_cap() { + let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + // 2^32 - 1 px wide: the width wraps negative as an i32, which once took the single-tile path. + for tile in [crate::RENDER_TILE, 1 << 20] { + let b = crate::render_tiled(&doc, Rect::new(i32::MIN, 0, i32::MAX, 1), tile); + assert!(b.px.is_empty() && b.rect.is_empty()); + } +} + +// The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape +// the maps are built from (a layer's own, or a styled group's children composite) without +// touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer +// had. Each case renders with the cache warm, edits, renders again and compares with a cold +// render. (A purge by a concurrent test only makes the warm render cold, never a false failure.) +#[test] +fn effect_cache_follows_transparency_shapes_layer() { + let mut d = doc_white(32, 32); + let mut l = solid_layer("holed", Rect::new(0, 0, 32, 32), [0.9, 0.2, 0.2, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(12, 12, 20, 20), &[0.0, 0.0, 0.0, 0.0]); + l.effects.items = vec![stroke(2.0, StrokePosition::Outside)]; + assert!(l.advanced.transparency_shapes); + d.layers.push(l); + let on = render(&d, d.bounds()); + d.layers[1].advanced.transparency_shapes = false; + let warm = render(&d, d.bounds()); + purge_effect_cache(); + let cold = render(&d, d.bounds()); + assert!(on.px != cold.px, "the stroke should stop rimming the hole"); + assert!(warm.px == cold.px, "stale effect maps after turning Transparency Shapes Layer off"); +} + +#[test] +fn effect_cache_follows_a_group_childs_blend_if() { + let mut d = doc_white(32, 32); + let mut child = solid_layer("bw", Rect::new(4, 4, 16, 28), [0.0, 0.0, 0.0, 1.0]); + child.surface_mut().unwrap().fill_rect(Rect::new(16, 4, 28, 28), &[1.0, 1.0, 1.0, 1.0]); + let mut g = Layer::group("g", vec![child]); + g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })]; + d.layers.push(g); + let before = render(&d, d.bounds()); + let LayerContent::Group(grp) = &mut d.layers[1].content else { panic!("not a group") }; + // Gray › This Layer: black point at 128 hides the black half. + grp.children[0].blend_if.set(0, [range([128, 128], [255, 255]), FULL]); + let warm = render(&d, d.bounds()); + purge_effect_cache(); + let cold = render(&d, d.bounds()); + assert!(before.px != cold.px, "Blend If should change the group's shadow"); + assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 354de06d31..a5c5381ee1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -99,6 +99,11 @@ pub struct Locks { } impl Locks { + /// Whether any lock is set: Photoshop then shows the lock icon on the layer's row. + pub fn any(self) -> bool { + self.transparency || self.pixels || self.position || self.artboard || self.all + } + pub fn union(self, o: Locks) -> Locks { Locks { transparency: self.transparency || o.transparency, @@ -1042,6 +1047,11 @@ impl Document { (1..=path.len()).filter_map(|n| self.layer_at(path.get(..n)?)).fold(Locks::default(), |a, l| a.union(l.locks)) } + /// Whether the layer at `path` is shown: it and every group around it are visible. + pub fn shown_at(&self, path: &[usize]) -> bool { + (1..=path.len()).all(|n| path.get(..n).and_then(|p| self.layer_at(p)).is_some_and(|l| l.visible)) + } + /// [`Self::locks_at`] for the layer `id`. pub fn effective_locks(&self, id: LayerId) -> Locks { self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p)) @@ -1195,6 +1205,19 @@ mod tests { Document::with_background("t", Size::new(100, 50), ColorMode::Rgb, SampleType::U8, Color::WHITE) } + #[test] + fn any_lock_counts_every_lock() { + assert!(!Locks::default().any()); + let each = [ + Locks { transparency: true, ..Locks::default() }, + Locks { pixels: true, ..Locks::default() }, + Locks { position: true, ..Locks::default() }, + Locks { artboard: true, ..Locks::default() }, + Locks { all: true, ..Locks::default() }, + ]; + assert!(each.iter().all(|k| k.any())); + } + #[test] fn unused_link_group_never_wraps() { let mut d = doc(); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fc586e3c2c..6f7836fa46 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -209,13 +209,18 @@ pub struct ParagraphRun { } /// Point text grows from its anchor (text-space origin = first baseline at the anchor); -/// paragraph (box) text wraps inside a rectangle. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +/// paragraph (box) text wraps inside a rectangle; path text runs along a curve. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum TextShape { #[default] Point, /// Text-space pixels, before the layer transform. Box { x: f32, y: f32, width: f32, height: f32 }, + /// Type on a path: one line laid out like point text, then each glyph is placed on `path` + /// (text-space pixels, before the layer transform) by arc length and turned to follow the + /// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip` + /// puts the text on the other side of the path. + Path { path: super::vector::Subpath, start: f32, flip: bool }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs new file mode 100644 index 0000000000..5ed1c3a3b0 --- /dev/null +++ b/crates/engine/examples/bench_web_palette.rs @@ -0,0 +1,30 @@ +//! Compare generated-palette and edited fixed-palette GIF exports on a 24 MP image. +//! cargo run --release -p photocraft-engine --example bench_web_palette +use std::time::Instant; + +use photocraft_engine::web_cmds::{WebSettings, optimize}; +use photocraft_geom::Rect; +use serde_json::json; + +fn main() -> Result<(), Box> { + let (w, h) = (6000usize, 4000usize); + let mut pixels = Vec::new(); + pixels.try_reserve_exact(w * h)?; + for y in 0..h { + for x in 0..w { + pixels.push([x as f32 / w as f32, 0.5 + ((x + y) as f32 * 0.002).sin() * 0.3, y as f32 / h as f32, 1.0]); + } + } + let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?; + let rect = Rect::new(0, 0, w as i32, h as i32); + let started = Instant::now(); + let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?; + println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0); + let mut edited = settings; + edited.color_table = Some(original.color_table); + edited.color_table_edits.insert(0, [0, 255, 0]); + let started = Instant::now(); + let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?; + println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len()); + Ok(()) +} diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 3ee242d7d6..3b3ffd1f01 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -115,10 +115,11 @@ pub fn replayable(id: &str) -> bool { && (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal))) } -/// File › Save and Save As, recorded by the desktop shell once the save has a path (#2032). -/// The shell replays them; batch and droplets write to their destination folder instead. +/// File › Save, Save As and Save a Copy, recorded by the desktop shell once the save has a path +/// (#2032, #4373). The shell replays them; batch and droplets write to their destination folder +/// instead. pub fn shell_save_command(id: &str) -> bool { - matches!(id, "file.save" | "file.saveAs") + matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") } /// View-only menu commands recorded by the desktop shell. Headless playback reports diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 48f6e56246..738ef921f4 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::tone::{self, HdrToning, MatchColor, ShadowsHighlights}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Rect}; -use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use photocraft_raster::{Surface, to_rgba}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -202,17 +202,8 @@ fn process_surface(surf: &mut Surface, selection: Option<&Surface>, f: &mut dyn let raw = surf.read_region(r); let mut px: Vec<[f32; 4]> = raw.chunks_exact(n).map(|q| to_rgba(&fmt, q)).collect(); let orig = px.clone(); - let w = r.width() as usize; f(&mut px, r); - let mut out = Vec::with_capacity(raw.len()); - for (i, (a, o)) in px.iter().zip(&orig).enumerate() { - let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0)); - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); - let mut enc = [0.0f32; 8]; - let m = from_rgba_into(&fmt, mixed, &mut enc); - out.extend_from_slice(&enc[..m]); - } - surf.write_region(r, &out); + surf.write_region(r, &crate::pixels::mix_through_selection(&fmt, r, &orig, &px, selection)); } /// One history step running `f` on the active pixel layer (or targeted channel or layer mask). @@ -463,7 +454,7 @@ pub fn specs() -> Vec { "image.adjustments.replaceColor", "Replace Color…", ["Image", "Adjustments"], - r##"{"color":json,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, + r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, has_pixels, replace_color ), diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 322e3ebd22..7c29ecc8ee 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -48,7 +48,7 @@ pub(crate) fn read_region(surface: &photocraft_raster::Surface, area: photocraft Ok(out) } -fn error(purpose: &str, detail: &str) -> EngineError { +pub(crate) fn error(purpose: &str, detail: &str) -> EngineError { EngineError::Other(format!("not enough memory for {purpose} ({detail}); the document was not changed")) } diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 1ba04dfe4c..64af849e59 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -842,6 +842,23 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { Ok(count_json(doc(s)?.as_ref())) } +/// A size parameter (`markerSize`, `labelSize`) clamped to `lo..=hi`: `None` when absent or null. +/// Whole numbers sent as JSON floats (`8.0`, as UIs and recorded actions do) count; fractions, +/// negatives and non-numbers are bad params rather than silently ignored. Clamping happens in +/// `u64`/`f64`, so a huge value can't wrap through `as u32`. +fn whole_size(cmd: &str, p: &Value, key: &str, lo: u32, hi: u32) -> Result> { + let v = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + let n = match (v.as_u64(), v.as_f64()) { + (Some(n), _) => n.clamp(u64::from(lo), u64::from(hi)), + (None, Some(f)) if f.is_finite() && f >= 0.0 && f.fract() == 0.0 => f.clamp(f64::from(lo), f64::from(hi)) as u64, + _ => return Err(bad(cmd, format!("`{key}` = {v} must be a whole number ({lo}..={hi})"))), + }; + Ok(Some(u32::try_from(n).unwrap_or(hi))) +} + fn count_set_group(s: &mut Session, p: &Value) -> Result { const CMD: &str = "count.setGroup"; let d = doc(s)?; @@ -853,11 +870,11 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { if let Some(c) = color_of(p, "color") { grp.color = c; } - if let Some(v) = p.get("markerSize").and_then(Value::as_u64) { - grp.marker_size = (v as u32).clamp(1, 10); + if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? { + grp.marker_size = v; } - if let Some(v) = p.get("labelSize").and_then(Value::as_u64) { - grp.label_size = (v as u32).clamp(8, 72); + if let Some(v) = whole_size(CMD, p, "labelSize", 8, 72)? { + grp.label_size = v; } if let Some(v) = p.get("visible").and_then(Value::as_bool) { grp.visible = v; diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index c043665b11..e5272d558d 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -349,3 +349,40 @@ fn now_iso_shape() { assert_eq!(t.len(), 20, "{t}"); assert!(t.ends_with('Z') && &t[4..5] == "-"); } + +#[test] +fn count_set_group_takes_sizes_sent_as_whole_floats() { + // The options bar sends `{"markerSize": 8.0, "labelSize": 20.0}`; `as_u64` read neither, so + // the fields did nothing. + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s.execute("count.setGroup", json!({"markerSize": 8.0, "labelSize": 20.0})).unwrap(); + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (8, 20)); + // Out-of-range whole numbers clamp, also huge ones that `as u32` would have wrapped. + s.execute("count.setGroup", json!({"markerSize": 4_294_967_297u64, "labelSize": 1e300})).unwrap(); + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (10, 72)); +} + +#[test] +fn count_set_group_rejects_sizes_that_are_not_whole_numbers() { + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s.execute("count.setGroup", json!({"markerSize": 3, "labelSize": 12})).unwrap(); + let past = s.active().unwrap().history.past_len(); + for p in [ + json!({"markerSize": 8.5}), + json!({"labelSize": 12.25}), + json!({"markerSize": -2}), + json!({"labelSize": -9.0}), + json!({"markerSize": "8"}), + json!({"labelSize": true}), + ] { + let err = s.execute("count.setGroup", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err}"); + } + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing"); + assert_eq!(s.active().unwrap().history.past_len(), past); +} diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index e5c8e74674..391c21abd8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -167,6 +167,8 @@ fn class(c: &str) -> Class { /// The pixel and percent units of a `UntF` value. const PX: [u8; 4] = *b"#Pxl"; const PCT: [u8; 4] = *b"#Prc"; +/// Points (1/72 in): what Photoshop calls "relative" units. +const PT: [u8; 4] = *b"#Rlt"; fn px(v: f64) -> D { D::UnitFloat { unit: PX, value: v } @@ -937,6 +939,49 @@ fn adjustment_steps(a: &Adjustment, prefix: &str, type_desc: Option<&Descriptor> step(&format!("{prefix}{}", a.suffix), params) } +/// A `patternLayer` descriptor's pattern, scale, angle and link as the params of +/// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. The pattern goes by name: ids differ +/// between Photoshop's library and one rebuilt from the same `.pat` file. None for a pattern +/// recorded without a name or id. +fn pattern_params(t: &Descriptor) -> Option> { + let mut p = Map::new(); + if let Some(ptrn) = get_obj(t, "Ptrn") { + let key = get_text(ptrn, "Nm ").filter(|n| !n.is_empty()).or_else(|| get_text(ptrn, "Idnt").filter(|i| !i.is_empty()))?; + p.insert("pattern".into(), key.into()); + } + if let Some(s) = get_num(t, "Scl ") { + p.insert("scale".into(), s.into()); + } + if let Some(a) = get_num(t, "Angl") { + p.insert("angle".into(), a.into()); + } + if let Some(l) = get_bool(t, "Algn") { + p.insert("link".into(), l.into()); + } + Some(p) +} + +/// The `patternLayer` descriptor for [`pattern_params`]' params. +fn pattern_desc(p: &Value) -> Option { + let mut t = desc("patternLayer"); + if let Some(n) = jstr(p, "pattern") { + t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n)))); + } + if let Some(s) = jnum(p, "scale") { + t = t.with("Scl ", pct(s)); + } + if let Some(a) = jnum(p, "angle") { + t = t.with("Angl", ang(a)); + } + if let Some(l) = jbool(p, "link") { + t = t.with("Algn", D::Boolean(l)); + } + if p.get("phase").is_some() { + return None; + } + Some(t) +} + fn make_from(d: &Descriptor) -> Mapped { if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. @@ -1010,6 +1055,10 @@ fn make_from(d: &Descriptor) -> Mapped { Some(c) => step("layer.newFillLayer.solidColor", json!({"color": c})), None => none("layer.newFillLayer.solidColor"), }, + Some(t) if t.class_id.is("patternLayer") => match pattern_params(t) { + Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + }, _ => Mapped::Unsupported { harmless: false }, }, Some("layerSection") => { @@ -1061,6 +1110,13 @@ fn make_to(id: &str, p: &Value) -> Option { } desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.newFillLayer.pattern" => { + if p.get("path").is_some() { + return None; + } + let t = pattern_desc(p)?; + desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1106,6 +1162,8 @@ fn set_from(d: &Descriptor) -> Mapped { }, Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) { (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})), + // ⌘-click on the targeted channel (a layer mask after `select` of `Msk`). + (Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})), (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), _ => unsupported, }, @@ -1138,6 +1196,37 @@ fn set_from(d: &Descriptor) -> Mapped { }, // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), + // Layer › New › Layer From Background (Photoshop records the New Layer dialog's values + // as a `set` of the Background); values other than the defaults then apply to the layer. + (Some("Lyr"), Target::Property(p)) if p == "Bckg" => { + // On an image that opened as a normal layer there is no Background to convert; + // Photoshop stops there, but the step's purpose (an editable layer) is met. + // Photoshop's `set` of another layer doesn't target it: the selected layer stays. + let mut v = vec![("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false}))]; + let mut props = Map::new(); + props.insert("layer".into(), "Layer 0".into()); + if let Some(t) = get_obj(d, "T ") { + if let Some(n) = get_text(t, "Nm ") { + props.insert("name".into(), n.into()); + } + if let Some(o) = get_num(t, "Opct").filter(|o| (o - 100.0).abs() > 1e-9) { + props.insert("opacity".into(), (o / 100.0).into()); + } + match get_enum(t, "Md ").as_deref() { + None | Some("Nrml") => {} + Some(m) => match blend_label(m) { + Some(label) => { + props.insert("blend".into(), label.into()); + } + None => return unsupported, + }, + } + } + if props.len() > 1 { + v.push(("layer.setProps", Value::Object(props))); + } + steps(v) + } (Some("Lyr"), _) => { let Some(mut p) = layer_target(r) else { return unsupported }; let Some(t) = get_obj(d, "T ") else { return unsupported }; @@ -1168,6 +1257,14 @@ fn set_from(d: &Descriptor) -> Mapped { } if v.is_empty() { unsupported } else { steps(v) } } + // A Pattern Fill layer's pattern (double-click its thumbnail). + (Some("contentLayer"), Target::Ordinal(o)) if o == "Trgt" => match get_obj(d, "T ") { + Some(t) if t.class_id.is("patternLayer") => match pattern_params(t) { + Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)), + _ => unsupported, + }, + _ => unsupported, + }, (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) { Some((a, t)) => match (a.from)(t) { @@ -1190,6 +1287,7 @@ fn set_to(id: &str, p: &Value) -> Option { "select.reselect" => sel(enm("Ordn", "Prvs")), "select.loadSelection" => match jstr(p, "channel")? { "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), + "targeted" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])), name => sel(name_ref("Chnl", name)), }, "select.lasso" => { @@ -1240,6 +1338,16 @@ fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", layer_ref(p)).with("T ", D::Descriptor(t)) } + "layer.new.layerFromBackground" => { + let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]); + desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml")))) + } + "layer.fill.pattern.set" => { + if p.get("layer").is_some() || p.get("document").is_some() { + return None; + } + desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?)) + } "layer.setAdjustment" => { // The adjustment kind isn't in the params: Photoshop needs it, so only steps that // name one (`kind`) export. @@ -1260,11 +1368,24 @@ fn select_from(d: &Descriptor) -> Mapped { match (ref_class(r).as_deref(), target(r)) { (Some("Lyr"), Target::Name(n)) => { let mut p = json!({"layer": n}); - if get_enum(d, "selectionModifier").as_deref() == Some("addToSelection") { - p["mode"] = json!("add"); + match get_enum(d, "selectionModifier").as_deref() { + Some("addToSelection") => p["mode"] = json!("add"), + Some("removeFromSelection") => p["mode"] = json!("remove"), + _ => {} } step("layer.select", p) } + // The Channels panel: the active layer's mask, or back to the composite (`RGB`, `CMYK`…). + (Some("Chnl"), Target::Ordinal(o)) => match o.as_str() { + "Msk" => step("channel.target", json!({"channel": "mask"})), + "RGB" | "CMYK" | "Lab" | "Gry" => step("channel.target", json!({"channel": "composite"})), + // Re-selecting the channel that is already targeted changes nothing. + "Trgt" => Mapped::Unsupported { harmless: true }, + _ => Mapped::Unsupported { harmless: false }, + }, + // History › Step Backward / Step Forward. + (Some("HstS"), Target::Ordinal(o)) if o == "Prvs" => none("edit.undo"), + (Some("HstS"), Target::Ordinal(o)) if o == "Nxt" => none("edit.redo"), (Some("Lyr"), Target::Ordinal(o)) => { // Shift+Alt+[ / ] add the next layer to the selection instead of replacing it. let add = get_enum(d, "selectionModifier").as_deref() == Some("addToSelection"); @@ -1285,14 +1406,28 @@ fn select_from(d: &Descriptor) -> Mapped { fn select_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); + let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]); Some(match id { "layer.select" => { let mut d = desc("null").with("null", name_ref("Lyr ", jstr(p, "layer")?)); - if jstr(p, "mode") == Some("add") { - d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")); + match jstr(p, "mode") { + None | Some("replace") => {} + Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), + Some("remove") => d = d.with("selectionModifier", enm("selectionModifierType", "removeFromSelection")), + Some(_) => return None, } d.with("MkVs", D::Boolean(false)) } + "channel.target" => { + let v = match jstr(p, "channel") { + Some("mask") => "Msk ", + None | Some("composite") => "RGB ", + Some(_) => return None, + }; + desc("null").with("null", enumerated("Chnl", "Chnl", v)).with("MkVs", D::Boolean(false)) + } + "edit.undo" => desc("null").with("null", enumerated("HstS", "Ordn", "Prvs")), + "edit.redo" => desc("null").with("null", enumerated("HstS", "Ordn", "Nxt ")), "layer.selectAbove" => desc("null").with("null", ord("Frwr")), "layer.selectBelow" => desc("null").with("null", ord("Bckw")), "layer.addAboveToSelection" => desc("null").with("null", ord("Frwr")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), @@ -1679,14 +1814,25 @@ fn move_from(d: &Descriptor) -> Mapped { let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; match d.get("T ") { Some(D::Descriptor(t)) => { - let in_px = |k: &str| matches!(t.get(k), Some(D::UnitFloat { unit: PX, .. })); + let unit = |k: &str| match t.get(k) { + Some(D::UnitFloat { unit, .. }) => Some(*unit), + _ => None, + }; let (Some(dx), Some(dy)) = (get_num(t, "Hrzn"), get_num(t, "Vrtc")) else { return Mapped::Unsupported { harmless: false } }; - if !in_px("Hrzn") || !in_px("Vrtc") { - // Points at an unknown resolution. - return Mapped::Unsupported { harmless: false }; + match (unit("Hrzn"), unit("Vrtc")) { + (Some(PX), Some(PX)) => { + p.insert("dx".into(), dx.round().into()); + p.insert("dy".into(), dy.round().into()); + } + // A nudge recorded with the rulers in other units: points, converted to pixels + // at the resolution of the document the action plays on. + (Some(PT), Some(PT)) => { + p.insert("dx".into(), dx.into()); + p.insert("dy".into(), dy.into()); + p.insert("units".into(), "pt".into()); + } + _ => return Mapped::Unsupported { harmless: false }, } - p.insert("dx".into(), dx.round().into()); - p.insert("dy".into(), dy.round().into()); step("layer.translate", Value::Object(p)) } Some(D::Reference(t)) => { @@ -1708,7 +1854,15 @@ fn move_from(d: &Descriptor) -> Mapped { fn move_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let t = match id { - "layer.translate" => D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0)))), + "layer.translate" => { + let unit = match jstr(p, "units") { + None | Some("px") => PX, + Some("pt") => PT, + Some(_) => return None, + }; + let v = |k: &str| D::UnitFloat { unit, value: jnum(p, k).unwrap_or(0.0) }; + D::Descriptor(desc("Ofst").with("Hrzn", v("dx")).with("Vrtc", v("dy"))) + } "layer.arrange.bringToFront" => ord("Frnt"), "layer.arrange.sendToBack" => ord("Back"), "layer.arrange.bringForward" => ord("Nxt "), @@ -1941,17 +2095,59 @@ fn convert_mode_from(d: &Descriptor) -> Mapped { _ => Mapped::Unsupported { harmless: false }, }; } - match get_enum(d, "T ").and_then(|t| lookup(MODES, &t)) { + // The mode as an enumeration (`ClrS.Grys`), a class (`Grys`), or an object of the mode's + // class holding its options (`BtmM {Rslt, Mthd}`, `Grys {Rt}` from Bitmap). + let (mode, options) = match d.get("T ") { + Some(D::Enumerated { value, .. }) => (trimmed(value), None), + Some(D::Class(c)) => (trimmed(&c.class_id), None), + Some(D::Descriptor(o)) => (trimmed(&o.class_id), Some(o)), + _ => return Mapped::Unsupported { harmless: false }, + }; + if mode == "BtmM" { + return bitmap_from(options); + } + // Bitmap to Grayscale records a size ratio; only 1 keeps the pixels as they are. + if options.and_then(|o| get_num(o, "Rt ")).is_some_and(|r| (r - 1.0).abs() > 1e-9) { + return Mapped::Unsupported { harmless: false }; + } + match lookup(MODES, &mode) { Some(m) => none(&format!("image.mode.{m}")), None => Mapped::Unsupported { harmless: false }, } } -fn convert_mode_to(id: &str, _: &Value) -> Option { +const BITMAP_METHOD: &[(&str, &str)] = &[("Thrh", "threshold"), ("PtnD", "pattern"), ("DfnD", "diffusion")]; + +/// Image › Mode › Bitmap. Its Output resolution resamples the image, keeping the print size, +/// before the conversion; at the document's own resolution that is a no-op. Halftone screens +/// and custom patterns stay unsupported. +fn bitmap_from(options: Option<&Descriptor>) -> Mapped { + let Some(o) = options else { return none("image.mode.bitmap") }; + let method = match get_enum(o, "Mthd") { + None => "diffusion", + Some(m) => match lookup(BITMAP_METHOD, &m) { + Some(m) => m, + None => return Mapped::Unsupported { harmless: false }, + }, + }; + let convert = ("image.mode.bitmap", json!({"method": method})); + match get_num(o, "Rslt") { + Some(r) => steps(vec![("image.imageSize", json!({"resolution": r, "resample": "bicubic", "keepPrintSize": true})), convert]), + None => steps(vec![convert]), + } +} + +fn bitmap_to(p: &Value) -> Option { + let method = lookup_back(BITMAP_METHOD, jstr(p, "method").unwrap_or("diffusion"))?; + Some(desc("null").with("T ", D::Descriptor(desc("BtmM").with("Mthd", enm("Mthd", method))))) +} + +fn convert_mode_to(id: &str, p: &Value) -> Option { Some(match id { "image.mode.bits8" => desc("null").with("Dpth", long(8.0)), "image.mode.bits16" => desc("null").with("Dpth", long(16.0)), "image.mode.bits32" => desc("null").with("Dpth", long(32.0)), + "image.mode.bitmap" => bitmap_to(p)?, _ => desc("null").with("T ", enm("ClrS", lookup_back(MODES, id.strip_prefix("image.mode.")?)?)), }) } @@ -2065,6 +2261,10 @@ fn image_size_from(d: &Descriptor) -> Mapped { match lookup(INTERPOLATION, &i) { Some(i) => { p.insert("resample".into(), i.into()); + // Resample on with only the resolution changed: Photoshop keeps the print size. + if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") && !p.contains_key("percent") { + p.insert("keepPrintSize".into(), true.into()); + } } None => return Mapped::Unsupported { harmless: false }, } @@ -2257,6 +2457,48 @@ macro_rules! adjustment_event { }; } +const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")]; + +/// Select and Mask (`refineSelectionEdge`). Smart Radius, decontamination and the output follow +/// Photoshop's; the edge itself is PhotoCraft's own matting, so results differ in detail. +const REFINE_EDGE: &[F] = &[ + F("refineEdgeBorderRadius", "radius", Kind::Px), + F("refineEdgeAutoRadius", "smartRadius", Kind::Bool), + F("refineEdgeSmooth", "smooth", Kind::Long), + F("refineEdgeFeatherRadius", "feather", Kind::Px), + F("refineEdgeBorderContrast", "contrast", Kind::Pct), + F("refineEdgeChoke", "shiftEdge", Kind::Pct), + F("refineEdgeDecontaminate", "decontaminate", Kind::Bool), + F("refineEdgeDeconAmount", "amount", Kind::Pct), + F("refineEdgeOutput", "output", Kind::Enum("refineEdgeOutput", REFINE_OUTPUT)), +]; +const REFINE_OUTPUT: &[(&str, &str)] = &[ + ("refineEdgeOutputSelection", "selection"), + ("refineEdgeOutputUserMask", "layerMask"), + ("refineEdgeOutputNewLayer", "newLayer"), + ("refineEdgeOutputNewLayerWithMask", "newLayerWithMask"), + ("refineEdgeOutputNewDocument", "newDocument"), + ("refineEdgeOutputNewDocumentWithMask", "newDocumentWithMask"), +]; + +/// Filter Gallery effects (`GEfc` with the effect in `GEfk`): Photoshop's effect code, the +/// command, and its fields. A stack of several effects stays unsupported. +const GALLERY: &[(&str, &str, &[F])] = &[("Spt", "filter.gallery.spatter", &[F("SprR", "sprayRadius", Kind::Long), F("Smth", "smoothness", Kind::Long)])]; +const GALLERY_IDS: &[&str] = &["filter.gallery.spatter"]; + +fn gallery_from(d: &Descriptor) -> Mapped { + let Some(effect) = get_enum(d, "GEfk") else { return Mapped::Unsupported { harmless: false } }; + match GALLERY.iter().find(|(code, _, _)| *code == effect).and_then(|(_, id, fields)| Some((*id, fields_from(d, fields)?))) { + Some((id, p)) => step(id, Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn gallery_to(id: &str, p: &Value) -> Option { + let (code, _, fields) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?; + fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code))) +} + /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. const EVENTS: &[Event] = &[ // Filters. @@ -2320,6 +2562,9 @@ const EVENTS: &[Event] = &[ "filter.other.offset", &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), + ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), + ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), + Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). Event { code: "Lvls", @@ -2436,6 +2681,7 @@ const EVENTS: &[Event] = &[ "image.mode.bits8", "image.mode.bits16", "image.mode.bits32", + "image.mode.bitmap", ], from: convert_mode_from, to: convert_mode_to, @@ -2453,6 +2699,7 @@ const EVENTS: &[Event] = &[ "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", + "layer.newFillLayer.pattern", "layer.newAdjustmentLayer.levels", "layer.newAdjustmentLayer.curves", "layer.newAdjustmentLayer.hueSaturation", @@ -2490,6 +2737,8 @@ const EVENTS: &[Event] = &[ "layer.renameLayer", "layer.setLabelColor", "layer.setAdjustment", + "layer.fill.pattern.set", + "layer.new.layerFromBackground", ], from: set_from, to: set_to, @@ -2506,6 +2755,9 @@ const EVENTS: &[Event] = &[ "layer.addBelowToSelection", "layer.selectTop", "layer.selectBottom", + "channel.target", + "edit.undo", + "edit.redo", ], from: select_from, to: select_to, @@ -2574,6 +2826,7 @@ const EVENTS: &[Event] = &[ ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]), ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), + ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE), // Edit. Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index fc73a18412..bdb839fa30 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -295,6 +295,26 @@ fn every_mapped_command_exports_and_imports_back() { ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), + // The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine). + ("filter.distort.ripple", json!({"amount": 50, "size": "medium"})), + ("filter.pixelate.crystallize", json!({"cellSize": 5})), + ("filter.gallery.spatter", json!({"sprayRadius": 7, "smoothness": 15})), + ( + "select.refineEdge", + json!({"radius": 0, "smartRadius": false, "smooth": 2, "feather": 0.5, "contrast": 15, "shiftEdge": -100, "decontaminate": false, "output": "layerMask"}), + ), + ("channel.target", json!({"channel": "mask"})), + ("channel.target", json!({"channel": "composite"})), + ("edit.undo", json!({})), + ("edit.redo", json!({})), + ("layer.select", json!({"layer": "YELLOW", "mode": "remove"})), + ("layer.translate", json!({"dx": 0.36, "dy": -1.08, "units": "pt"})), + ("select.loadSelection", json!({"channel": "targeted"})), + ("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})), + ("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})), + ("image.mode.bitmap", json!({"method": "threshold"})), + ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), + ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); @@ -409,7 +429,7 @@ fn every_mapped_command_exports_and_imports_back() { #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a - // move in points, an unknown adjustment class. + // move in percent, an unknown adjustment class. let items = vec![ item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)).with("Hght", pct(150.0)))), item( @@ -420,10 +440,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { item( "move", "Move", - Some(desc("null").with("null", target_ref("Lyr ")).with( - "T ", - D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })), - )), + Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Ofst").with("Hrzn", pct(3.0)).with("Vrtc", pct(1.0))))), ), item( "make", @@ -746,3 +763,179 @@ fn remaining_events_import_in_their_photoshop_shapes() { let e = s.execute("image.applyImage", json!({"source": {"layer": "Nope"}})).unwrap_err().to_string(); assert!(e.contains("Nope"), "{e}"); } + +fn channel_ord(value: &str) -> D { + D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: code(value) }]) +} + +fn fsel(t: D) -> Descriptor { + desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", t) +} + +fn set_of(name: &str, actions: Vec<(&str, Vec)>) -> String { + let file = AtnFile { + name: UnicodeString::new_nul(name), + expanded: false, + actions: actions.into_iter().map(|(n, items)| AtnAction { name: UnicodeString::new_nul(n), items, ..Default::default() }).collect(), + warnings: vec![], + }; + b64(&atn::write(&file).unwrap()) +} + +/// The step shapes of the TrueGrit Texture Supply sets (Distress Press, Debaser, Transparency, +/// Halftone Zine Machine), rebuilt here as Photoshop records them, import and play: the +/// distressing filters land on the targeted layer mask, Remove White leaves the white +/// transparent, nudges in points follow the resolution, and Step Backward undoes a step. +#[test] +fn distressing_and_remove_white_shapes_import_and_play() { + let ord = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new(ty), value: code(v) }]); + let black = D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0))); + let fill = desc("null") + .with("null", class_ref("contentLayer")) + .with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(desc("solidColorLayer").with("Clr ", black))))); + let refine = desc("null") + .with("refineEdgeBorderRadius", px(0.0)) + .with("refineEdgeBorderContrast", pct(100.0)) + .with("refineEdgeSmooth", long(2.0)) + .with("refineEdgeFeatherRadius", px(0.0)) + .with("refineEdgeChoke", pct(0.0)) + .with("refineEdgeAutoRadius", D::Boolean(false)) + .with("refineEdgeDecontaminate", D::Boolean(false)) + .with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask")); + let distress = vec![ + item("setd", "Set", Some(fsel(channel_ord("Trsp")))), + item("Hd ", "Hide", Some(desc("null").with("null", D::List(vec![target_ref("Lyr ")])))), + item("Mk ", "Make", Some(fill)), + item("slct", "Select", Some(desc("null").with("null", channel_ord("Msk")).with("MkVs", D::Boolean(false)))), + item("refineSelectionEdge", "Select and Mask", Some(refine)), + item("Rple", "Ripple", Some(desc("null").with("Amnt", long(50.0)).with("RplS", enm("RplS", "Sml")))), + item("Crst", "Crystallize", Some(desc("null").with("ClSz", long(3.0)))), + item("GEfc", "Filter Gallery", Some(desc("null").with("GEfk", enm("GEft", "Spt")).with("SprR", long(7.0)).with("Smth", long(15.0)))), + item("slct", "Select", Some(desc("null").with("null", ord("Chnl", "Ordn", "Trgt")).with("MkVs", D::Boolean(false)))), + item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))), + ]; + let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]); + let remove_white = vec![ + item("CnvM", "Convert Mode", Some(desc("null").with("T ", D::Class(class("Grys"))))), + item( + "setd", + "Set", + Some(desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))))), + ), + item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))), + item("Dlt ", "Delete", None), + item("setd", "Set", Some(fsel(enm("Ordn", "None")))), + ]; + let pt = |v: f64| D::UnitFloat { unit: PT, value: v }; + let nudge = vec![item( + "move", + "Move", + Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Ofst").with("Hrzn", pt(0.36)).with("Vrtc", pt(-0.36))))), + )]; + let undo = vec![item("Invs", "Inverse", None), item("slct", "Select", Some(desc("null").with("null", ord("HstS", "Ordn", "Prvs"))))]; + let data = set_of("TrueGrit", vec![("Distress", distress), ("Remove White", remove_white), ("Nudge", nudge), ("Undo", undo)]); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 120, "height": 80, "resolution": 200})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 80, "height": 40})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + // Only the re-selection of the targeted channel stays a Photoshop step, switched off. + let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["action"].as_str().unwrap()).collect(); + assert_eq!(unsupported, ["Distress"], "{r}"); + + let r = s.execute("actions.play", json!({"action": "Distress"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + let st = s.active().unwrap(); + let fill = st.doc.layer(st.active_layer.unwrap()).unwrap(); + let mask = &fill.mask.as_ref().expect("the fill layer is masked by the ink").surface; + // The rectangle's straight top edge is roughened: pixels along it differ. + let edge: Vec = (20..100).map(|x| mask.sample_channel(x, 20, 0)).collect(); + assert!(edge.iter().any(|v| *v < 0.5) && edge.iter().any(|v| *v > 0.5), "{edge:?}"); + assert!(mask.sample_channel(60, 40, 0) > 0.9 && mask.sample_channel(5, 5, 0) < 0.1); + assert!(st.doc.selection.is_some(), "the targeted mask loads as the selection"); + assert_eq!(st.channel_view.target, crate::channel_cmds::ChannelTarget::LayerMask); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#202020"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.load", json!({"data": data})).unwrap(); + let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + let d = &s.active().unwrap().doc; + assert_eq!(d.mode, photocraft_color::ColorMode::Grayscale); + let l = d.layers.last().unwrap(); + assert!(!d.is_background(l.id), "Layer From Background"); + let surf = l.surface().unwrap(); + assert!(surf.sample_channel(2, 2, 1) < 0.01, "white is cleared"); + // #202020 is about 22 in the default (Dot Gain 20 %-compatible) working gray. + let ink = surf.sample_channel(20, 15, 1); + assert!((ink - (1.0 - 22.0 / 255.0)).abs() < 0.02, "the ink keeps its darkness as opacity: {ink}"); + // An image that opened as a normal layer has no Background: the step leaves it as it is. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents": "white"})).unwrap(); + s.execute("actions.load", json!({"data": data})).unwrap(); + let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + let surf = s.active().unwrap().doc.layers.last().unwrap().surface().unwrap().clone(); + assert!(surf.sample_channel(2, 2, 1) < 0.01, "white is cleared without a Background"); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "resolution": 200})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("actions.load", json!({"data": data})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + assert!(s.execute("actions.play", json!({"action": "Nudge"})).unwrap().get("failed").is_none()); + let st = s.active().unwrap(); + let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!((b.x0, b.y0), (11, 9), "0.36 pt is 1 px at 200 ppi"); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let before = s.active().unwrap().doc.selection.clone(); + assert!(s.execute("actions.play", json!({"action": "Undo"})).unwrap().get("failed").is_none()); + assert_eq!(s.active().unwrap().doc.selection, before, "Step Backward undid the Inverse"); +} + +/// Remove White on art in its own layer above a white Background: the Background becomes a +/// normal layer, but the art layer stays targeted, so the white is cleared from the art. +#[test] +fn remove_white_clears_the_targeted_layer_not_the_converted_background() { + let ord = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new(ty), value: code(v) }]); + let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]); + let steps = vec![ + item("CnvM", "Convert Mode", Some(desc("null").with("T ", D::Class(class("Grys"))))), + item( + "setd", + "Set", + Some(desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))))), + ), + item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))), + item("Dlt ", "Delete", None), + item("setd", "Set", Some(fsel(enm("Ordn", "None")))), + ]; + let data = set_of("TrueGrit", vec![("Remove White", steps)]); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + let art = s.execute("layer.new.layer", json!({"name": "Art"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"contents": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.load", json!({"data": data})).unwrap(); + let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + let st = s.active().unwrap(); + assert_eq!(st.active_layer.map(|l| l.0), Some(art), "the art layer stays targeted"); + let art = st.doc.layer(photocraft_doc::LayerId(art)).unwrap().surface().unwrap(); + assert!(art.sample_channel(2, 2, 1) < 0.01, "the art's white is cleared"); + assert!(art.sample_channel(20, 15, 1) > 0.99, "the black stays"); + let bottom = st.doc.layers.first().unwrap(); + assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer"); + assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); +} diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index b8f827d125..6a98ee3c4f 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -391,23 +391,8 @@ fn statistics(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(cmd, format!("unknown stack mode `{mode}`")))?; let input = p.get("input").or_else(|| p.get("paths")).cloned().ok_or_else(|| bad(cmd, "missing \"input\" (files or a folder)"))?; let align = p.get("align").and_then(Value::as_bool).unwrap_or(false); - let r = s.execute("file.scripts.loadFilesIntoStack", json!({"paths": input, "createSmartObject": !align}))?; + let r = s.execute("file.scripts.loadFilesIntoStack", json!({"paths": input, "align": align, "createSmartObject": true}))?; let n = r["layers"].as_u64().unwrap_or(0); - if align && n > 1 { - s.execute("select.allLayers", json!({}))?; - s.execute("edit.autoAlignLayers", json!({"projection": "auto"}))?; - let name = s.active().map(|d| d.doc.name.clone()).unwrap_or_default(); - s.edit("Convert to Smart Object", |doc, active| { - let layers = std::mem::take(&mut doc.layers); - let smart = crate::smart_cmds::layers_to_smart(doc, &name, layers)?; - *active = Some(smart.id); - doc.layers = vec![smart]; - Ok(()) - })?; - if let Some(st) = s.active_mut() { - st.selected_layers = st.active_layer.into_iter().collect(); - } - } let id = s.active().and_then(|d| d.doc.layers.first().map(|l| l.id)).ok_or(EngineError::NoDocument)?; s.select_layer(id)?; s.execute(&format!("layer.smartObjects.stackMode.{}", sm.id()), json!({"layer": id.0}))?; diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 7471f1cfd2..b60dc2940e 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -371,3 +371,45 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)], disabled: Default::default(), ..Default::default() }); batch(&mut s, "Loop"); } + +/// #4671: Load Files into Stack's Attempt to Automatically Align Source Images runs Auto-Align +/// Layers on the loaded layers, then (with Create Smart Object) puts them in one smart object; +/// without the smart object they stay loose layers, selected. +#[test] +fn load_files_into_stack_aligns_before_the_smart_object() { + let dir = tmp("stack-align"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 96, "height": 72, "background": "#606060"})).unwrap(); + s.edit("texture", |doc, active| { + let surf = doc.layer_mut(active.ok_or(EngineError::NoDocument)?).and_then(Layer::surface_mut).ok_or(EngineError::NoDocument)?; + for k in 0..40 { + let (x, y, v) = ((k * 37) % 88, (k * 23) % 66, (k % 7) as f32 / 6.0); + surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 3 + k % 5, y + 2 + k % 4), &[v, 1.0 - v, 0.5, 1.0]); + } + Ok(()) + }) + .unwrap(); + let files: Vec = (0..2) + .map(|i| { + let path = format!("{dir}/img{i}.png"); + crate::file_cmds::save_doc(&s.active().unwrap().doc, &path, None).unwrap(); + path + }) + .collect(); + let mut s = Session::new(); + let r = s.execute("file.scripts.loadFilesIntoStack", json!({"paths": files, "align": true})).unwrap(); + assert_eq!(r["layers"], 2); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), 2, "loose layers"); + assert!(st.doc.layers.iter().all(|l| matches!(l.content, LayerContent::Raster(_)))); + assert_eq!(st.selected_layers().len(), 2, "Auto-Align ran on them all"); + let mut s = Session::new(); + s.execute("file.scripts.loadFilesIntoStack", json!({"paths": files, "align": true, "createSmartObject": true})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 1); + let LayerContent::Smart(_) = &d.layers[0].content else { panic!("not a smart object") }; + // A single file has nothing to align with: it just loads. + let mut s = Session::new(); + s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [files[0].clone()], "align": true})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); +} diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index abc6b49d96..1fd186f931 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -10,7 +10,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; -use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke}; +use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, PresetCaptures, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -177,7 +177,7 @@ fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPre pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result { let mut b = s.tools.brush.clone(); if let Some(name) = p.get("preset").and_then(Value::as_str) { - b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush); + b = find_preset(s, name, cmd)?.picked_over(&s.tools.brush); } if let Some(patch) = p.get("brush").filter(|v| v.is_object()) { b = merge_brush(&b, patch, cmd)?; @@ -204,6 +204,7 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result b.smoothing.amount = v.clamp(0.0, 1.0); } b.color = color(p.get("color"), s.tools.foreground); + b.color_cmyk = s.tools.picked_cmyk([b.color[0], b.color[1], b.color[2]]).map(|k| ([b.color[0], b.color[1], b.color[2]], k)); b.background = s.tools.background; b.erase = flag(p, "erase", false); b.seed = match p.get("seed").and_then(Value::as_u64) { @@ -367,6 +368,7 @@ fn block_brush(b: BrushSettings, p: &Value) -> Result { pressure_opacity: false, spacing: 0.25, color: b.color, + color_cmyk: b.color_cmyk, background: b.background, erase: b.erase, smoothing: b.smoothing, @@ -435,17 +437,19 @@ pub struct LiveStroke { base: (photocraft_doc::DocId, u64), } -/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`]): when the -/// committing `paint.stroke` / `paint.pencil` matches it (same command, document revision, params, -/// seed and points), the command takes these pixels instead of rendering the stroke again. +/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`], +/// [`Session::offer_live_retouch`]): when the committing command matches it (same command, +/// document revision, params, seed and points), it takes these pixels instead of rendering the +/// stroke again. pub struct LiveCommit { - cmd: String, - base: (photocraft_doc::DocId, u64), - params: Value, - seed: u64, - points: Vec, - surface: Surface, - bounds: Rect, + pub(crate) cmd: String, + pub(crate) base: (photocraft_doc::DocId, u64), + pub(crate) params: Value, + /// The Brush's and Pencil's seed; the retouching tools have none. + pub(crate) seed: Option, + pub(crate) points: Vec, + pub(crate) surface: Surface, + pub(crate) bounds: Rect, } impl LiveCommit { @@ -459,11 +463,11 @@ impl LiveCommit { p } - fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool { + pub(crate) fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool { let base = s.active().map(|st| (st.doc.id, st.revision)); self.cmd == cmd && base == Some(self.base) - && p.get("seed").and_then(Value::as_u64) == Some(self.seed) + && p.get("seed").and_then(Value::as_u64) == self.seed && self.points == pts && Self::settings(p) == Self::settings(&self.params) } @@ -529,7 +533,7 @@ impl LiveStroke { pub fn into_commit(mut self) -> Result { let bounds = self.bounds(); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; - Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: self.seed, points: self.points, surface: surf.clone(), bounds }) + Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: Some(self.seed), points: self.points, surface: surf.clone(), bounds }) } /// Everything the stroke has touched so far. @@ -757,11 +761,16 @@ fn upsert(s: &mut Session, preset: BrushPreset) { fn presets_save(s: &mut Session, p: &Value) -> Result { let cmd = "brush.presets.save"; let name = name_param(p, cmd)?; - let brush = match p.get("brush").filter(|v| v.is_object()) { + let mut brush = match p.get("brush").filter(|v| v.is_object()) { Some(patch) => merge_brush(&s.tools.brush, patch, cmd)?, None => s.tools.brush.clone(), }; - upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new() }); + // The New Brush Preset dialog's boxes; without them the preset captures what it always did. + let captures = PresetCaptures { size: flag(p, "captureSize", true), tool_settings: flag(p, "toolSettings", true), color: flag(p, "color", false) }; + if captures.color { + brush.color = s.tools.foreground; + } + upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new(), captures }); // The saved brush is the current brush now (Photoshop selects the brush it just saved). s.tools.current_preset = Some(name.clone()); Ok(json!({ "name": name, "count": s.tools.presets.len() })) @@ -877,7 +886,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { } let tip = GrayTile::from_f32(tw, th, &data); let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() }; - upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new() }); + upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() }); s.tools.brush = brush; s.tools.current_preset = Some(name.clone()); Ok(json!({ "name": name, "width": tw, "height": th })) @@ -887,8 +896,11 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { let cmd = "tools.setBrush"; let mut b = s.tools.brush.clone(); let mut picked = None; + let mut picked_color = None; if let Some(name) = p.get("preset").and_then(Value::as_str) { - b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush); + let preset = find_preset(s, name, cmd)?; + b = preset.picked_over(&s.tools.brush); + picked_color = preset.captures.color.then_some(preset.brush.color); picked = Some(name.to_string()); } let reset = flag(p, "reset", false); @@ -917,6 +929,10 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { s.tools.current_preset = None; } else if let Some(name) = picked { s.tools.current_preset = Some(name); + // A preset saved with Include Color brings its colour as the foreground (Photoshop). + if let Some(c) = picked_color { + s.tools.foreground = c; + } } // The tool's brush always paints with full tips: load a library preset's from the store. s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; @@ -1037,7 +1053,14 @@ pub fn specs() -> Vec { true ), spec!("brush.presets.list", "List Brush Presets", r##"{"full":bool=false}"##, always, presets_list, false), - spec!("brush.presets.save", "Save Brush Preset", r##"{"name":string,"brush":{…BrushSettings}?=current brush}"##, always, presets_save, true), + spec!( + "brush.presets.save", + "Save Brush Preset", + r##"{"name":string,"brush":{…BrushSettings}?=current brush,"captureSize":bool=true (false: picking it keeps the tool's size),"toolSettings":bool=true (false: picking it keeps the tool's mode, opacity, flow and pressure buttons),"color":bool=false (picking it sets the foreground to the current foreground)}"##, + always, + presets_save, + true + ), spec!( "brush.presets.update", "Update Brush Preset", diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 3a04a470fb..1d44fe6e7d 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -191,7 +191,7 @@ fn live_color_replacement_matches_the_commit() { } } assert!(b.rgba(10, 20)[1] > 0.5 || sampling == "backgroundSwatch", "{depth}-bit {sampling}: recoloured"); - assert!(worst <= 2.0 / 255.0, "{depth}-bit {sampling}: live and commit differ by {worst}"); + assert!(worst <= 2.0 / 255.0 + 1e-6, "{depth}-bit {sampling}: live and commit differ by {worst}"); } } } @@ -237,6 +237,45 @@ fn presets_save_load_delete_round_trip() { assert!(s.execute("brush.presets.save", json!({})).is_err()); } +/// The New Brush Preset dialog's boxes (#4490): a preset saved without Capture Brush Size or +/// Include Tool Settings leaves the tool's size, mode, opacity, flow and pressure buttons as they +/// are when picked; one saved with Include Color brings its colour back as the foreground. A preset +/// saved without the boxes picks as it always did. +#[test] +fn presets_save_captures_size_tool_settings_and_color_as_asked() { + let mut s = session(10, 10); + s.execute("tools.setBrush", json!({"size": 40, "opacity": 0.5, "flow": 0.25, "mode": "Multiply", "pressureSize": false})).unwrap(); + s.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + s.execute("brush.presets.save", json!({"name": "Legacy"})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Bare", "captureSize": false, "toolSettings": false})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Red", "color": true})).unwrap(); + let caps = |n: &str| s.tools.presets.iter().find(|p| p.name == n).unwrap().captures.clone(); + assert!(caps("Legacy").is_default(), "no boxes, the captures of any other preset"); + assert_eq!(caps("Bare"), PresetCaptures { size: false, tool_settings: false, color: false }); + assert!(caps("Red").color); + + // Change the tool, then pick each. + let other = json!({"size": 9, "opacity": 1.0, "flow": 1.0, "mode": "Normal", "pressureSize": true}); + s.execute("tools.setBrush", other.clone()).unwrap(); + s.tools.foreground = [0.0, 0.0, 1.0, 1.0]; + s.execute("tools.setBrush", json!({"preset": "Legacy"})).unwrap(); + let b = &s.tools.brush; + assert_eq!((b.size, b.opacity, b.flow, b.mode, b.pressure_size), (40.0, 0.5, 0.25, photocraft_color::BlendMode::Multiply, false), "everything it saved"); + assert_eq!(s.tools.foreground, [0.0, 0.0, 1.0, 1.0], "a preset without Include Color leaves the foreground"); + + s.execute("tools.setBrush", other.clone()).unwrap(); + s.execute("tools.setBrush", json!({"preset": "Bare"})).unwrap(); + let b = &s.tools.brush; + assert_eq!((b.size, b.opacity, b.flow, b.mode, b.pressure_size), (9.0, 1.0, 1.0, photocraft_color::BlendMode::Normal, true), "the tool's own"); + assert_eq!(s.tools.current_preset.as_deref(), Some("Bare")); + + s.execute("tools.setBrush", json!({"preset": "Red"})).unwrap(); + assert_eq!(s.tools.foreground, [1.0, 0.0, 0.0, 1.0], "Include Color sets the foreground"); + // A stroke naming the preset takes the same brush. + let r = resolve_brush(&s, &json!({"preset": "Bare"}), "paint.brush").unwrap(); + assert_eq!(r.size, s.tools.brush.size); +} + #[test] fn presets_save_appends_at_the_end_and_update_overwrites_in_place() { let mut s = session(10, 10); @@ -744,10 +783,11 @@ fn out_of_range_scatter_is_rejected_and_the_maximum_still_paints() { let d: Vec = r["damage"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect(); assert!(d[0].abs() < 1000 && d[1].abs() < 1000 && d[2] > 0 && d[3] > 0, "{r}"); } - // Control: 200 % scatter paints where it did before the cap. + // Control: 200 % scatter paints where it did before the cap (the soft default tip reaches a + // little past its radius). let mut s = new(); s.execute("tools.setBrush", json!({ "brush": scatter(2.0) })).unwrap(); - assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [17, 14, 23, 23]})); + assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [15, 12, 27, 27]})); } #[test] @@ -883,3 +923,21 @@ fn clear_mode_erases_with_the_brush_or_pencil() { assert_eq!(s.active().unwrap().history.undo_label(), Some(label), "{cmd}"); } } + +/// #4344: a foreground picked as CMYK 100/0/0/0 is painted into a CMYK document as those inks by +/// the Brush and the Pencil, not as whatever its sRGB stand-in converts back to (100/28/0/17). +#[test] +fn brush_and_pencil_paint_a_cmyk_pick_with_the_picked_inks() { + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + for cmd in ["paint.stroke", "paint.pencil"] { + let mut s = session(16, 16); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + s.execute(cmd, json!({"points": [[8.0, 8.0]], "size": 6.0, "hardness": 1.0})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let got: Vec = surf.read_region(Rect::new(8, 8, 9, 9)).into_iter().take(4).collect(); + assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{cmd}: {got:?}"); + } +} diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 03b2814336..49ee1b0630 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -486,7 +486,14 @@ mod tests { fn with_groups() -> Session { let mut s = Session::new(); for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] { - s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into(), folder: Vec::new() }); + s.tools.presets.push(BrushPreset { + name: n.into(), + brush: Default::default(), + builtin: false, + group: g.into(), + folder: Vec::new(), + captures: Default::default(), + }); } s } @@ -591,7 +598,14 @@ mod tests { let mut s = Session::new(); for (n, f) in [("S0", &[][..]), ("I1", &["Ink"][..]), ("F1", &["Ink", "Fine"][..]), ("I2", &["Ink"][..]), ("D1", &["Dry"][..])] { let folder = f.iter().map(|x| x.to_string()).collect(); - s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Set".into(), folder }); + s.tools.presets.push(BrushPreset { + name: n.into(), + brush: Default::default(), + builtin: false, + group: "Set".into(), + folder, + captures: Default::default(), + }); } s } @@ -691,7 +705,14 @@ mod tests { let mut s = with_folders(); for (n, f) in [("O1", &[][..]), ("B1", &["Box"][..])] { let folder = f.iter().map(|x| x.to_string()).collect(); - s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Other".into(), folder }); + s.tools.presets.push(BrushPreset { + name: n.into(), + brush: Default::default(), + builtin: false, + group: "Other".into(), + folder, + captures: Default::default(), + }); } s } @@ -724,8 +745,22 @@ mod tests { let mut s = with_two_groups(); // A chain 31 deep under Set/Deep. let deep: Vec = std::iter::once("Deep".to_string()).chain((0..30).map(|i| format!("d{i}"))).collect(); - s.tools.presets.push(BrushPreset { name: "Z".into(), brush: Default::default(), builtin: false, group: "Set".into(), folder: deep.clone() }); - s.tools.presets.push(BrushPreset { name: "Y".into(), brush: Default::default(), builtin: false, group: "Other".into(), folder: vec!["Dry".into()] }); + s.tools.presets.push(BrushPreset { + name: "Z".into(), + brush: Default::default(), + builtin: false, + group: "Set".into(), + folder: deep.clone(), + captures: Default::default(), + }); + s.tools.presets.push(BrushPreset { + name: "Y".into(), + brush: Default::default(), + builtin: false, + group: "Other".into(), + folder: vec!["Dry".into()], + captures: Default::default(), + }); let before = s.tools.presets.clone(); let deep_json = json!(deep); for p in [ @@ -758,7 +793,14 @@ mod tests { s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "into": {"group": "Set"}})).unwrap(); assert_eq!(folder_of(&s, "F1"), ["Fine"]); // An ungrouped preset's group can't move. - s.tools.presets.push(BrushPreset { name: "U".into(), brush: Default::default(), builtin: false, group: String::new(), folder: Vec::new() }); + s.tools.presets.push(BrushPreset { + name: "U".into(), + brush: Default::default(), + builtin: false, + group: String::new(), + folder: Vec::new(), + captures: Default::default(), + }); assert!(s.execute("brush.presets.moveGroup", json!({"group": "", "into": {"group": "Set"}})).is_err()); } } diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index 8b1739d161..fc52cf0a43 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -73,7 +73,7 @@ pub(crate) fn copy(s: &mut Session) -> Result> { if bounds.is_empty() { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } - s.clipboard = Some(Clip { surface, bounds, layers: None }); + s.clipboard = Some(Clip { surface, bounds, layers: None, resolution: Some(doc.resolution_dpi) }); Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k}))) } diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 86bf55d1a1..120ab36b46 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -194,8 +194,8 @@ pub fn channel_format(doc: &Document) -> PixelFormat { PixelFormat::new(ColorMode::Grayscale, doc.depth, false) } -/// Next free "Alpha N" name. -fn next_alpha_name(doc: &Document, base: &str) -> String { +/// Next free "`base` N" name (Alpha 1, Spot Color 2, …). +pub fn next_alpha_name(doc: &Document, base: &str) -> String { // `channels.len() + 1` candidates always include a free one. (1..=doc.channels.len() + 1).map(|n| format!("{base} {n}")).find(|n| !doc.channels.iter().any(|c| &c.name == n)).unwrap_or_else(|| base.to_string()) } @@ -897,6 +897,20 @@ fn load_selection(s: &mut Session, p: &Value) -> Result { if p.get("channel").is_none() { return Err(bad(cmd, "missing `channel`")); } + // `"targeted"`: the Channels panel's target, as a Photoshop action's "load the target + // channel" step records it. + let mut targeted = p.clone(); + if p.get("channel").and_then(Value::as_str) == Some("targeted") { + let st = s.active().ok_or(EngineError::NoDocument)?; + targeted["channel"] = match st.channel_view.target { + ChannelTarget::Composite => json!("composite"), + ChannelTarget::Color(k) => json!({ "color": k }), + ChannelTarget::Alpha(i) => json!(i), + ChannelTarget::LayerMask => json!("mask"), + ChannelTarget::VectorMask => json!("vectorMask"), + }; + } + let p = &targeted; // Composite and colour channels load their luminosity / values, as ⌘-clicking them does. let plane = source(s, p, cmd, true)?.into_iter().next().ok_or_else(|| bad(cmd, "the channel has no pixels"))?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; @@ -1226,7 +1240,12 @@ fn target(s: &mut Session, t: ChannelTarget) { let v = &mut st.channel_view; v.target = t; match t { - ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => v.color_hidden.clear(), + // Like clicking RGB in Photoshop: the alpha overlays go away with their eyes. + ChannelTarget::Composite => { + v.color_hidden.clear(); + v.alpha_visible.clear(); + } + ChannelTarget::LayerMask | ChannelTarget::VectorMask => v.color_hidden.clear(), ChannelTarget::Color(k) => { v.color_hidden = (0..colors).map(|c| c != k).collect(); v.alpha_visible.clear(); @@ -1717,7 +1736,7 @@ pub fn specs() -> Vec { "Load Selection…", ["Select"], None, - r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, + r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection"|"targeted" (the Channels panel's target),"layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, has_doc, load_selection ), diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index d39dda70b7..1d721ee5c5 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -218,6 +218,20 @@ fn deleting_a_channel_remaps_all_surviving_eyes_and_targets() { } } +#[test] +fn targeting_the_composite_hides_the_alpha_overlay() { + for (cmd, params) in [("channel.target", json!({"channel": "composite"})), ("channel.target.composite", json!({}))] { + let mut s = session(); + s.execute("channel.new", json!({})).unwrap(); + assert!(s.active().unwrap().channel_view.alpha_shown(0)); + s.execute(cmd, params).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.channel_view.visible_colors(3), 3, "{cmd}"); + assert!(!st.channel_view.alpha_shown(0), "{cmd}: the alpha eye turns off"); + assert!(st.channel_view.is_plain(&st.doc), "{cmd}: no overlay left on the image (#4538)"); + } +} + #[test] fn every_colour_channel_can_hide_while_alpha_stays() { let mut s = session(); @@ -533,6 +547,18 @@ fn apply_image_hand_computed() { assert!(s.execute("image.applyImage", json!({"blending": "hue"})).is_err()); } +#[test] +fn apply_image_subtract_layer_from_itself_is_mid_grey() { + // Frequency separation: Subtract, Scale 2, Offset 128, in the flat form the dialog sends. + let mut s = session(); + s.execute("edit.fill", json!({"color": "#c08040"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "High"})).unwrap(); + s.execute("edit.fill", json!({"color": "#406080"})).unwrap(); + s.execute("image.applyImage", json!({"sourceLayer": "High", "blending": "subtract", "scale": 2.0, "offset": 128})).unwrap(); + let p = px(&s, 3, 3); + assert!(p[..3].iter().all(|v| (v - 128.0 / 255.0).abs() < 0.003), "{p:?}"); +} + #[test] fn apply_image_onto_transparent_layer() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 794b1b5507..31dfe87f61 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -133,7 +133,8 @@ impl Default for ColorSettings { Self { working_rgb: Builtin::Srgb.id().into(), working_cmyk: Builtin::CoatedCmyk.id().into(), - working_gray: Builtin::SGray.id().into(), + // Photoshop's default working gray is Dot Gain 20 %. + working_gray: Builtin::DotGain20Compat.id().into(), policy_rgb: Policy::Preserve, policy_cmyk: Policy::Preserve, policy_gray: Policy::Preserve, @@ -570,20 +571,41 @@ pub fn convert_surface(s: &Surface, from: ColorMode, to: PixelFormat, t: &Transf let mut dpo = vec![0.0f32; ds]; t.convert_f32(&dp, ss, &mut dpo, ds, true); let mut out = Surface::with_default(to, &dpo); - // Convert all tiles in one parallel pass into a staging buffer, then store them. - let tiles: Vec<_> = s.tiles().map(|(c, t)| (*c, t.clone())).collect(); + // Convert every distinct tile once, in one parallel pass into a staging buffer, then store + // them. Coordinates that share a tile (a filled background) share the converted one (#4531). + let mut distinct: Vec> = Vec::new(); + let mut slot: std::collections::HashMap<*const photocraft_raster::Tile, usize> = std::collections::HashMap::new(); + let tiles: Vec<(photocraft_geom::TileCoord, usize)> = s + .tiles() + .map(|(c, tile)| { + let i = *slot.entry(std::sync::Arc::as_ptr(tile)).or_insert_with(|| { + distinct.push(tile.clone()); + distinct.len() - 1 + }); + (*c, i) + }) + .collect(); let tile_px = (photocraft_geom::TILE_SIZE * photocraft_geom::TILE_SIZE) as usize; let dst_len = tile_px * to.bytes_per_pixel(); - let mut staging = vec![0u8; dst_len * tiles.len()]; - let jobs: Vec<(&[u8], &mut [u8])> = tiles.iter().map(|(_, t)| t.bytes()).zip(staging.chunks_mut(dst_len.max(1))).collect(); + let mut staging = vec![0u8; dst_len * distinct.len()]; + let jobs: Vec<(&[u8], &mut [u8])> = distinct.iter().map(|t| t.bytes()).zip(staging.chunks_mut(dst_len.max(1))).collect(); let kind = match sf.sample { SampleType::U8 => SampleKind::U8, SampleType::U16 => SampleKind::U16, SampleType::F32 => SampleKind::F32, }; t.convert_bytes_many(kind, jobs, ss, ds, true); - for ((c, _), bytes) in tiles.iter().zip(staging.chunks(dst_len.max(1))) { - out.tile_mut(*c).bytes_mut().copy_from_slice(bytes); + let mut converted: Vec>> = vec![None; distinct.len()]; + for (c, i) in tiles { + if let Some(Some(tile)) = converted.get(i) { + out.put_tiles([(c, tile.clone())]); + continue; + } + let Some(bytes) = staging.chunks(dst_len.max(1)).nth(i) else { continue }; + out.tile_mut(c).bytes_mut().copy_from_slice(bytes); + if let Some(slot) = converted.get_mut(i) { + *slot = out.tile(c).cloned(); + } } out } @@ -1062,7 +1084,7 @@ pub fn specs() -> Vec { "edit.assignProfile", "Assign Profile…", ["Edit"], - r##"{"profile":"working|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|none" (or a path to an .icc file)}"##, + r##"{"profile":"working|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|dot-gain-20-compat|lab-d50|coated-cmyk|none" (or a path to an .icc file)}"##, has_doc, assign_profile, true @@ -1080,7 +1102,7 @@ pub fn specs() -> Vec { "edit.colorSettings", "Color Settings…", ["Edit"], - r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"monitorProfile":"auto|srgb|display-p3|adobe-rgb-compat|prophoto-compat|rec2020","reset":bool=false} (working spaces and the monitor profile also accept .icc paths; monitor `auto` = the main display's profile when the platform provides it, else sRGB; the reply's `monitorStatus` says which profile is in use and why: source auto|manual|fallback, reason)"##, + r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"dot-gain-20-compat|sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"monitorProfile":"auto|srgb|display-p3|adobe-rgb-compat|prophoto-compat|rec2020","reset":bool=false} (working spaces and the monitor profile also accept .icc paths; monitor `auto` = the main display's profile when the platform provides it, else sRGB; the reply's `monitorStatus` says which profile is in use and why: source auto|manual|fallback, reason)"##, always, color_settings, true, @@ -1124,6 +1146,29 @@ mod tests { use super::*; use photocraft_geom::Rect; + /// Image › Mode to CMYK keeps a filled background's shared tile shared (#4531): one + /// converted tile for the whole grid, the same colour everywhere. + #[test] + fn mode_conversion_keeps_shared_tiles_shared() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2048, "height": 1024, "background": "white"})).unwrap(); + let distinct = |s: &Session| { + let doc = &s.active().unwrap().doc; + let surf = doc.layers[0].surface().unwrap(); + let mut seen: Vec<_> = surf.tiles().map(|(_, t)| std::sync::Arc::as_ptr(t)).collect(); + seen.sort(); + seen.dedup(); + (seen.len(), surf.tile_count()) + }; + let (before, count) = distinct(&s); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let (after, count_after) = distinct(&s); + assert_eq!((after, count_after), (before, count), "{before} distinct of {count} before"); + let doc = &s.active().unwrap().doc; + let surf = doc.layers[0].surface().unwrap(); + assert_eq!(surf.pixel(5, 5), surf.pixel(2000, 1000), "the same converted white everywhere"); + } + fn session(mode: &str, depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 64, "height": 32, "mode": mode, "depth": depth})).unwrap(); @@ -1280,7 +1325,10 @@ mod tests { let t = s.color.display_transform(doc(&s)).unwrap(); let mut o = [0.0f32; 3]; t.eval(&[0.5, 0.5, 0.5], &mut o); - assert!(o.iter().all(|v| (v - 0.5).abs() < 2e-3), "sGray displays as sRGB gray: {o:?}"); + // The default working gray (Dot Gain 20 %-compatible, gamma 1.732) shows gray 0.5 as the + // sRGB value of linear 0.5^1.732, as Photoshop does: lighter than sGray's 0.5. + let want = photocraft_color::convert::linear_to_srgb(0.5f32.powf(1.732)); + assert!(o.iter().all(|v| (v - want).abs() < 2e-3), "Dot Gain 20 % gray displays as {want}: {o:?}"); let s = session("cmyk", 8); assert_eq!(composite_profile(doc(&s)).description, "sRGB IEC61966-2.1"); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 09db339bfe..89278165e6 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -652,7 +652,7 @@ fn build() -> Vec { "Select Layer", [], None, - r##"{"layer":id,"mode":"replace|toggle|range|add"="replace"} (toggle = ⌘-click, range = ⇧-click)"##, + r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, has_doc, crate::layer_multi_cmds::select ), @@ -922,7 +922,7 @@ fn build() -> Vec { "Move Layer", [], None, - r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##, + r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##, has_layer, crate::layer_multi_cmds::translate ), diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index 58f92f84ea..fe5708387c 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -319,13 +319,21 @@ fn restore_last(s: &mut Session) -> Result { } /// Comps that refer to deleted layers (Photoshop's warning triangle). `"clear": true` drops -/// those stale states (Clear Layer Comp Warning) as one history step. +/// those stale states as one history step: from `"comp"` only when given (Clear Layer Comp +/// Warning), else from every comp (Clear All Layer Comp Warnings). `"comp"` also limits the +/// report to that comp; an unknown comp is an error and changes nothing. fn update_warnings(s: &mut Session, p: &Value) -> Result { + let cmd = "layerComp.updateWarnings"; let d = s.active().ok_or(EngineError::NoDocument)?; + let only = match p.get("comp") { + None | Some(Value::Null) => None, + Some(_) => Some(comp_param(&d.doc, p, cmd)?), + }; let warnings: Vec = d .doc .layer_comps .iter() + .filter(|c| only.is_none_or(|id| c.id == id)) .filter_map(|c| { let missing = c.missing_layers(&d.doc); (!missing.is_empty()).then(|| json!({"comp": c.id, "name": c.name, "missingLayers": missing.iter().map(|l| l.0).collect::>()})) @@ -333,9 +341,10 @@ fn update_warnings(s: &mut Session, p: &Value) -> Result { .collect(); let n = warnings.len(); if flag(p, "clear", false) && n > 0 { - s.edit("Clear Layer Comp Warnings", |doc, _| { + let label = if only.is_some() { "Clear Layer Comp Warning" } else { "Clear All Layer Comp Warnings" }; + s.edit(label, |doc, _| { let live: std::collections::HashSet = doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); - for c in &mut doc.layer_comps { + for c in doc.layer_comps.iter_mut().filter(|c| only.is_none_or(|id| c.id == id)) { c.states.retain(|st| live.contains(&st.layer)); } Ok(()) @@ -434,7 +443,7 @@ pub fn specs() -> Vec { "layerComp.updateWarnings", "Layer Comp Warnings", &[], - r##"{"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, + r##"{"comp":id|name? (default: every comp; limits the report and the clear to that comp),"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, has_doc, update_warnings ), diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 22ef1a4697..5458523bcf 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -304,3 +304,46 @@ fn unreachable_comp_positions_leave_the_layer_in_place() { assert_eq!(pos(&s, b), Some((31, 22))); } } + +#[test] +fn clearing_one_comps_warning_leaves_the_others() { + // Regression for #4197: the per-comp warning icon cleared every comp's warnings. + let (mut s, a, _) = session(8); + let c1 = s.execute("layerComp.new", json!({"name": "One"})).unwrap()["comp"].as_u64().unwrap(); + let c2 = s.execute("layerComp.new", json!({"name": "Two"})).unwrap()["comp"].as_u64().unwrap(); + s.edit("del", |d, _| { + d.remove(a); + Ok(()) + }) + .unwrap(); + // Reporting can be limited to one comp too. + let w = s.execute("layerComp.updateWarnings", json!({"comp": c2})).unwrap(); + assert_eq!(w["count"], 1); + assert_eq!(w["warnings"][0]["comp"], c2); + s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": c1})).unwrap(); + assert!(doc(&s).comp(c1 as u32).unwrap().missing_layers(doc(&s)).is_empty()); + assert_eq!(doc(&s).comp(c2 as u32).unwrap().missing_layers(doc(&s)), vec![a]); + let w = s.execute("layerComp.updateWarnings", json!({})).unwrap(); + assert_eq!(w["count"], 1); + assert_eq!(w["warnings"][0]["comp"], c2); + // Without "comp" every comp is cleared (Clear All Layer Comp Warnings). + s.execute("layerComp.updateWarnings", json!({"clear": true})).unwrap(); + assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 0); +} + +#[test] +fn clearing_warnings_of_an_unknown_comp_is_an_error() { + let (mut s, a, _) = session(8); + let c1 = s.execute("layerComp.new", json!({})).unwrap()["comp"].as_u64().unwrap(); + s.edit("del", |d, _| { + d.remove(a); + Ok(()) + }) + .unwrap(); + let before = doc(&s).layer_comps.clone(); + for bad in [json!(c1 + 99), json!("nope"), json!(-1), json!(1.5), json!(true), json!([c1])] { + assert!(s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": bad})).is_err(), "{bad}"); + } + assert_eq!(doc(&s).layer_comps, before); + assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); +} diff --git a/crates/engine/src/display_color_tests.rs b/crates/engine/src/display_color_tests.rs index 2ce0e0cfb1..e40dca9bf6 100644 --- a/crates/engine/src/display_color_tests.rs +++ b/crates/engine/src/display_color_tests.rs @@ -80,8 +80,11 @@ fn srgb_is_the_identity_fast_path() { assert!(s.color.canvas_lut(&d, 9).unwrap().is_none(), "no GPU LUT either"); assert!(close(shown(&s, &d), [204.0, 127.5, 76.5], 0.6)); } - // Untagged gray (sGray), CMYK (shown in sRGB) and Lab composites are sRGB too. - let g = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::gray(0.3)); + // sGray-tagged gray and CMYK (shown in sRGB) composites are sRGB too; untagged gray takes the + // default working gray (Dot Gain 20 %-compatible), which is not. + let mut g = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::gray(0.3)); + assert!(!s.color.canvas_display(&g).unwrap().is_identity()); + g.icc_profile = Some(Builtin::SGray.profile().to_bytes()); assert!(s.color.canvas_display(&g).unwrap().is_identity()); assert!(s.color.canvas_display(&cmyk_doc(None, [0.1, 0.5, 0.5, 0.0])).unwrap().is_identity()); // Cached: the same Arc for the same profiles. @@ -195,7 +198,8 @@ fn gray_exr_round_trip_is_linear() { let mut s = Session::new(); // An sGray document exported to EXR is linearised (#2386); re-opened it is tagged linear gray // and displays as before. - let d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(128.0 / 255.0)); + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(128.0 / 255.0)); + d.icc_profile = Some(Builtin::SGray.profile().to_bytes()); let r = photocraft_io::export(&d, "x.exr", &photocraft_io::ExportOptions::default()).unwrap(); let back = photocraft_io::import("x.exr", &r.bytes).unwrap().document; let icc = back.icc_profile.clone().expect("tagged"); diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index c164eaf679..55556c53d8 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -216,7 +216,7 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> { fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> { let locks = l.locks.union(group); if locks.all || locks.pixels || (position && locks.position) { - return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); } Ok(()) } @@ -244,6 +244,9 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let LayerContent::Smart(_) = l.content { + // As filters do: a pixel or full lock refuses the smart filter (the position lock + // doesn't, since the smart object itself stays put). + check_locks(l, group, false)?; let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index d0ea88ed05..50a8a49e62 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -352,3 +352,62 @@ fn liquify_with_a_far_off_stroke_point_returns() { assert_eq!(active_surface(&s).read_region(Rect::new(0, 0, 80, 60)), before.read_region(Rect::new(0, 0, 80, 60)), "{depth}: off-canvas only"); } } + +/// The three distortions on the active smart object, each with a visible change. +fn smart_distortions() -> [(&'static str, Value); 3] { + let q = [[10.0, 10.0], [60.0, 10.0], [60.0, 40.0], [10.0, 40.0]]; + [ + (LIQUIFY, json!({"strokes": [{"tool": "twirlCw", "size": 40, "points": [[35, 25], [35, 25], [35, 25]]}]})), + (PUPPET_SMART, json!({"pins": [{"src": [30, 25], "dst": [34, 25]}], "mode": "rigid"})), + (PERSPECTIVE_SMART, json!({"planes": [{"src": q, "dst": [[12.0, 10.0], [58.0, 10.0], [60.0, 40.0], [10.0, 40.0]]}]})), + ] +} + +#[test] +fn locked_smart_objects_refuse_distortions() { + // As filters do: a pixel or full lock (the layer's own or its group's) refuses the smart + // filter, leaving the layer and the history as they were. + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + for (cmd, p) in smart_distortions() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the smart object ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.layer(layer).unwrap().clone(); + let r = s.execute(cmd, p); + assert!(r.is_err(), "{cmd} edited a smart object locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(layer).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.doc.layer(layer).unwrap().surface(), before.surface(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{cmd} {lock} group: {on_group}: no history step"); + } + } + } +} + +#[test] +fn unlocked_or_position_locked_smart_objects_take_distortions() { + // Filters don't check the position lock, so neither do the smart-filter distortions. + for lock in [None, Some(json!({"position": true})), Some(json!({"transparency": true}))] { + for (cmd, p) in smart_distortions() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + if let Some(lock) = &lock { + s.execute("layer.lockLayers", lock.clone()).unwrap(); + } + let steps = s.active().unwrap().history.past_len(); + s.execute(cmd, p).unwrap(); + assert_eq!(smart(&s).smart_filters.len(), 1, "{cmd} {lock:?}"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{cmd} {lock:?}"); + } + } +} diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index d4de2d548e..6bf834ae14 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -28,6 +28,9 @@ pub struct Clip { /// than rasterize (see [`whole_layer`]): a scratch document in the source's colour holding it. /// `surface` still holds its pixels, for Paste Into, masks, channels and other apps. pub layers: Option>, + /// The source document's resolution (ppi): File › New from the clipboard takes it, as + /// Photoshop's Clipboard preset does. None for an image from another app. + pub resolution: Option, } /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer @@ -52,6 +55,46 @@ fn scratch_doc(doc: &Document) -> Document { copies } +/// Scope linked records to the copied layer tree: a PSD block can also hold files belonging +/// to unselected or hidden layers, which must not follow this copy into a native save. +fn copy_linked_sources(source: &Document, copies: &mut Document) -> Result<()> { + fn collect<'a>(layers: &'a [Layer], depth: usize, paths: &mut std::collections::HashSet<&'a str>) -> Result<()> { + if depth > photocraft_doc::MAX_GROUP_DEPTH { + return Err(EngineError::Other("can't copy linked smart objects from layers nested too deeply".into())); + } + for layer in layers { + if let LayerContent::Smart(sm) = &layer.content + && let photocraft_doc::SmartSource::Linked { path } = &sm.source + { + paths.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source IDs".into()))?; + paths.insert(path); + } + if let Some(children) = layer.children().filter(|children| !children.is_empty()) { + collect(children, depth + 1, paths)?; + } + } + Ok(()) + } + let mut paths = std::collections::HashSet::new(); + collect(&copies.layers, 0, &mut paths)?; + if paths.is_empty() { + return Ok(()); + } + for (signature, key, data) in &source.metadata.psd_global_blocks { + if !matches!(key, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE") { + continue; + } + let selected = photocraft_io::linked::select_items(data, &|uuid, _| paths.contains(uuid)) + .map_err(|e| EngineError::Other(format!("Could not copy smart-object contents: {e}; the clipboard was not changed")))?; + let selected = selected.map(std::sync::Arc::new).unwrap_or_else(|| data.clone()); + if !selected.is_empty() { + copies.metadata.psd_global_blocks.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source records".into()))?; + copies.metadata.psd_global_blocks.push((*signature, *key, selected)); + } + } + Ok(()) +} + /// 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> { @@ -139,6 +182,11 @@ fn active_id(s: &Session) -> Result { } /// The pixels of `src` inside the selection (or all of them), as a straight-alpha surface. +/// +/// Built tile by tile (#4516). A source tile that the copy takes whole (inside the canvas, fully +/// selected or no selection, already in the clip's format) is shared copy-on-write, so a plain +/// Copy or Select All, Copy costs a pointer per tile; only the other tiles are read and written, +/// one at a time, instead of the whole area as one `f32` buffer. fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { let fmt = src.format(); let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true); @@ -148,15 +196,39 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None }); + return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None }); } // Only the selected area is read: converting the whole layer (a 24 MP Background gaining // alpha) cost about a second per Layer via Copy (#668). - let n = with_alpha.channels(); + let mut shared = Vec::new(); + for tc in area.tiles() { + let tr = tc.rect().intersect(&area); + if fmt.alpha + && tr == tc.rect() + && let Some(tile) = src.tile(tc) + && sel.is_none_or(|m| crate::fill_cmds::Mask::of(m, tc).full(tr)) + { + // Reading and writing it back would give the same bytes. + shared.push((tc, tile.clone())); + continue; + } + let px = lift_region(src, sel, tr, with_alpha.channels())?; + out.try_write_region(tr, &px).map_err(|e| EngineError::Other(format!("not enough memory while copying pixels ({e}); the document was not changed")))?; + } + out.put_tiles(shared); + out.prune(); + let bounds = out.content_bounds(); + Ok(Clip { surface: out, bounds, layers: None, resolution: None }) +} + +/// [`lift`] over one rectangle: `src`'s pixels with alpha (`n` channels), the selection +/// multiplied into it. +fn lift_region(src: &Surface, sel: Option<&Surface>, r: Rect, n: usize) -> Result> { + let fmt = src.format(); let mut px = if fmt.alpha { - crate::allocation::read_region(src, area, "copying pixels")? + crate::allocation::read_region(src, r, "copying pixels")? } else { - let opaque = crate::allocation::read_region(src, area, "copying pixels")?; + let opaque = crate::allocation::read_region(src, r, "copying pixels")?; let k = fmt.channels(); let len = opaque .len() @@ -177,16 +249,15 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { v }; if let Some(m) = sel { - let mask = crate::allocation::read_region(m, area, "applying the selection to copied pixels")?; + let mask = crate::allocation::read_region(m, r, "applying the selection to copied pixels")?; let mk = m.format().channels().max(1); - for (p, a) in px.chunks_exact_mut(n).zip(mask.chunks_exact(mk)) { - p[n - 1] *= a[0]; + for (p, a) in px.chunks_exact_mut(n.max(1)).zip(mask.chunks_exact(mk)) { + if let Some(alpha) = p.last_mut() { + *alpha *= a[0]; + } } } - out.try_write_region(area, &px).map_err(|e| EngineError::Other(format!("not enough memory while copying pixels ({e}); the document was not changed")))?; - out.prune(); - let bounds = out.content_bounds(); - Ok(Clip { surface: out, bounds, layers: None }) + Ok(px) } /// Merged composite of the visible document as a surface in the document's format. @@ -203,7 +274,10 @@ fn copy(s: &mut Session, merged: bool) -> Result { } else { let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?; let several = multi_copy_ids(d); - let layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) }; + let mut layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) }; + if let Some(copies) = &mut layers { + copy_linked_sources(&d.doc, copies)?; + } 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. @@ -221,6 +295,7 @@ fn copy(s: &mut Session, merged: bool) -> Result { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } let b = clip.bounds; + let clip = Clip { resolution: Some(d.doc.resolution_dpi), ..clip }; s.clipboard = Some(clip); Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]})) } @@ -263,7 +338,8 @@ fn copy_mask(s: &mut Session) -> Result { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } let b = bounds; - s.clipboard = Some(Clip { surface, bounds: b, layers: None }); + let resolution = Some(d.doc.resolution_dpi); + s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution }); Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": "mask"})) } @@ -340,8 +416,27 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result< crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?; crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy); let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect(); + // A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584). + let mut linked = Vec::new(); + for (signature, key, data) in copies.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")) { + if d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && std::sync::Arc::ptr_eq(dd, data)) { + continue; + } + // The clipboard may contain a subset of a destination block. Compare complete records, + // so copying one layer in the same document does not duplicate large embedded files. + let missing = photocraft_io::linked::select_items(data, &|_, raw| { + !d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && photocraft_io::linked::contains_item(dd, raw)) + }) + .map_err(|e| EngineError::Other(format!("Could not paste smart-object contents: {e}; the document was not changed")))?; + let missing = missing.map(std::sync::Arc::new).unwrap_or_else(|| data.clone()); + if !missing.is_empty() { + linked.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to paste smart-object source records".into()))?; + linked.push((*signature, *key, missing)); + } + } let id = s.edit("Paste", |doc, active| { doc.patterns.extend(patterns); + doc.metadata.psd_global_blocks.extend(linked); for l in copies.layers { *active = Some(doc.insert_above(*active, l)); } @@ -404,10 +499,17 @@ fn new_from_clipboard(s: &mut Session) -> Result { let target = PixelFormat::new(fmt.mode, fmt.sample, true); let moved = if b.x0 == 0 && b.y0 == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, -b.x0, -b.y0) }; let mut doc = Document::new("Untitled", photocraft_geom::Size::new(w, h), fmt.mode, fmt.sample); + if let Some(r) = clip.resolution.filter(|r| r.is_finite() && *r > 0.0) { + doc.resolution_dpi = r; + } let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = if moved.format() == target { moved } else { moved.convert(target) }; doc.layers.push(l); let i = s.add_document(doc, None); + // Clipboard content has never been saved; closing this tab must ask before discarding it. + if let Some(st) = s.active_mut() { + st.saved_revision = 0; + } Ok(json!({"document": i, "width": w, "height": h})) } @@ -1007,6 +1109,18 @@ mod tests { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() } + /// New from Clipboard takes the copied document's resolution (Photoshop's Clipboard preset), + /// so a later Image Size by resolution scales from the right size. + #[test] + fn new_from_clipboard_keeps_the_source_resolution() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 40, "resolution": 300})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.newFromClipboard", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.resolution_dpi, 300.0); + } + /// #368: the clipboard as a document of its own, from New from Clipboard or a Paste with /// nothing open, at every depth. #[test] @@ -1016,6 +1130,7 @@ mod tests { assert!(!s.is_enabled("file.newFromClipboard") && !s.is_enabled("edit.paste"), "nothing copied yet"); assert!(s.execute("file.newFromClipboard", json!({})).is_err()); s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth})).unwrap(); + assert!(!s.active().unwrap().is_dirty(), "an untouched blank document is clean"); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 15})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); @@ -1023,6 +1138,7 @@ mod tests { let r = s.execute("file.newFromClipboard", json!({})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(30), Some(15)), "{r}"); assert_eq!(s.documents().len(), 2); + assert!(s.active().unwrap().is_dirty(), "a document made from clipboard pixels needs saving (#4665)"); let d = &s.active().unwrap().doc; assert_eq!((d.size.width, d.size.height, d.layers.len()), (30, 15, 1)); assert_eq!(d.pixel_format().sample, s.documents()[0].doc.pixel_format().sample, "{depth}-bit kept"); @@ -1036,6 +1152,7 @@ mod tests { assert!(s.is_enabled("edit.paste") && !s.is_enabled("edit.pasteSpecial.pasteInPlace")); s.execute("edit.paste", json!({})).unwrap(); assert_eq!(s.documents().len(), 1); + assert!(s.active().unwrap().is_dirty(), "Paste with no tab also needs a save prompt"); assert_eq!(active_bounds(&s), Rect::new(0, 0, 30, 15)); assert!(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).is_ok(), "into the new document"); } @@ -1629,6 +1746,34 @@ mod tests { assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30), "on the canvas: in place"); } + /// #3584: a PSD-embedded linked smart object (its bytes in the document's `lnk2` block, its + /// source a uuid) can still be opened after Copy + Paste into another document. + #[test] + fn paste_keeps_a_psd_embedded_smart_object_openable() { + use std::sync::Arc; + let mut s = session(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let outer = s.active().unwrap().active_layer.unwrap(); + s.edit("embedded linked source", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(outer).ok_or(EngineError::NoDocument)?.content else { + return Err(EngineError::Other("not a smart object".into())); + }; + sm.source = photocraft_doc::SmartSource::Linked { path: "uuid-1".into() }; + let linked = photocraft_io::linked::LinkedFile { uuid: "uuid-1".into(), file_name: "in.png".into(), bytes: vec![1, 2, 3, 4] }; + doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(photocraft_io::linked::encode_linked_file(&linked)))); + Ok(()) + }) + .unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let LayerContent::Smart(sm) = active_content(&s) else { panic!("the paste is a smart object") }; + assert!( + crate::smart_cmds::source_bytes(&s.active().unwrap().doc.metadata, &sm.source).is_some(), + "the pasted PSD-embedded linked source must resolve (#3584)" + ); + } + /// Type and shape layers copy their rendered pixels too; groups and adjustments have none. #[test] fn copy_reads_any_layer_that_shows_pixels() { @@ -1907,4 +2052,89 @@ mod tests { let back = photocraft_io::import("g.psd", &psd).unwrap().document.guides; assert_eq!(wide(back.vertical[0]), far, "1032 stores 1/32 px"); } + + /// `lift` as it was before #4516: the whole area through one `f32` buffer. The oracle for + /// the tiled version. + fn lift_reference(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip { + let fmt = src.format(); + let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true); + let area = match sel { + Some(m) => m.content_bounds().intersect(&canvas), + None => src.content_bounds().intersect(&canvas), + }; + let mut out = Surface::new(with_alpha); + if area.is_empty() { + return Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None }; + } + let px = lift_region(src, sel, area, with_alpha.channels()).unwrap(); + out.try_write_region(area, &px).unwrap(); + out.prune(); + let bounds = out.content_bounds(); + Clip { surface: out, bounds, layers: None, resolution: None } + } + + fn noise(fmt: PixelFormat, r: Rect, seed: u32) -> Surface { + let mut s = Surface::new(fmt); + let n = r.width() as usize * r.height() as usize * fmt.channels(); + let data: Vec = (0..n as u32) + .map(|i| { + let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + (h >> 16) as f32 / 65535.0 + }) + .collect(); + s.write_region(r, &data); + s + } + + fn assert_same_clip(a: &Clip, b: &Clip, what: &str) { + assert_eq!(a.bounds, b.bounds, "{what}: bounds"); + assert_eq!(a.surface.format(), b.surface.format(), "{what}: format"); + let r = a.bounds.union(&b.bounds); + if !r.is_empty() { + let (pa, pb) = (a.surface.read_region(r), b.surface.read_region(r)); + assert!(pa.iter().zip(&pb).all(|(x, y)| x.to_bits() == y.to_bits()), "{what}: pixels differ"); + } + } + + /// The tiled `lift` gives exactly the old result (#4516): with and without alpha, at every + /// depth, with no selection, Select All, a partial feathered selection and a selection off + /// the tile grid, and for content reaching past the canvas. + #[test] + fn tiled_lift_matches_the_whole_area_lift() { + use photocraft_color::{ColorMode, SampleType}; + let canvas = Rect::new(0, 0, 700, 600); + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [true, false] { + let fmt = PixelFormat::new(ColorMode::Rgb, sample, alpha); + let src = noise(fmt, Rect::new(-40, -30, 760, 640), 7); + let mut all = Surface::new(PixelFormat::GRAY8); + all.fill_rect(canvas, &[1.0]); + let mut partial = Surface::new(PixelFormat::GRAY8); + partial.fill_rect(Rect::new(100, 50, 600, 550), &[1.0]); + partial.write_region(Rect::new(90, 40, 610, 50), &vec![0.5; 520 * 10]); + let off_grid = noise(PixelFormat::GRAY8, Rect::new(13, 270, 333, 290), 9); + for (name, sel) in [("no selection", None), ("select all", Some(&all)), ("partial", Some(&partial)), ("off grid", Some(&off_grid))] { + let what = format!("{sample:?} alpha {alpha} {name}"); + let new = lift(&src, sel, canvas).unwrap(); + assert_same_clip(&new, &lift_reference(&src, sel, canvas), &what); + } + } + } + } + + /// Copy shares the layer's whole tiles instead of copying them. + #[test] + fn copy_shares_whole_tiles_with_the_layer() { + let src = noise(PixelFormat::RGBA8, Rect::new(0, 0, 1024, 768), 3); + let canvas = Rect::new(0, 0, 1024, 768); + let mut all = Surface::new(PixelFormat::GRAY8); + all.fill_rect(canvas, &[1.0]); + for sel in [None, Some(&all)] { + let clip = lift(&src, sel, canvas).unwrap(); + let shared = src.tiles().filter(|(c, t)| clip.surface.tile(**c).is_some_and(|o| std::sync::Arc::ptr_eq(o, t))).count(); + assert_eq!(shared, src.tile_count(), "every tile is shared"); + } + } } diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 103a5edc6e..f798b6e4af 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -391,6 +391,13 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & } let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; let fmt = surf.format(); + // Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active + // layer has no pixels in the window there is nothing to sample, so that case uses the + // visible composite (#4026). An explicit false still reads only this layer. + let sample_all = match p.get("sampleAllLayers").and_then(Value::as_bool) { + Some(v) => v, + None => surf.content_bounds().intersect(&window).is_empty(), + }; let n = fmt.channels(); // Refuse a window too large to read before allocating for it, whatever the sampling (#963). let (w, h) = crate::fill_cmds::window_size(window)?; @@ -407,7 +414,18 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & ctx.progress(0.0, label); let cancel = || ctx.cancelled(); let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; - let img = read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)?; + let img = if sample_all { + if cancel() { + return Err(EngineError::Cancelled); + } + let region = crate::retouch_cmds::composite_region(&doc, None, crate::retouch_cmds::SampleLayers::All, window, fmt); + if cancel() { + return Err(EngineError::Cancelled); + } + region.data + } else { + read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)? + }; let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?; let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) { ("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(), @@ -894,6 +912,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { && let Some((id, a, b)) = cursor && text_of(&doc, id).is_some_and(|t| find_matches(&t, &find, case, whole).contains(&(a, b))) { + crate::type_cmds::check_text_unlocked(&doc, id)?; s.edit("Replace Text", |doc, _| { let snapshot = doc.clone(); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { @@ -903,7 +922,10 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { Ok(()) })?; let end = a + replace.len(); - s.edit_state.find_cursor = Some((id, end, end)); + // Collapse the cursor on the side the search moves away from, so the next search + // skips the replaced text even when the replacement still matches. + let c = if forward { end } else { a }; + s.edit_state.find_cursor = Some((id, c, c)); changed = Some((id, a, end)); if action == "change" { return Ok(json!({"changed": {"layer": id.0, "start": a, "end": end}})); @@ -1135,7 +1157,7 @@ pub fn specs() -> Vec { "Content-Aware Fill…", ["Edit"], None, - r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, + r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"sampleAllLayers":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, can_caf, content_aware_fill ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 7b58891a82..624a236200 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -218,6 +218,38 @@ fn delete_and_fill_selection_removes_the_object_in_one_step() { assert!(s.execute("edit.deleteAndFillSelection", json!({})).is_err()); } +#[test] +fn content_aware_fill_on_an_empty_layer_samples_the_composite() { + let mut s = blob_session(8); + s.execute("layer.new.layer", json!({})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + assert_eq!(px(&s, 30, 20)[3], 0.0, "the new layer has no paint"); + let r = s.execute("edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + assert!(r["filled"].as_u64().unwrap() > 0); + assert_eq!(s.active().unwrap().active_layer, Some(empty)); + assert!(px(&s, 30, 20)[3] > 0.5, "the hole is filled from the layers underneath"); + s.undo(); + let missed = s.execute("edit.contentAwareFill", json!({"sampleAllLayers": false, "colorAdaptation": "none"})).unwrap(); + assert!(missed["filled"].as_u64().unwrap() > 0); + assert!(px(&s, 30, 20)[3] < 0.05, "sampling only the empty layer leaves it empty"); +} + +#[test] +fn content_aware_fill_on_an_empty_cmyk_layer_uses_the_layer_format() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 36, "mode": "cmyk", "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 16, "y": 12, "width": 12, "height": 10})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let before = px(&s, 20, 16); + assert!(before.iter().all(|c| *c < 0.05), "the new CMYK layer is empty: {before:?}"); + s.execute("edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + let v = px(&s, 20, 16); + assert_eq!(v.len(), before.len()); + // White in the layer's CMYK is near zero ink with a covered alpha, not the compositor's RGBA. + assert!(v[..4].iter().all(|c| *c < 0.15), "CMYK ink stayed low: {v:?}"); + assert!(v.last().copied().unwrap_or(0.0) > 0.5, "the hole is covered: {v:?}"); +} + #[test] fn content_aware_fill_samples_photoshops_window() { // A 50 px selection samples the 200 px square centred on it (Photoshop 25.4's overlay); @@ -446,6 +478,27 @@ fn find_and_replace_across_type_layers() { assert!(s.execute("edit.findAndReplaceText", json!({"find": ""})).is_err()); } +#[test] +fn find_and_replace_change_rejects_a_layer_locked_after_the_find() { + // #4188: the found match's layer gets Lock All before Change; the text must stay. + let mut s = session(8); + let id = s.execute("type.create", json!({"text": "abc", "x": 2, "y": 12})).unwrap()["layer"].as_u64().unwrap(); + // A second, unlocked type layer keeps the command enabled. + s.execute("type.create", json!({"text": "zzz", "x": 2, "y": 40})).unwrap(); + let found = s.execute("edit.findAndReplaceText", json!({"find": "abc", "action": "find"})).unwrap(); + assert_eq!(found["found"]["layer"].as_u64(), Some(id)); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + let result = s.execute("edit.findAndReplaceText", json!({"find": "abc", "replace": "x", "action": "change"})); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{result:?}"); + let text = match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Text(t) => t.text.clone(), + _ => String::new(), + }; + assert_eq!(text, "abc"); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + #[test] fn find_in_the_active_layer_finds_nothing_when_it_is_not_type() { // #703: `allLayers: false` with a raster layer active leaves nothing to search. @@ -468,3 +521,26 @@ fn find_in_the_active_layer_finds_nothing_when_it_is_not_type() { let r = s.execute("edit.findAndReplaceText", json!({"find": "abc", "allLayers": false, "action": "find"})).unwrap(); assert_eq!((r["found"]["layer"].as_u64(), r["found"]["text"].as_str()), (Some(id), Some("abc"))); } + +#[test] +fn backward_change_find_moves_past_the_replaced_text() { + // A backward Change/Find whose replacement still matches ("cat" -> "Cat", case-insensitive) + // must move on to the previous match, not find the replaced text again. + let mut s = session(8); + s.execute("type.create", json!({"text": "cat dog cat", "x": 2, "y": 12})).unwrap(); + let p = |action: &str, forward: bool| json!({"find": "cat", "replace": "Cat", "action": action, "forward": forward, "allLayers": false}); + let f = s.execute("edit.findAndReplaceText", p("find", false)).unwrap(); + assert_eq!((f["found"]["start"].as_u64(), f["found"]["end"].as_u64()), (Some(8), Some(11)), "{f}"); + let r = s.execute("edit.findAndReplaceText", p("changeFind", false)).unwrap(); + assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(8), Some(11)), "{r}"); + assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(0), Some(3)), "{r}"); + + // Forward is unchanged: replace the first match, then find the next one. + let mut s = session(8); + s.execute("type.create", json!({"text": "cat dog cat", "x": 2, "y": 12})).unwrap(); + let f = s.execute("edit.findAndReplaceText", p("find", true)).unwrap(); + assert_eq!(f["found"]["start"].as_u64(), Some(0), "{f}"); + let r = s.execute("edit.findAndReplaceText", p("changeFind", true)).unwrap(); + assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(0), Some(3)), "{r}"); + assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(8), Some(11)), "{r}"); +} diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 3fb546de4f..920c96a014 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -27,7 +27,9 @@ fn flag(p: &Value, k: &str, d: bool) -> bool { } /// A finite number param clamped to `range`, `default` when absent; non-numbers are an error. -fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive) -> Result { +/// A numeric param clamped to `range`; missing or null gives `default`, anything else not a +/// finite number is BadParams. Shared with the Paint Bucket and Gradient opacity. +pub(crate) fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive) -> Result { match p.get(k) { None | Some(Value::Null) => Ok(default), Some(v) => { diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs index c0a3310f9f..908942d4fd 100644 --- a/crates/engine/src/exr_cmds.rs +++ b/crates/engine/src/exr_cmds.rs @@ -210,6 +210,46 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A single-part EXR with no part name (the usual render output) plus an AOV group. + fn write_unnamed_grouped(path: &std::path::Path) { + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let list = vec![mk("R", 0.7), mk("G", 0.7), mk("B", 0.7), mk("diffuse.R", 0.3), mk("diffuse.G", 0.3), mk("diffuse.B", 0.3)]; + let layer = Layer::new( + (w, h), + LayerAttributes::default(), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture"); + } + + #[test] + fn an_unnamed_part_opens_by_the_name_the_listing_gives_it() { + // #4210: the listing names an unnamed part "part 0"; feeding that name back must open it. + let dir = std::env::temp_dir().join(format!("pcraft-exr-unnamed-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("unnamed.exr"); + write_unnamed_grouped(&path); + let info = entries_json(path.to_str().unwrap()).unwrap(); + let part = info.as_array().unwrap().iter().find(|e| e["kind"] == "part").expect("a part entry"); + let name = part["name"].as_str().unwrap(); + assert_eq!(name, "part 0", "{info}"); + let key = format!("part/{name}"); + let mut s = session(); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), key: true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].name, "part 0"); + if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content { + let px = surface.pixel(0, 0); + assert!((px[0] - 0.7).abs() < 1e-4, "{:?}", px); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn precomp_sums_the_light_path_set_and_refuses_an_incomplete_one() { let dir = std::env::temp_dir().join(format!("pcraft-exr-precomp-{}", std::process::id())); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 1399784b1d..1f3748d7b9 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -136,6 +136,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; let color = color_param(p, "color", s.tools.foreground); + // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). + let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { None | Some(Value::Null) => photocraft_color::BlendMode::Normal, Some(Value::String(m)) => crate::commands::blend_from_str(m) @@ -214,7 +216,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result { let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; let area = band.content_bounds().intersect(&canvas); if !area.is_empty() { - crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock)?; + crate::fill_cmds::blend_color_mask(surf, area, color, picked, &band, mode, opacity, preserve || lock)?; surf.prune(); } Ok(()) @@ -435,11 +437,20 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect { // ---------- layers ---------- -fn layer_from_background(s: &mut Session) -> Result { +fn layer_from_background(s: &mut Session, p: &Value) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + if d.doc.background().is_none() && p.get("ifPresent").and_then(Value::as_bool) == Some(true) { + return Ok(json!({"layer": null})); + } + // `select: false`: the targeted layer stays targeted, as when a Photoshop action's `set` + // names the Background while another layer is selected. + let select = p.get("select").and_then(Value::as_bool).unwrap_or(true); let id = s.edit("Layer From Background", |doc, active| { let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?; unlock_background(l); - *active = Some(l.id); + if select || active.is_none() { + *active = Some(l.id); + } Ok(l.id) })?; Ok(json!({"layer": id.0})) @@ -856,7 +867,15 @@ pub fn specs() -> Vec { s.execute("image.crop", json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}))?; Ok(json!({"changed": true})) }), - spec!("layer.new.layerFromBackground", "Layer From Background…", &["Layer", "New"], None, "{}", has_background, |s, _| layer_from_background(s)), + spec!( + "layer.new.layerFromBackground", + "Layer From Background…", + &["Layer", "New"], + None, + r##"{"ifPresent":bool=false (true: a document without a Background is left as it is, as a Photoshop action's step needs on an image that opened as a normal layer),"select":bool=true (false: the targeted layer stays targeted)}"##, + has_background, + layer_from_background + ), spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?; diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 74069c8b8c..7470e78231 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -98,7 +98,7 @@ pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { /// Files of a folder that look like images we can open, sorted by name. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn list_images(dir: &str) -> Result> { +pub fn list_images(dir: &str) -> Result> { let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; let mut out: Vec = rd .filter_map(|e| e.ok()) @@ -110,15 +110,16 @@ pub(crate) fn list_images(dir: &str) -> Result> { Ok(out) } #[cfg(target_arch = "wasm32")] -pub(crate) fn list_images(dir: &str) -> Result> { +pub fn list_images(dir: &str) -> Result> { Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } /// Extensions the batch commands pick up from a folder. #[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ - "pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", - "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", + "pdn", "ora", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", + "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", + "afpub", ]; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone @@ -150,8 +151,9 @@ pub fn extension(path: &str) -> Option { file_name(path).rsplit_once('.').filter(|(base, ext)| !base.is_empty() && !ext.is_empty()).map(|(_, ext)| ext.to_ascii_lowercase()) } -/// Whether a save without a new path may write back to `path`: only layered files (PSD, PSB, -/// .pcraft). A flat file goes through Save As instead, so it is never flattened over the original. +/// Whether a save without a new path may write back to `path` as far as its format goes: only +/// layered files (PSD, PSB, OpenRaster, .pcraft). A flat file goes through Save As instead, so it +/// is never flattened over the original. Whether this document fits is [`holds_at`]. pub fn saves_in_place(path: &str) -> bool { extension(path).is_some_and(|ext| layered_extension(&ext)) } @@ -163,6 +165,33 @@ pub fn layered_extension(ext: &str) -> bool { matches!(ext, "psd" | "psb" | "pcraft" | "ora") } +/// Whether a file in format `ext` (lower-case) holds the whole of `doc`, so it can be the +/// document's own file: PSD, PSB and .pcraft always; OpenRaster only when it drops nothing (no +/// adjustment layers, styles, masks…, [`photocraft_io::ora_losses`]). A save to a format that +/// doesn't hold the document is a copy, and File › Save goes through Save As, so an editable +/// original is never overwritten with less than it had (#3806). +pub fn holds(doc: &photocraft_doc::Document, ext: &str) -> bool { + match ext { + "psd" | "psb" | "pcraft" => true, + "ora" => photocraft_io::ora_losses(doc).is_empty(), + _ => false, + } +} + +/// [`holds`] for the file at `path`. +pub fn holds_at(doc: &photocraft_doc::Document, path: &str) -> bool { + extension(path).is_some_and(|ext| holds(doc, &ext)) +} + +/// What a save of `doc` to `path` would leave out when its format can't hold it: OpenRaster's +/// [`photocraft_io::ora_losses`]; empty for other formats. +pub fn losses_at(doc: &photocraft_doc::Document, path: &str) -> Vec { + match extension(path).as_deref() { + Some("ora") => photocraft_io::ora_losses(doc), + _ => Vec::new(), + } +} + /// Whether `path` names a document template (.psdt). A template opens as a new untitled document /// without its path, so a save never writes over the template. pub fn is_template(path: &str) -> bool { @@ -229,6 +258,8 @@ pub(crate) struct SaveOpts { /// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or /// transparency, else 24. pub tga_bits: Option, + /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. + pub dpx_bits: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB @@ -238,7 +269,7 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } } } @@ -253,6 +284,7 @@ impl SaveOpts { quality: f64_param(p, "quality"), tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, + dpx_bits: dpx_bits_param(p, cmd)?, image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) @@ -284,6 +316,19 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + match p.get("dpxBits") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(photocraft_io::DpxBits::from_bits) + .map(Some) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`dpxBits` must be 8, 10, 12 or 16, got {v}") }), + } +} + /// Encodes `doc` for `path`'s extension. pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; @@ -298,6 +343,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); + opts.encode.dpx_bits = save.dpx_bits; if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; opts.encode.jpeg_quality = q; @@ -942,7 +988,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { // Each result goes to the destination folder, which overrides the action's recorded Save / // Save As steps (Photoshop's "Override Action 'Save As' Commands"). Recorded view steps // (zoom, fit) don't change the output, so they are skipped (#2752). - steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id)); + steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id) && id != "file.export.quickExport"); if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } @@ -1027,7 +1073,11 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result { stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened(d, fmt)))); } let n = stack.layers.len(); - if p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false) { + let smart = p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false); + // "Attempt to Automatically Align Source Images": Auto-Align Layers on the loaded layers, before + // they go into the smart object. + let align = p.get("align").and_then(Value::as_bool).unwrap_or(false) && n > 1; + if smart && !align { // "Create Smart Object after Loading Layers": the layers go inside one smart object, ready // for Layer › Smart Objects › Stack Mode. Like Photoshop, they sit at the top level of the // contents, with no wrapper group. @@ -1036,6 +1086,23 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result { stack.layers = vec![smart]; } let i = s.add_document(stack, None); + if align { + s.execute("select.allLayers", json!({}))?; + s.execute("edit.autoAlignLayers", json!({"projection": "auto"}))?; + if smart { + let name = s.active().map(|d| d.doc.name.clone()).unwrap_or_default(); + s.edit("Convert to Smart Object", |doc, active| { + let layers = std::mem::take(&mut doc.layers); + let smart = crate::smart_cmds::layers_to_smart(doc, &name, layers)?; + *active = Some(smart.id); + doc.layers = vec![smart]; + Ok(()) + })?; + if let Some(st) = s.active_mut() { + st.selected_layers = st.active_layer.into_iter().collect(); + } + } + } Ok(json!({"document": i, "layers": n})) } @@ -1405,7 +1472,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), @@ -1468,7 +1535,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1477,7 +1544,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1486,7 +1553,7 @@ pub fn specs() -> Vec { "Load Files into Stack…", &["File", "Scripts"], None, - r##"{"paths":[str]|folder,"createSmartObject":bool=false} → new document with one layer per file (inside one smart object with createSmartObject)"##, + r##"{"paths":[str]|folder,"align":bool=false (Auto-Align Layers on them first),"createSmartObject":bool=false} → new document with one layer per file (inside one smart object with createSmartObject)"##, native, load_files_into_stack ), @@ -1505,7 +1572,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 8cab1ce635..73c09020a5 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -155,6 +155,32 @@ fn save_a_copy_writes_the_targa_bits_asked_for() { } } +/// `dpxBits` picks a DPX's bits per sample (byte 803 of the header); unset, it follows the +/// document's depth. Anything but 8, 10, 12 or 16 is refused before anything is written. +#[test] +fn save_a_copy_writes_the_dpx_bits_asked_for() { + let dir = tmp("dpx-bits"); + for (depth, params, bits) in + [(8, json!({}), 8), (16, json!({}), 16), (16, json!({"dpxBits": 10}), 10), (8, json!({"dpxBits": 12}), 12), (16, json!({"dpxBits": null}), 16)] + { + let mut s = session(12, 6, depth); + let out = join(&dir, "a.dpx"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], bits), "{depth}-bit {params}"); + assert_eq!(photocraft_io::import("a.dpx", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); + } + let mut s = session(8, 8, 8); + for bad in [json!(24), json!(0), json!("10"), json!(10.5)] { + let out = join(&dir, "bad.dpx"); + let r = s.execute("file.saveACopy", json!({"path": out, "dpxBits": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + } +} + /// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written. #[test] fn bad_targa_bits_are_refused_before_writing() { @@ -730,6 +756,18 @@ fn load_files_into_stack_and_image_processor_and_batch() { let inv = photocraft_io::import("a.png", &std::fs::read(join(&out, "a.png")).unwrap()).unwrap().document; let px = photocraft_compose::render(&inv, Rect::new(1, 1, 2, 2)).px[0]; assert!(px[0] < 0.01 && px[1] > 0.99 && px[2] > 0.99, "red inverted to cyan: {px:?}"); + // A recorded Quick Export writes to the current document path, but Batch owns + // the destination. Replaying it would fail for scratch documents with no save path. + let quick_out = join(&dir, "batch-with-quick-export"); + let r = s + .execute( + "file.automate.batch", + json!({"steps": [["image.adjustments.invert", {}], ["file.export.quickExport", {}]], "input": input, "output": quick_out, "format": "png"}), + ) + .unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + assert!(std::path::Path::new(&join(&quick_out, "a.png")).is_file()); assert!(s.execute("file.automate.batch", json!({"steps": [["no.such.command", {}]], "input": input, "output": out})).is_err()); } @@ -875,6 +913,26 @@ fn only_layered_files_save_in_place() { assert_eq!(extension("my.dir/name"), None); } +/// #3806: a layered format holds a document only if it keeps everything in it. +#[test] +fn openraster_holds_a_document_only_when_it_drops_nothing() { + let mut s = session(16, 16, 8); + s.execute("layer.new.layer", json!({})).unwrap(); + let plain = doc(&s).clone(); + for p in ["a.psd", "a.psb", "a.pcraft", "a.ora", "A.ORA"] { + assert!(holds_at(&plain, p), "{p}"); + } + for p in ["a.png", "a.tif", "a", ""] { + assert!(!holds_at(&plain, p), "{p}"); + } + assert!(losses_at(&plain, "a.ora").is_empty()); + s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + let adjusted = doc(&s).clone(); + assert!(holds_at(&adjusted, "a.psd") && !holds_at(&adjusted, "a.ora")); + assert!(losses_at(&adjusted, "a.ora").iter().any(|l| l.contains("adjustment layers")), "{:?}", losses_at(&adjusted, "a.ora")); + assert!(losses_at(&adjusted, "a.psd").is_empty()); +} + #[test] fn templates_open_untitled_without_their_path() { assert!(is_template("dir/card.PSDT") && !is_template("card.psd") && !is_template("psdt")); diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index d99ebd9a55..df925caf76 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -215,16 +215,22 @@ pub(crate) fn fill_color(surf: &mut Surface, area: Rect, color: [f32; 4], sel: O /// Reuse Edit › Fill's compositing for Edit › Stroke. The stroke's rasterized band /// is already a grayscale coverage mask; opacity, blend modes and alpha locking /// must behave identically for both commands at 8/16/32-bit depth. +#[allow(clippy::too_many_arguments)] pub(crate) fn blend_color_mask( surf: &mut Surface, area: Rect, color: [f32; 4], + picked: Option<[f32; 4]>, coverage: &Surface, mode: BlendMode, opacity: f32, preserve_transparency: bool, ) -> Result<()> { - blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) + let source = match picked { + Some(cmyk) if color[3] >= 1.0 => Source::Cmyk { rgba: color, cmyk }, + _ => Source::Color(color), + }; + blend_into(surf, area, &source, Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) } /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight @@ -393,6 +399,12 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, s.clone())); return Ok(()); } + // Nothing selected in this tile (or no opacity): `blend_tile` would leave every pixel as + // it is, so keep the tile itself rather than a copy the history would hold again (#4517). + if b.opacity <= 0.0 || mask.as_ref().is_some_and(|m| m.empty(tr)) { + done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, tile)); + return Ok(()); + } let mut tile = match std::sync::Arc::try_unwrap(tile) { Ok(tile) => tile, Err(tile) => tile @@ -430,6 +442,10 @@ impl<'a> Mask<'a> { 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)) } + /// Whether nothing in `r` is selected. + pub(crate) fn empty(&self, r: Rect) -> bool { + (r.y0..r.y1).all(|y| (r.x0..r.x1).all(|x| self.at(x, y) <= 0.0)) + } } /// Blend into one tile's encoded pixels (`bytes`, the tile at `origin`) over `tr`. @@ -740,4 +756,42 @@ mod tests { s.execute(CMD, json!({"contents": "contentAware", "colorAdaptation": false})).unwrap(); assert!(close(px(&s, 17, 12), [0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]), "{:?}", px(&s, 17, 12)); } + + /// A Stroke, Fill or Clear inside a selection keeps the tiles the selection doesn't reach as + /// they were (shared with the history), instead of storing a copy of each (#4517). + #[test] + fn unselected_tiles_stay_shared() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let surf = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone(); + for (cmd, params) in [ + ("edit.fill", json!({"color": "#ff0000"})), + ("edit.stroke", json!({"width": 4, "color": "#00ff00", "location": "inside"})), + ("edit.clear", json!({})), + ] { + // Two small selections in opposite corners (256 px tiles): the selection's bounds + // span every tile, but only the two corner tiles have anything selected. + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 100})).unwrap(); + s.execute("select.rect", json!({"x": 900, "y": 900, "width": 100, "height": 100, "mode": "add"})).unwrap(); + let before = surf(&s); + s.execute(cmd, params).unwrap(); + let after = surf(&s); + let corners = [photocraft_geom::Rect::new(0, 0, 256, 256), photocraft_geom::Rect::new(768, 768, 1024, 1024)]; + let mut kept = 0; + for (c, t) in before.tiles() { + if corners.iter().all(|r| c.rect().intersect(r).is_empty()) { + assert!(after.tile(*c).is_some_and(|a| std::sync::Arc::ptr_eq(a, t)), "{cmd}: unselected tile {c:?} was copied"); + kept += 1; + } + } + assert_eq!(kept, 14, "{cmd}: the 14 tiles between the corners"); + for r in corners { + assert_ne!(before.read_region(r), after.read_region(r), "{cmd} changed the selected corner {r:?}"); + } + s.execute("select.deselect", json!({})).unwrap(); + } + } } diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index 8beff0dc0e..6b94e6cd47 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -6,7 +6,8 @@ //! //! Each one is `edit.fill` with the colour and Preserve Transparency fixed: one "Fill" history //! step, the Channels panel target (alpha channel, Quick Mask) honoured, locked layers refused. -//! When targeting a vector shape layer, the fill keys recolor the shape's fill. +//! When targeting a vector shape layer, the fill keys recolor the shape's fill; on a type layer +//! they recolor the text (#4580), and the layer stays type. use photocraft_doc::{LayerContent, LayerId}; use serde_json::{Map, Value, json}; @@ -19,7 +20,8 @@ pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit. const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##; -fn target_shape_layer(s: &Session, p: &Value) -> Option { +/// The shape or type layer the colour fill keys recolor instead of filling pixels. +fn target_live_layer(s: &Session, p: &Value) -> Option { if let Some(t) = p.get("target") && (t.as_str() == Some("mask") || t.as_str() == Some("quickMask") || t.get("channel").is_some()) { @@ -31,12 +33,12 @@ fn target_shape_layer(s: &Session, p: &Value) -> Option { } let id = if let Some(v) = p.get("layer") { LayerId(v.as_u64()?) } else { st.active_layer? }; let layer = st.doc.layer(id)?; - if matches!(layer.content, LayerContent::Shape(_)) { Some(id) } else { None } + if matches!(layer.content, LayerContent::Shape(_) | LayerContent::Text(_)) { Some(id) } else { None } } -/// The colour fill keys also recolor a targeted shape layer's fill. +/// The colour fill keys also recolor a targeted shape layer's fill or type layer's text. fn enabled(s: &Session) -> std::result::Result<(), String> { - if target_shape_layer(s, &Value::Null).is_some() { + if target_live_layer(s, &Value::Null).is_some() { return Ok(()); } fill_enabled(s) @@ -49,7 +51,7 @@ fn fill_enabled(s: &Session) -> std::result::Result<(), String> { } /// Run `edit.fill` with `color` (the foreground or background colour) and Preserve Transparency, -/// or recolor the vector shape fill when targeting a vector shape layer. +/// or recolor the vector shape fill or the text when targeting a shape or type layer. fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool) -> Result { let mut q: Map = match p { Value::Object(m) => m.clone(), @@ -57,8 +59,14 @@ fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool) _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected an object".into() }), }; - if let Some(id) = target_shape_layer(s, p) { + if let Some(id) = target_live_layer(s, p) { let c = if background { s.tools.background } else { s.tools.foreground }; + if s.active().and_then(|st| st.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) { + // type.setStyle doesn't check locks itself; refuse like the pixel fill does. + crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; + let set_style = crate::commands::find("type.setStyle").ok_or_else(|| EngineError::Other("type.setStyle is unavailable".into()))?; + return (set_style.run)(s, &json!({ "layer": id.0, "color": [c[0], c[1], c[2], 1.0] })); + } let q_u8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; let hex = if c[3] < 1.0 { format!("#{:02x}{:02x}{:02x}{:02x}", q_u8(c[0]), q_u8(c[1]), q_u8(c[2]), q_u8(c[3])) @@ -365,4 +373,42 @@ mod tests { assert!(err.contains("locked"), "{id}: {err}"); } } + + /// The colour of every run of the active type layer, as RGB. + fn type_colors(s: &Session) -> Vec<[f32; 3]> { + let st = s.active().unwrap(); + let LayerContent::Text(t) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("not a type layer any more") }; + t.char_runs().iter().map(|r| r.style.color.to_rgb()).collect() + } + + /// #4580: on a type layer the fill keys recolour the text, like Photoshop, instead of being + /// disabled; the layer stays editable type. + #[test] + fn fill_keys_recolor_type_layer() { + let mut s = session(); + s.execute("type.create", json!({"x": 1, "y": 5, "text": "Hello", "size": 4, "color": "#000000"})).unwrap(); + s.execute("type.setStyle", json!({"range": [0, 2], "color": "#00ff00"})).unwrap(); + assert_eq!(type_colors(&s).len(), 2, "two runs before the fill"); + for id in &IDS[..4] { + let e = crate::commands::find(id).unwrap().enabled; + assert!(e(&s).is_ok(), "{id} on a type layer: {:?}", e(&s)); + } + assert!(crate::commands::find("edit.fillHistory").map(|c| (c.enabled)(&s).is_err()).unwrap()); + + s.execute("edit.fillForeground", json!({})).unwrap(); + assert_eq!(type_colors(&s), [[1.0, 0.0, 0.0]], "every run takes the foreground"); + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.fillBackgroundPreserve", json!({})).unwrap(); + assert_eq!(type_colors(&s), [[0.0, 0.0, 1.0]]); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step"); + assert!(s.undo()); + assert_eq!(type_colors(&s), [[1.0, 0.0, 0.0]]); + + s.execute("layer.lockLayers", json!({"pixels": true})).unwrap(); + for id in &IDS[..4] { + let err = s.execute(id, json!({})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{id}: {err}"); + } + assert_eq!(type_colors(&s), [[1.0, 0.0, 0.0]]); + } } diff --git a/crates/engine/src/fill_layer_mode_tests.rs b/crates/engine/src/fill_layer_mode_tests.rs index 69f786baa8..4bcc25ef26 100644 --- a/crates/engine/src/fill_layer_mode_tests.rs +++ b/crates/engine/src/fill_layer_mode_tests.rs @@ -122,6 +122,9 @@ fn rgb_documents_keep_rgb_fill_colours() { #[test] fn gray_fill_converts_with_the_document() { let mut s = session("gray", 16); + // Tagged sGray, where a gray value reads the same as RGB, so the before/after pixels compare + // directly (an untagged gray document takes the Dot Gain 20 %-compatible default). + s.execute("edit.assignProfile", json!({"profile": "sgray"})).unwrap(); s.execute("layer.newFillLayer.solidColor", json!({"color": "#0000ff"})).unwrap(); let before = pixel(&mut s, 10, 10); s.execute("image.mode.rgb", json!({})).unwrap(); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 32ae7fb3b8..c54868a808 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -627,6 +627,30 @@ mod tests { } } + #[test] + fn minimum_signed_offset_matches_its_wrapped_distance() { + for depth in [8, 16, 32] { + let make_session = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 32, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_pattern(&mut s); + s + }; + let mut actual = make_session(); + let before = active_pixels(&actual); + actual.execute("filter.other.offset", json!({"horizontal": i32::MIN, "vertical": i32::MIN})).unwrap(); + let mut expected = make_session(); + expected.execute("filter.other.offset", json!({"horizontal": i32::MIN.rem_euclid(48), "vertical": i32::MIN.rem_euclid(32)})).unwrap(); + let after = active_pixels(&actual); + assert_eq!(after, active_pixels(&expected), "depth {depth}"); + assert_ne!(after, before); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(active_pixels(&actual), before); + actual.execute("edit.redo", json!({})).unwrap(); + assert_eq!(active_pixels(&actual), after); + } + } #[test] fn huge_offsets_clamp_instead_of_wrapping() { // 3e9 wrapped to a negative offset through `as i32`; it saturates now, so it diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index d26d87e517..15a71376b0 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -173,6 +173,18 @@ fn image_size(s: &mut Session, p: &Value) -> Result { pw = pw.or(Some(ow as f64 * pc / 100.0)); ph = ph.or(Some(oh as f64 * pc / 100.0)); } + // `keepPrintSize`: only the resolution given, the pixels follow it (Photoshop's Image Size + // with Resample on, as an action records it). + if p.get("keepPrintSize").and_then(Value::as_bool) == Some(true) + && pw.is_none() + && ph.is_none() + && let Some(r) = p.get("resolution").and_then(Value::as_f64) + { + let r = r.clamp(1.0, 10_000.0); + let old = f64::from(d.doc.resolution_dpi).max(1.0); + pw = Some(ow as f64 * r / old); + ph = Some(oh as f64 * r / old); + } // Missing dimension keeps the aspect ratio. let (nw, nh) = match (pw, ph) { (Some(w), Some(h)) => (w, h), @@ -719,7 +731,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), @@ -862,6 +874,21 @@ mod tests { &s.active().unwrap().doc } + /// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size + /// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the + /// resolution changes. + #[test] + fn image_size_keep_print_size_resamples_to_the_resolution() { + let mut s = session(); + s.execute("image.imageSize", json!({"resolution": 144, "resample": "bicubic"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(40, 20), 144.0)); + s.execute("image.imageSize", json!({"resolution": 288, "resample": "bicubic", "keepPrintSize": true})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), 288.0)); + // An explicit side still wins. + s.execute("image.imageSize", json!({"width": 20, "resolution": 72, "resample": "bicubic", "keepPrintSize": true})).unwrap(); + assert_eq!(doc(&s).size, Size::new(20, 10)); + } + #[test] fn image_size_scales_everything() { for resample in ["bicubic", "bicubicSmoother", "bicubicSharper", "bicubicAutomatic", "automatic", "bilinear", "nearest", "lanczos", "preserveDetails"] { @@ -1381,6 +1408,29 @@ mod tests { } } + #[test] + fn grayscale_conversion_matches_photoshop() { + // Photoshop 27.11, default Color Settings (working gray Dot Gain 20 %): Image > Mode > + // Grayscale turns sRGB 64 / 128 / 192 grey and pure red into 46 / 105 / 177 / 107. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 1, "background": "white"})).unwrap(); + s.edit("paint", |doc, _| { + let l = doc.layers.first_mut().unwrap(); + let surf = l.surface_mut().unwrap(); + for (x, c) in [[64.0, 64.0, 64.0], [128.0, 128.0, 128.0], [192.0, 192.0, 192.0], [255.0, 0.0, 0.0]].iter().enumerate() { + surf.fill_rect(Rect::new(x as i32, 0, x as i32 + 1, 1), &[c[0] / 255.0, c[1] / 255.0, c[2] / 255.0, 1.0]); + } + Ok(()) + }) + .unwrap(); + s.execute("image.mode.grayscale", json!({})).unwrap(); + let surf = doc(&s).layers[0].surface().unwrap(); + for (x, want) in [46.0, 105.0, 177.0, 107.0].iter().enumerate() { + let got = surf.pixel(x as i32, 0)[0] * 255.0; + assert!((got - want).abs() <= 1.5, "pixel {x}: {got} vs {want}"); + } + } + #[test] fn mode_and_depth_conversions() { let mut s = session(); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 42c7fa4862..9c8cb9cdd0 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -632,6 +632,7 @@ impl Session { if id == "file.new" { crate::document_preset_cmds::validate_new_params(self, ¶ms)?; } + crate::param_check::check(spec, ¶ms)?; crate::filters::validate_params(id, ¶ms)?; // A floating selection drops before any other command (Undo puts it back instead). if let Some(v) = crate::float_cmds::before_command(self, id)? { diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 4f631e82ef..a40b712c81 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -78,6 +78,22 @@ fn a_job_applies_as_one_undo_step_and_matches_the_inline_result() { assert!(s.jobs().is_empty()); } +#[test] +fn free_transform_runs_as_a_job_with_the_inline_result() { + let quad = json!({"quad": [[20, 10], [280, 30], [260, 190], [40, 170]]}); + let mut inline = session(300, 200); + inline.execute("edit.transform", quad.clone()).unwrap(); + + let mut s = session(300, 200); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("edit.transform", quad).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + assert_eq!(e.label, "Free Transform"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step"); + assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); +} + #[test] fn cancel_leaves_the_document_byte_identical() { let mut s = session(1600, 1200); diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index a895210c19..366f9c8d39 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -612,8 +612,14 @@ fn blend_range(v: &Value) -> Option { fn global_light(s: &mut Session, p: &Value) -> Result { let cur = s.active().ok_or(EngineError::NoDocument)?.doc.global_light; - let angle = num(p, "angle", cur.angle); - let altitude = num(p, "altitude", cur.altitude).clamp(0.0, 90.0); + // Read as f64 and reject what f32 can't hold: 1e39 would become inf, and rem_euclid NaN. + let f32_param = |key: &str, default: f32| match p.get(key).and_then(Value::as_f64) { + None => Ok(default), + Some(v) if v.is_finite() && v.abs() <= f64::from(f32::MAX) => Ok(v as f32), + Some(_) => Err(bad("layer.layerStyle.globalLight", format!("{key} must be a finite number"))), + }; + let angle = f32_param("angle", cur.angle)?; + let altitude = f32_param("altitude", cur.altitude)?.clamp(0.0, 90.0); let angle = (angle + 180.0).rem_euclid(360.0) - 180.0; s.edit("Global Light", |doc, _| { doc.global_light.angle = angle; diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 51ad90500f..708bee5fce 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -140,6 +140,24 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn global_light_rejects_numbers_beyond_f32() { + let mut s = session(8, "rgb"); + // In-range values still wrap into -180..=180 and clamp to 0..=90. + s.execute("layer.layerStyle.globalLight", json!({"angle": 450, "altitude": 120})).unwrap(); + assert_eq!((doc(&s).global_light.angle, doc(&s).global_light.altitude), (90.0, 90.0)); + s.execute("layer.layerStyle.globalLight", json!({"angle": 540, "altitude": 30})).unwrap(); + assert_eq!((doc(&s).global_light.angle.abs(), doc(&s).global_light.altitude), (180.0, 30.0)); + let before = doc(&s).global_light; + let steps = s.active().unwrap().history.past_len(); + // 1e39 overflowed f32 to inf, which rem_euclid turned into a stored NaN (#4181). + for p in [json!({"angle": 1e39}), json!({"angle": -1e39}), json!({"altitude": 1e39}), json!({"altitude": -1e39})] { + assert!(s.execute("layer.layerStyle.globalLight", p.clone()).is_err(), "{p}"); + assert_eq!(doc(&s).global_light, before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{p}"); + } +} + #[test] fn blending_options_blend_if() { for depth in DEPTHS { diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 0d5985e339..e2f666d10e 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -113,7 +113,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec } } -/// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add"}`. +/// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`. pub fn select(s: &mut Session, p: &Value) -> Result { let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); @@ -144,6 +144,15 @@ pub fn select(s: &mut Session, p: &Value) -> Result { set_selection(s, sel, Some(id), Some(id))?; } } + // Toggle's removing half: a layer that isn't selected stays unselected (Photoshop's + // `removeFromSelection`, recorded by actions). + "remove" => { + if sel.contains(&id) && sel.len() > 1 { + sel.retain(|x| *x != id); + let active = if st.active_layer == Some(id) { sel.last().copied() } else { st.active_layer }; + set_selection(s, sel, active, active)?; + } + } "range" => { let anchor = st.layer_anchor.filter(|a| st.doc.layer(*a).is_some()).or(st.active_layer).unwrap_or(id); let mut order = Vec::new(); @@ -261,9 +270,9 @@ pub(crate) fn with_links(doc: &Document, ids: &[LayerId]) -> Vec { } /// Move layers by whole pixels the way the Move tool does: pixels, linked masks, type and -/// vector content (shapes, linked vector masks) all move together. -pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> Result<()> { - let snapshot = doc.clone(); +/// vector content (shapes, linked vector masks) all move together. `before` is `doc` as it was +/// before any of the moves (the edit's starting document), read when type and shapes re-render. +pub(crate) fn move_layers(doc: &mut Document, before: &Document, moves: &[(LayerId, i32, i32)]) -> Result<()> { for &(id, dx, dy) in moves { if dx == 0 && dy == 0 { continue; @@ -273,8 +282,8 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name))); } - crate::commands::translate_layer(&snapshot, l, dx, dy); - crate::vector_cmds::translate_vectors(&snapshot, l, dx as f64, dy as f64); + crate::commands::translate_layer(before, l, dx, dy); + crate::vector_cmds::translate_vectors(before, l, dx as f64, dy as f64); } Ok(()) } @@ -288,8 +297,27 @@ pub fn moves_mask_alone(l: &Layer) -> bool { /// `layer.translate`: the explicit layer, or every selected layer, plus their linked layers. /// With `"target":"mask"` a layer whose mask is unlinked moves that mask alone. pub fn translate(s: &mut Session, p: &Value) -> Result { - let dx = int_i32("layer.translate", p, "dx")?.unwrap_or(0); - let dy = int_i32("layer.translate", p, "dy")?.unwrap_or(0); + let (dx, dy) = match p.get("units").and_then(Value::as_str) { + None | Some("px") => (int_i32("layer.translate", p, "dx")?.unwrap_or(0), int_i32("layer.translate", p, "dy")?.unwrap_or(0)), + // Points (1/72 in), as a Photoshop action records a nudge: whole pixels at this + // document's resolution. + Some("pt") => { + let dpi = s.active().map_or(72.0, |d| f64::from(d.doc.resolution_dpi)); + let to_px = |key: &str| -> Result { + let pt = match p.get(key) { + None => 0.0, + Some(v) => v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| bad("layer.translate", format!("`{key}` must be a number")))?, + }; + let px = (pt * dpi / 72.0).round(); + if px.abs() > f64::from(i32::MAX) { + return Err(bad("layer.translate", format!("`{key}` = {pt} pt is outside the 32-bit coordinate range"))); + } + Ok(px as i32) + }; + (to_px("dx")?, to_px("dy")?) + } + Some(other) => return Err(bad("layer.translate", format!("unknown units `{other}` (px|pt)"))), + }; if dx == 0 && dy == 0 { return Ok(Value::Null); } @@ -309,7 +337,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { return Err(EngineError::NoLayer(roots[0])); } let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect(); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; for id in alone { let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -873,6 +901,7 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { let edge = Edge::parse(kind).ok_or_else(|| bad(&cmd, "unknown edge"))?; let sel = selected(s); let to = p.get("to").and_then(Value::as_str).unwrap_or("auto").to_string(); + let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let moved = s.edit("Align", |doc, _| { let items = bounded_selection(doc, &sel); if items.is_empty() { @@ -904,7 +933,7 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { }) .collect(); let moves = dedup_moves(doc, moves); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count()) })?; Ok(json!({"moved": moved})) @@ -927,6 +956,7 @@ fn dedup_moves(doc: &Document, moves: Vec<(LayerId, i32, i32)>) -> Vec<(LayerId, fn distribute(s: &mut Session, kind: &str) -> Result { let sel = selected(s); let kind = kind.to_string(); + let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let moved = s.edit("Distribute", |doc, _| { let mut items = bounded_selection(doc, &sel); if items.len() < 3 { @@ -966,7 +996,7 @@ fn distribute(s: &mut Session, kind: &str) -> Result { } let moves: Vec<_> = moves.into_iter().flat_map(|(id, dx, dy)| with_links(doc, &[id]).into_iter().map(move |l| (l, dx, dy))).collect(); let moves = dedup_moves(doc, moves); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count()) })?; Ok(json!({"moved": moved})) @@ -1480,6 +1510,42 @@ mod tests { s.active().unwrap().last_damage } + /// A Photoshop action's nudge in points moves whole pixels at the document's resolution: + /// 0.36 pt is 1 px at 200 ppi and 1.5 → 2 px at 300 ppi. + #[test] + fn translate_in_points_follows_the_resolution() { + for (res, moved) in [(200, 1), (300, 2), (72, 0)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 80, "resolution": res})).unwrap(); + let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20)); + s.execute("layer.translate", json!({"dx": 0.36, "dy": -0.36, "units": "pt"})).unwrap(); + assert_eq!(bounds(&s, a), Rect::new(10 + moved, 10 - moved, 20 + moved, 20 - moved), "{res} ppi"); + } + let mut s = session(8); + rect_layer(&mut s, Rect::new(10, 10, 20, 20)); + for bad in [json!({"dx": 1, "units": "in"}), json!({"dx": "x", "units": "pt"}), json!({"dx": 1e300, "units": "pt"})] { + assert!(s.execute("layer.translate", bad.clone()).is_err(), "{bad}"); + } + } + + /// `remove` deselects a selected layer, leaves an unselected one alone and never empties the + /// selection. + #[test] + fn select_remove_takes_a_layer_out_of_the_selection() { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20)); + let b = rect_layer(&mut s, Rect::new(30, 10, 40, 20)); + let c = rect_layer(&mut s, Rect::new(50, 10, 60, 20)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + s.execute("layer.select", json!({"layer": c.0, "mode": "remove"})).unwrap(); + assert_eq!(sel(&s).len(), 2, "an unselected layer stays out"); + s.execute("layer.select", json!({"layer": b.0, "mode": "remove"})).unwrap(); + assert_eq!(sel(&s), vec![a]); + s.execute("layer.select", json!({"layer": a.0, "mode": "remove"})).unwrap(); + assert_eq!(sel(&s), vec![a], "the last selected layer stays"); + } + /// A mask with its chain off stays put while the layer moves; a linked one moves along. #[test] fn translate_leaves_an_unlinked_mask_in_place() { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index b7fc8b7b01..fbbc949e20 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -52,9 +52,21 @@ fn color(p: &Value, k: &str, d: [f32; 3]) -> Color { let c = color_of(p, k).map_or(d, |c| c.to_rgb()); Color::rgb(c[0], c[1], c[2]) } +/// The stops of a two-colour gradient, baked with Photoshop's interpolation when `method` names +/// one (`classic`, `perceptual`, `linear`, `smooth`; `smoothness` 0..=100, default 100 as in +/// Photoshop): the compositors interpolate stops linearly, so the curve becomes dense stops, as on +/// PSD import. Photoshop 27.11's Classic Gradient Overlay eases (t + smoothstep(t)) / 2 between the +/// colours: a straight ramp was up to 12 levels off. +fn gradient_stops(p: &Value, from: Color, to: Color) -> Vec<(f32, Color)> { + let stops = vec![(0.0, from), (1.0, to)]; + match p.get("method").and_then(Value::as_str).and_then(photocraft_io::GradientMethod::from_name) { + Some(m) => photocraft_io::bake_gradient(stops, &[0.5], (f(p, "smoothness", 100.0) / 100.0).clamp(0.0, 1.0), m), + None => stops, + } +} fn gradient(p: &Value) -> Gradient { Gradient { - stops: vec![(0.0, color(p, "from", [0.0; 3])), (1.0, color(p, "to", [1.0; 3]))], + stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])), style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")), angle: f(p, "angle", 90.0), scale: f(p, "scale", 100.0) / 100.0, @@ -546,6 +558,13 @@ fn fresh_effect(kind: &str) -> Option { /// Edits an existing gradient in place: only the end colours and geometry. /// Intermediate stops, opacity stops, alignment and offset survive a round trip. fn overlay_gradient(g: &mut Gradient, p: &Value) { + // A method rebuilds the gradient from its end colours (the stops between are the old curve). + if p.get("method").and_then(Value::as_str).is_some_and(|m| photocraft_io::GradientMethod::from_name(m).is_some()) { + let ends = (g.stops.first().map(|s| s.1), g.stops.last().map(|s| s.1)); + let from = color_of(p, "from").or(ends.0).unwrap_or(Color::BLACK); + let to = color_of(p, "to").or(ends.1).unwrap_or(Color::WHITE); + g.stops = gradient_stops(p, from, to); + } if let Some(c) = color_of(p, "from") && let Some(first) = g.stops.first_mut() { @@ -983,13 +1002,13 @@ pub fn specs() -> Vec { style_cmd!( "stroke", "Stroke…", - r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), style_cmd!( "gradientOverlay", "Gradient Overlay…", - r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!( "patternOverlay", @@ -1342,6 +1361,26 @@ mod tests { assert!((at(&lut("Cove - Deep"), 0.0) - 0.26).abs() < 0.02, "Cove - Deep starts above zero"); } + #[test] + fn gradient_overlay_method_matches_photoshop() { + // Photoshop 27.11, Classic Gradient Overlay #000000 → #ffffff (Smoothness 100 %): 0.037 at + // t = 1/16 and 0.5 in the middle; straight stops would give 0.0625. + let mut s = session(); + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "method": "classic"})).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &effects(&s)[0] else { panic!() }; + let at = |t: f32| { + let w = gradient.stops.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-6); + w[0].1.to_rgb()[0] + (w[1].1.to_rgb()[0] - w[0].1.to_rgb()[0]) * u + }; + assert!((at(1.0 / 16.0) - 0.037).abs() < 0.004, "{}", at(1.0 / 16.0)); + assert!((at(0.5) - 0.5).abs() < 0.004, "{}", at(0.5)); + // Without a method the two stops stay as they are. + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "add": true})).unwrap(); + let plain = effects(&s).iter().filter(|e| matches!(e, Effect::GradientOverlay { gradient, .. } if gradient.stops.len() == 2)).count(); + assert_eq!(plain, 1, "the added overlay keeps its two stops"); + } + #[test] fn contour_presets_and_custom_curves_apply_in_one_undo_step() { let mut s = session(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 27a2f941a8..58a8974298 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -76,6 +76,7 @@ pub mod multichannel_cmds; pub mod new_layer; pub mod notes_cmds; pub mod paint_cmds; +mod param_check; mod path_edit_cmds; pub mod pattern_cmds; pub mod pattern_fill_cmds; @@ -432,6 +433,9 @@ pub struct Session { pub link_scan: Option, /// Select Subject's last result, reused while the pixels are unchanged (#2865). subject_cache: smartselect_cmds::SubjectCache, + /// Object Selection's Object Finder results (#4087), shared by the hover preview and the + /// click that commits it; valid while the pixels are unchanged. + pub object_finder: smartselect_cmds::ObjectFinderCache, /// A finished live stroke offered to the `paint.stroke` / `paint.pencil` that commits it /// ([`Session::offer_live_stroke`]). pub(crate) live_commit: Option, @@ -528,6 +532,7 @@ impl Session { if let Some(id) = self.docs.get(index).map(|d| d.doc.id) { self.cancel_jobs_on(id); self.subject_cache.forget_doc(id); + self.object_finder.forget_doc(id); } let d = self.docs.remove(index); self.active = if self.docs.is_empty() { @@ -567,6 +572,15 @@ impl Session { Ok(()) } + /// [`Self::offer_live_stroke`] for a live Clone Stamp or History Brush stroke, taken by the + /// `paint.cloneStamp` / `paint.historyBrush` it was drawn for. Refused for the Healing Brush + /// (it blends on release) and when the brush, layer or clone source changed during the stroke. + pub fn offer_live_retouch(&mut self, live: retouch_cmds::LiveRetouch) -> Result<()> { + let commit = live.into_commit(self)?; + self.live_commit = Some(commit); + Ok(()) + } + /// Withdraw an offer the command didn't take. pub fn clear_live_stroke(&mut self) { self.live_commit = None; @@ -600,6 +614,10 @@ impl Session { if self.names_existing_layer(spec.id, params) { return Ok(()); } + // `ifPresent`: with no Background there is nothing to do, which isn't an error. + if spec.id == "layer.new.layerFromBackground" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) { + return self.active().map(|_| ()).ok_or_else(|| "no document open".into()); + } channel_cmds::mask_target_enabled(self, spec.id, params).unwrap_or_else(|| (spec.enabled)(self)) } @@ -692,6 +710,7 @@ impl Session { if damage.is_none_or(|d| !d.is_empty()) { // Free the mask early; a stale entry would only miss (it is keyed by pixels). self.subject_cache = Default::default(); + self.object_finder = Default::default(); } Ok(r) } diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 319a6e5334..fe22a192c7 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -27,6 +27,14 @@ fn num(p: &Value, key: &str, default: f32) -> f32 { p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) } +/// A finite number param, `default` when absent or null; anything else is a bad-params error. +fn finite_num(p: &Value, cmd: &str, key: &str, default: f64) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number"))), + } +} + fn str_or<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str { p.get(key).and_then(Value::as_str).unwrap_or(default) } @@ -388,9 +396,14 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { "pattern" => BitmapMethod::Pattern, "diffusion" => BitmapMethod::Diffusion, "halftone" => { - let freq = num(p, "frequency", 53.0).clamp(1.0, 999.0); + // Photoshop's Halftone Screen: frequency 1..999 lines/inch, angle -180..180 degrees. + let freq = finite_num(p, CMD, "frequency", 53.0)?.clamp(1.0, 999.0) as f32; + let angle = finite_num(p, CMD, "angle", 45.0)?; + if !(-180.0..=180.0).contains(&angle) { + return Err(bad(CMD, format!("angle {angle} is outside -180..180"))); + } let shape = HalftoneShape::from_id(str_or(p, "shape", "round")).ok_or_else(|| bad(CMD, "shape: round|ellipse|line|square|diamond|cross"))?; - BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: num(p, "angle", 45.0), shape } + BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: angle as f32, shape } } m => { return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone)"))); diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 8ac4e6d332..d6d020699e 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -291,6 +291,38 @@ fn bitmap_methods_need_grayscale() { } } +#[test] +fn bitmap_halftone_rejects_bad_angles_and_frequencies_without_editing_the_document() { + // Photoshop's Halftone Screen takes an angle in -180..180 degrees (#4214): 1e39 used to become + // inf, then NaN in the screen rotation, and an almost white bitmap landed in history. + let mut s = session(16, 16, 8, "grayscale"); + let before = s.active().unwrap().history.past_len(); + let bad = [ + ("angle", json!(1e39)), + ("angle", json!(500)), + ("angle", json!(-181)), + ("angle", json!(180.01)), + ("angle", json!("x")), + ("frequency", json!("x")), + ("frequency", json!(true)), + ]; + for (key, value) in bad { + let mut p = json!({"method": "halftone"}); + p[key] = value.clone(); + assert!(s.execute("image.mode.bitmap", p).is_err(), "{key}: {value}"); + assert_eq!(s.active().unwrap().history.past_len(), before, "{key}: {value}"); + assert_eq!(doc(&s).mode, ColorMode::Grayscale, "{key}: {value}"); + } + for angle in [-180.0, 30.0, 180.0] { + s.execute("image.mode.bitmap", json!({"method": "halftone", "angle": angle})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Bitmap, "{angle}"); + s.execute("edit.undo", json!({})).unwrap(); + } + // Frequency stays clamped to the documented 1..999 lines/inch. + s.execute("image.mode.bitmap", json!({"method": "halftone", "frequency": 1e39})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Bitmap); +} + #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index ad16cd85d9..497d7f4fb1 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -1,6 +1,6 @@ //! Paint helpers: Paint Bucket and Gradient tool. -use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_region_src, bucket_fill_src, paint_gradient}; +use photocraft_algo::paint::{GradientShape, bucket_fill_inks, bucket_fill_region_inks, bucket_fill_region_src, bucket_fill_src, paint_gradient}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -35,11 +35,18 @@ fn point(p: &Value, k: &str) -> Option<(f32, f32)> { Some((a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) } +/// `opacity` (1..100 %) as a coverage in 0.01..=1, clamped like the Magic Eraser's: a value +/// above 1 would overshoot the blend (#4167). +fn opacity(p: &Value, cmd: &str) -> Result { + Ok(crate::eraser_cmds::number(p, cmd, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0) +} + fn bucket(s: &mut Session, p: &Value) -> Result { let fg = s.tools.foreground; let c = color(p.get("color"), fg); let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32); - let (tol, contiguous, aa, opacity) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), f(p, "opacity", 100.0) / 100.0); + let (tol, contiguous, aa) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true)); + let opacity = opacity(p, "paint.bucket")?; let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels; // Other visible layers can define the region, but the fill still writes only to the target. let region = if all { @@ -61,6 +68,8 @@ fn bucket(s: &mut Session, p: &Value) -> Result { } else { None }; + // A foreground picked as CMYK fills a CMYK document with the picked inks (#4196). + let inks = s.tools.picked_cmyk([c[0], c[1], c[2]]).filter(|_| c[3] >= 1.0 && pattern.is_none()); let filled = s.edit("Paint Bucket", |doc, active| { let area = doc.bounds(); let sel = doc.selection.clone(); @@ -80,10 +89,10 @@ fn bucket(s: &mut Session, p: &Value) -> Result { bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src) } } else if let Some(region) = ®ion { - bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, |_, _| c); + bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks); true } else { - bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref(), lock) + bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks) }; surf.prune(); Ok(ok) @@ -114,8 +123,25 @@ fn gradient(s: &mut Session, p: &Value) -> Result { gradients::apply_opacity(cs, &gradients::transparency_param(p, "paint.gradient")?.unwrap_or_default()) } }; + // Photoshop's interpolation (`method`, with `smoothness` 0..=100, default 100) baked into dense + // stops, as for Gradient Overlay: Photoshop 27.11's Classic black → white is 0.037 at t = 1/16 + // where straight stops give 0.0625. + let stops = match p.get("method").and_then(Value::as_str).and_then(photocraft_io::GradientMethod::from_name) { + Some(m) if stops.len() >= 2 => { + let colors = stops.iter().map(|(t, c)| (*t, photocraft_color::Color::rgba(c[0], c[1], c[2], c[3]))).collect(); + let mids = vec![0.5; stops.len() - 1]; + photocraft_io::bake_gradient(colors, &mids, (f(p, "smoothness", 100.0) / 100.0).clamp(0.0, 1.0), m) + .into_iter() + .map(|(t, c)| { + let rgb = c.to_rgb(); + (t, [rgb[0], rgb[1], rgb[2], c.alpha]) + }) + .collect() + } + _ => stops, + }; let reverse = b(p, "reverse", false); - let opacity = f(p, "opacity", 100.0) / 100.0; + let opacity = opacity(p, "paint.gradient")?; let blend = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(photocraft_color::BlendMode::Normal); let dither = b(p, "dither", true); // Photoshop dithers gradients by default (reduces banding). s.edit("Gradient", |doc, active| { @@ -146,7 +172,7 @@ pub fn specs() -> Vec { label: "Gradient", menu: &[], shortcut: None, - params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, enabled: crate::commands::has_paintable, run: gradient, journal: true, @@ -330,6 +356,30 @@ mod tests { assert_eq!(px(&s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); } + /// #4196: the Paint Bucket and Edit > Stroke write a CMYK pick as its inks into a CMYK document, + /// not as its sRGB stand-in converted back (100/0/0/0 cyan would come out as about 100/28/0/17). + #[test] + fn bucket_and_stroke_write_a_cmyk_pick_as_its_inks() { + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + let mut s = session(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + let inks_at = |s: &Session, x: i32, y: i32| -> Vec { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(Rect::new(x, y, x + 1, y + 1)).into_iter().take(4).collect() + }; + let is_cyan = |got: &[f32]| got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02); + s.execute("paint.bucket", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap(); + let got = inks_at(&s, 5, 5); + assert!(is_cyan(&got), "bucket: {got:?}"); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.stroke", json!({"width": 2, "location": "inside", "color": rgb})).unwrap(); + let got = inks_at(&s, 0, 5); + assert!(is_cyan(&got), "stroke: {got:?}"); + } + #[test] fn bucket_fills_with_a_pattern() { // Paint Bucket with contents="pattern" flood-fills the region with a pattern, not a colour. @@ -381,4 +431,59 @@ mod tests { let noisy = run(true); assert!(noisy.windows(2).any(|w| w[0] != w[1]), "dither: per-pixel variation"); } + + /// The two commands under test, each painting #808080 over a white document. + fn grey_fill(cmd: &str, opacity: Value) -> Value { + match cmd { + "paint.bucket" => json!({"x": 2, "y": 2, "color": "#808080", "opacity": opacity}), + _ => json!({"from": [0, 0], "to": [19, 0], "colors": ["#808080", "#808080"], "dither": false, "opacity": opacity}), + } + } + + fn white(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10, "depth": depth, "background": "white"})).unwrap(); + s + } + + /// Issue #4167: an opacity above 100 overshot the blend (200 painted near-black on white). + #[test] + fn bucket_and_gradient_clamp_opacity_like_the_magic_eraser() { + for cmd in ["paint.bucket", "paint.gradient"] { + for depth in [8, 16, 32] { + let mut full = white(depth); + full.execute(cmd, grey_fill(cmd, json!(100))).unwrap(); + let expected = px(&full, 5, 5); + assert!((expected[0] - 128.0 / 255.0).abs() < 0.01, "{cmd} depth {depth}: 100% paints #808080, got {expected:?}"); + for over in [200.0, 1e9] { + let mut s = white(depth); + s.execute(cmd, grey_fill(cmd, json!(over))).unwrap(); + assert_eq!(px(&s, 5, 5), expected, "{cmd} depth {depth}: opacity {over} clamps to 100"); + } + let (mut low, mut one) = (white(depth), white(depth)); + low.execute(cmd, grey_fill(cmd, json!(-50))).unwrap(); + one.execute(cmd, grey_fill(cmd, json!(1))).unwrap(); + assert_eq!(px(&low, 5, 5), px(&one, 5, 5), "{cmd} depth {depth}: opacity below 1 clamps to 1"); + assert!(px(&low, 5, 5).iter().all(|v| (0.0..=1.0).contains(v)), "{cmd} depth {depth}: no overshoot"); + } + } + } + + #[test] + fn bucket_and_gradient_reject_non_numeric_opacity_without_editing() { + for cmd in ["paint.bucket", "paint.gradient"] { + for bad in [json!("50"), json!(true), json!([50]), json!({"v": 50})] { + let mut s = white(8); + let (before, steps) = (px(&s, 5, 5), s.active().unwrap().history.past_len()); + let err = s.execute(cmd, grey_fill(cmd, bad.clone())).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{cmd} {bad}: {err:?}"); + assert_eq!(px(&s, 5, 5), before, "{cmd} {bad}: pixels unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd} {bad}: no history step"); + } + // An explicit null keeps the default (100%), like the Magic Eraser. + let mut s = white(8); + s.execute(cmd, grey_fill(cmd, Value::Null)).unwrap(); + assert!((px(&s, 5, 5)[0] - 128.0 / 255.0).abs() < 0.01, "{cmd}: null opacity is 100%"); + } + } } diff --git a/crates/engine/src/param_check.rs b/crates/engine/src/param_check.rs new file mode 100644 index 0000000000..a59236d1d1 --- /dev/null +++ b/crates/engine/src/param_check.rs @@ -0,0 +1,138 @@ +//! Reject params a command does not document (#4336). +//! +//! Commands read the keys they know from a JSON object and used to ignore the rest, so a +//! misspelled key (`{"ids":[4]}` for `{"layer":4}`) ran the command on its defaults and reported +//! success. For the commands in [`STRICT`] the dispatcher now checks the keys against the +//! command's `params` doc once, for every transport (CLI, MCP, control channel, UI, actions). +//! +//! This is opt-in because `params` docs are free-form text and many are not complete yet (the +//! engine's own tests pass undocumented keys to `paint.pencil`, `timeline.create`, +//! `layer.new.artboard`, the Selective Color and Color Lookup adjustments and more). To opt a +//! command in: make sure its `params` doc names every key the command reads as `"key":...`, and +//! add its id to [`STRICT`]; `strict_commands_have_closed_docs` checks the doc is parseable. +//! A doc with a top-level `…` is open-ended and is never checked. + +use serde_json::Value; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result}; + +/// Keys any command accepts: the history-coalescing key every command honours (see +/// `Session::execute`) and the Channels-panel `"target"` that pixel commands follow. +const UNIVERSAL: &[&str] = &["coalesce", "target"]; + +/// The commands whose unknown params are rejected: their `params` doc is complete. +const STRICT: &[&str] = &["layer.hideLayers", "layer.showLayers"]; + +/// The keys a `params` doc names, or `None` when the doc is open-ended. +fn documented_keys(doc: &str) -> Option> { + let bytes = doc.as_bytes(); + let (mut depth, mut in_str, mut start) = (0usize, false, 0usize); + let mut quoted: Vec<(usize, usize)> = Vec::new(); + for (i, &b) in bytes.iter().enumerate() { + if in_str { + if b == b'"' { + in_str = false; + quoted.push((start, i)); + } + continue; + } + match b { + b'"' => { + in_str = true; + start = i + 1; + } + b'{' | b'[' => depth += 1, + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + // `…` / `...` straight inside the top-level braces: the list is not complete. + if depth == 1 + && (doc.get(i..).is_some_and(|r| r.starts_with('…') || r.starts_with("..."))) + && doc.get(..i).is_some_and(|l| l.trim_start().starts_with('{')) + { + return None; + } + } + let mut keys = Vec::new(); + for (n, &(a, b)) in quoted.iter().enumerate() { + let Some(word) = doc.get(a..b) else { continue }; + if word.is_empty() || !word.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') { + continue; + } + // A key is followed by `:`, or by `,"other"` pairs that end in one (`"all","pixels":bool`). + let mut end = b + 1; + let mut k = n; + loop { + let rest = doc.get(end..).unwrap_or(""); + if rest.starts_with(':') { + keys.push(word); + break; + } + let Some(after) = rest.strip_prefix(',') else { break }; + let Some(&(na, nb)) = quoted.get(k + 1) else { break }; + if na != end + 2 || after.as_bytes().first() != Some(&b'"') { + break; + } + end = nb + 1; + k += 1; + } + } + Some(keys) +} + +/// Err when `params` has a key the command does not document (only for commands in [`STRICT`]). +pub(crate) fn check(spec: &CommandSpec, params: &Value) -> Result<()> { + if !STRICT.contains(&spec.id) { + return Ok(()); + } + let Some(map) = params.as_object() else { return Ok(()) }; + let Some(keys) = documented_keys(spec.params) else { return Ok(()) }; + for key in map.keys() { + if UNIVERSAL.contains(&key.as_str()) || keys.contains(&key.as_str()) { + continue; + } + let expected = if keys.is_empty() { "no parameters".to_string() } else { format!("expected: {}", keys.join(", ")) }; + return Err(EngineError::BadParams { cmd: spec.id.to_string(), msg: format!("unknown parameter `{key}` ({expected})") }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + #[test] + fn documented_keys_reads_keys_and_stops_at_open_docs() { + assert_eq!(documented_keys(r##"{"layer":id?} (shows them instead)"##), Some(vec!["layer"])); + assert_eq!(documented_keys(r##"{"locks":{"all","pixels":bool}?,"name":str?}"##), Some(vec!["locks", "all", "pixels", "name"])); + assert_eq!(documented_keys("{}"), Some(vec![])); + assert_eq!(documented_keys(r##"{"layer":id?, …params of that kind}"##), None); + } + + #[test] + fn strict_commands_have_closed_docs() { + for id in STRICT { + let spec = crate::commands::find(id).expect("strict command exists"); + assert!(documented_keys(spec.params).is_some_and(|k| !k.is_empty()), "{id}: params doc must list its keys"); + } + } + + #[test] + fn unknown_param_is_an_error_naming_the_key_and_the_accepted_ones() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let visible = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(a)).unwrap().visible; + for cmd in ["layer.hideLayers", "layer.showLayers"] { + let err = s.execute(cmd, json!({"ids": [a]})).unwrap_err().to_string(); + assert!(err.contains("unknown parameter `ids`") && err.contains("expected: layer"), "{err}"); + assert!(visible(&s), "{cmd}: nothing ran"); + } + s.execute("layer.hideLayers", json!({"layer": a, "coalesce": "k"})).unwrap(); + assert!(!visible(&s)); + } +} diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index d58fd9e827..0cb9052ceb 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -268,8 +268,12 @@ fn delete(s: &mut Session, p: &Value) -> Result { fn import(s: &mut Session, p: &Value) -> Result { let cmd = "pattern.import"; - let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `path` (.pat file)"))?; - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // A file on disk, or its bytes (a dropped file, the web build). + let bytes = match (p.get("path").and_then(Value::as_str), p.get("data").and_then(Value::as_str)) { + (Some(path), _) => std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, + (None, Some(b64)) => photocraft_paint::tile::b64_decode(b64).ok_or_else(|| bad(cmd, "`data` is not base64"))?, + (None, None) => return Err(bad(cmd, "missing `path` (.pat file) or `data`")), + }; let pats = photocraft_io::pattern_map::read_pat(&bytes).map_err(EngineError::Other)?; let mut ids = Vec::new(); for pat in pats { @@ -279,7 +283,7 @@ fn import(s: &mut Session, p: &Value) -> Result { None => s.patterns.items.push(pat), } } - Ok(json!({"imported": ids})) + Ok(json!({"imported": ids, "count": ids.len()})) } fn export(s: &mut Session, p: &Value) -> Result { @@ -409,7 +413,7 @@ pub fn specs() -> Vec { label: "Import Patterns…", menu: &[], shortcut: None, - params: r##"{"path":".pat file"}"##, + params: r##"{"path":".pat file"?,"data":base64 bytes? (instead of a path)} → {imported:[ids],count}"##, enabled: always, journal: true, run: import, @@ -500,8 +504,17 @@ mod tests { let v = s.execute("pattern.import", json!({"path": path})).unwrap(); assert!(v["imported"].as_array().unwrap().iter().any(|x| x == id.as_str())); assert_eq!(resolve(&s, &id).unwrap().surface.read_region(Rect::new(0, 0, 5, 4)), pat.surface.read_region(Rect::new(0, 0, 5, 4))); + // The same file as bytes (a dropped file, the web build). + let bytes = std::fs::read(path).unwrap(); + s.execute("pattern.delete", json!({"pattern": "Crimson"})).unwrap(); + let v = s.execute("pattern.import", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(v["count"], v["imported"].as_array().unwrap().len()); + assert_eq!(resolve(&s, "Crimson").unwrap().id, id); std::fs::remove_dir_all(dir).ok(); assert!(s.execute("pattern.import", json!({"path": "/nonexistent.pat"})).is_err()); + for bad in [json!({}), json!({"data": "not base64!"}), json!({"data": "aGVsbG8="})] { + assert!(s.execute("pattern.import", bad.clone()).is_err(), "{bad}"); + } } #[test] diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index c8583305da..c40e15e9b8 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -61,11 +61,8 @@ fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec None => 0.0, }, }; - let mask = l.mask.as_ref().filter(|m| m.enabled).map_or(1.0, |m| { - let mut v = [0.0f32]; - m.surface.read_pixel(x, y, &mut v); - v[0] - }); + // As the compositor sees it: density applied, all ones when the mask is disabled. + let mask = l.mask.as_ref().map_or(1.0, |m| m.value(x, y)); if alpha * mask > 0.0 { out.push(l.id); } @@ -95,9 +92,9 @@ fn pick(s: &mut Session, p: &Value) -> Result { let x = p.get("x").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `x`".into() })?.floor() as i32; let y = p.get("y").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `y`".into() })?.floor() as i32; let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - // The right-click list names the layers with pixels under the pointer. + // The right-click list includes type layers between glyphs, like Auto-Select. if p.get("list").and_then(Value::as_bool).unwrap_or(false) { - let hits = layers_hit(&doc, x, y, false); + let hits = layers_hit(&doc, x, y, true); let names: Vec = hits.iter().filter_map(|id| doc.layer(*id)).map(|l| json!({"layer": l.id.0, "name": l.name})).collect(); return Ok(json!({ "layers": names })); } @@ -193,7 +190,7 @@ pub fn specs() -> Vec { label: "Auto-Select Layer", menu: &[], shortcut: None, - params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return every layer with pixels there, topmost first)} → {layer} | {layers}"##, + params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return visible layers at the point, including text bounds, topmost first)} → {layer} | {layers}"##, enabled: has_doc, journal: false, run: pick, @@ -375,9 +372,10 @@ mod tests { assert_eq!(pick(&mut s, 12, 15), t.0, "on a glyph"); assert_eq!(pick(&mut s, 17, 15), t.0, "between the glyphs: the type layer, not the one under it"); assert_eq!(pick(&mut s, 17, 20), bg.0, "below the text bounds"); - // The right-click list still names only the layers with pixels under the pointer. + // The context menu also reaches this type layer between the glyphs (#4259). let list = s.execute("layer.pickAt", json!({"x": 17, "y": 15, "list": true})).unwrap(); - assert_eq!(list["layers"].as_array().unwrap().len(), 1); + assert_eq!(list["layers"][0]["layer"], t.0); + assert_eq!(list["layers"][1]["layer"], bg.0); } #[test] @@ -401,6 +399,9 @@ mod tests { let Some(x) = gap else { return }; assert_eq!(layers_at(&doc, x, y), vec![bg], "no type pixels at the gap"); assert_eq!(s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"], t); + let menu = s.execute("layer.pickAt", json!({"x": x, "y": y, "list": true})).unwrap(); + assert_eq!(menu["layers"][0]["layer"], t, "type layer stays above its background in the menu"); + assert_eq!(menu["layers"][1]["layer"], bg.0); } #[test] @@ -445,4 +446,24 @@ mod tests { let mut empty = Session::new(); assert!(empty.execute("layer.pickAt", json!({"x": 1, "y": 1})).is_err()); } + + /// A hide-all mask at density 0 hides nothing (the compositor applies density through + /// `LayerMask::value`), so the layer stays pickable; at full density it is clicked through. + #[test] + fn mask_density_is_honoured_when_picking() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let red = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + // Full density: the mask hides the layer, so the click reaches the Background. + assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], bg.0); + s.execute("layer.layerMask.edit", json!({"density": 0})).unwrap(); + assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], red.0); + let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); + let ids: Vec<_> = list["layers"].as_array().unwrap().iter().map(|l| l["layer"].clone()).collect(); + assert_eq!(ids, vec![json!(red.0), json!(bg.0)]); + } } diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index b19a1b270f..cde7fa41db 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -91,6 +91,9 @@ pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface /// `s`'s pixels over `r` (untouched ones included) with `adj` applied through the selection, encoded. fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Vec { let fmt = s.format(); + if fmt.mode == mode && matches!(mode, photocraft_color::ColorMode::Lab | photocraft_color::ColorMode::Cmyk) && adjust::stored_channel_adjustment(adj) { + return adjusted_channels(s, r, adj, selection); + } let n = fmt.channels(); let raw = s.read_region(r); let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() }; @@ -99,14 +102,65 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, // integer depths keep the unrounded, clipped curves. let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample); adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode); - let w = r.width() as usize; - let mut out = Vec::with_capacity(raw.len()); - for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() { - let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0)); - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); - let mut enc = [0.0f32; 8]; - let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc); - out.extend_from_slice(&enc[..m]); + mix_through_selection(&fmt, r, &orig.px, &buf.px, selection) +} + +/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels +/// (`adjust::stored_channels`): exact on the stored values, with no trip through RGB, and mixed +/// through the selection on those values (alpha untouched). +fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec { + let fmt = s.format(); + let (n, colors) = (fmt.channels(), fmt.mode.color_channels()); + let mut raw = s.read_region(r); + let w = (r.width() as usize).max(1); + let mut coverage = vec![1.0f32; w]; + for (j, row) in raw.chunks_mut(w * n).enumerate() { + if let Some(sel) = selection { + sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage); + } + for (px, &k) in row.chunks_exact_mut(n).zip(&coverage) { + if k <= 0.0 { + continue; + } + let p = to_rgba(&fmt, px); + let gray = photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]); + let Some(ch) = px.get_mut(..colors) else { continue }; + let mut out = [0.0f32; 4]; + let Some(out) = out.get_mut(..colors) else { continue }; + out.copy_from_slice(ch); + adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), out, gray); + for (v, o) in ch.iter_mut().zip(out.iter()) { + *v += (o - *v) * k; + } + } + } + raw +} + +/// `edited` mixed back over `orig` (straight RGBA over `r`, row-major) by the selection's +/// coverage, encoded in `fmt`: what a destructive edit writes back through a selection. +pub(crate) fn mix_through_selection( + fmt: &photocraft_color::PixelFormat, + r: Rect, + orig: &[[f32; 4]], + edited: &[[f32; 4]], + selection: Option<&Surface>, +) -> Vec { + let w = (r.width() as usize).max(1); + let mut out = Vec::with_capacity(orig.len() * fmt.channels()); + // The selection's coverage (channel 0) one row at a time, each of its tiles looked up once + // per row rather than once per pixel. + let mut coverage = vec![1.0f32; w]; + for (j, (edited_row, orig_row)) in edited.chunks(w).zip(orig.chunks(w)).enumerate() { + if let Some(sel) = selection { + sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage); + } + for ((a, o), k) in edited_row.iter().zip(orig_row).zip(&coverage) { + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(fmt, mixed, &mut enc); + out.extend_from_slice(&enc[..m]); + } } out } diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index c0dc368e40..965544307a 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -242,7 +242,8 @@ fn benchmark_clear_large_selection() { } } -/// Image › Adjustments › Invert in a Lab document mirrors L, a and b, at every depth. +/// Image › Adjustments › Invert in a Lab document mirrors L, a and b, at every depth: 8-bit bytes +/// become 255 - v (a lands one step past the mirror), 16 bits mirror a and b around neutral. #[test] fn invert_in_a_lab_document_mirrors_l_a_and_b() { use photocraft_color::convert::{lab_to_srgb, srgb_to_lab}; @@ -256,6 +257,95 @@ fn invert_in_a_lab_document_mirrors_l_a_and_b() { let px = s.read_region(Rect::new(1, 1, 2, 2)); let rgba = photocraft_raster::to_rgba(&fmt, &px); let got = srgb_to_lab([rgba[0], rgba[1], rgba[2]]); - assert!(got.iter().zip([70.0, -13.0, 17.0]).all(|(g, w)| (g - w).abs() < 1.0), "{depth:?}: {got:?}"); + let want = if depth == SampleType::U8 { [70.0, -13.0, 17.0] } else { [70.0, -12.0, 18.0] }; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.5), "{depth:?}: {got:?}"); } } + +#[test] +fn mixing_through_a_selection_matches_sampling_it_per_pixel() { + // A rectangle across tile edges (negative x included), a selection with fractional + // coverage, untouched tiles (default coverage) and a 16-bit as well as an 8-bit layer. + let r = Rect::new(-300, 200, 330, 290); + let gray32 = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }; + for sel_fmt in [PixelFormat::GRAY8, PixelFormat::GRAYA8, gray32] { + let mut sel = Surface::new(sel_fmt); + sel.fill_rect(Rect::new(-260, 210, 40, 280), &vec![0.6; sel_fmt.channels()]); + for x in 0..120 { + sel.write_pixel(x * 2, 230 + x % 40, &vec![x as f32 / 119.0; sel_fmt.channels()]); + } + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + let n = (r.width() * r.height()) as usize; + let orig: Vec<[f32; 4]> = (0..n).map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.25 + (i % 3) as f32 / 4.0]).collect(); + let edited: Vec<[f32; 4]> = orig.iter().map(|p| [1.0 - p[0], p[1] * 0.5, p[2], 1.0]).collect(); + let w = r.width() as usize; + let mut expected = Vec::new(); + for (i, (a, o)) in edited.iter().zip(&orig).enumerate() { + let k = sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0); + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc); + expected.extend_from_slice(&enc[..m]); + } + assert_eq!(mix_through_selection(&fmt, r, &orig, &edited, Some(&sel)), expected, "{sel_fmt:?} over {fmt:?}"); + } + } + // Without a selection the edit is taken whole; an empty rectangle mixes nothing. + let px = [[0.1, 0.2, 0.3, 1.0]]; + let fmt = PixelFormat::RGBA8; + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(&fmt, px[0], &mut enc); + assert_eq!(mix_through_selection(&fmt, Rect::new(0, 0, 1, 1), &[[0.0; 4]], &px, None), enc[..m].to_vec()); + assert!(mix_through_selection(&fmt, Rect::EMPTY, &[], &[], None).is_empty()); +} + +/// One `fmt` pixel holding `ch`, after `adj` through an optional `k`-strength selection. +fn adjusted_pixel(fmt: PixelFormat, ch: &[f32], adj: &Adjustment, k: Option) -> Vec { + let unit = Rect::new(0, 0, 1, 1); + let mut s = Surface::new(fmt); + s.write_region(unit, ch); + let mut sel = Surface::new(PixelFormat::GRAY8); + if let Some(k) = k { + sel.write_region(unit, &[k]); + } + adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode); + s.read_region(unit) +} + +fn assert_levels(got: &[f32], want_255: &[f32], what: &str) { + let want: Vec = want_255.iter().map(|v| v / 255.0).collect(); + assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-4), "{what}: got {:?}, want {want_255:?}", got.iter().map(|v| v * 255.0).collect::>()); +} + +/// Image › Adjustments › Invert, Posterize and Threshold change a Lab or CMYK layer's stored +/// channels exactly as Photoshop's do (photoshop corpus lab8, lab16, cmyk8 and cmyk16 invert, +/// posterize-4 and threshold-128 follow these rules on every pixel), with no trip through RGB. +#[test] +fn invert_posterize_and_threshold_change_lab_and_cmyk_channels_as_stored() { + let f = |v: [f32; 4]| v.map(|x| x / 255.0); + let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true); + let ink = [51.0, 102.0, 153.0, 26.0, 255.0].map(|x| x / 255.0); + assert_levels(&adjusted_pixel(cmyk, &ink, &Adjustment::Invert, None), &[204.0, 153.0, 102.0, 229.0, 255.0], "CMYK invert"); + assert_levels(&adjusted_pixel(cmyk, &ink, &Adjustment::Posterize { levels: 4 }, None), &[0.0, 85.0, 170.0, 0.0, 255.0], "CMYK posterize"); + // Threshold: the black ink alone, or paper. + let dark = [200.0, 180.0, 180.0, 200.0, 255.0].map(|x| x / 255.0); + assert_levels(&adjusted_pixel(cmyk, &dark, &Adjustment::Threshold { level: 0.5 }, None), &[0.0, 0.0, 0.0, 255.0, 255.0], "CMYK threshold dark"); + let paper = [0.0, 0.0, 0.0, 0.0, 1.0]; + assert_levels(&adjusted_pixel(cmyk, &paper, &Adjustment::Threshold { level: 0.5 }, None), &[0.0, 0.0, 0.0, 0.0, 255.0], "CMYK threshold paper"); + // A half-strength selection mixes the stored inks. + let half = adjusted_pixel(cmyk, &paper, &Adjustment::Invert, Some(0.5)); + assert!(half.iter().take(4).all(|v| (v - 0.5).abs() < 0.01) && half[4] == 1.0, "{half:?}"); + + let lab8 = PixelFormat::new(ColorMode::Lab, SampleType::U8, true); + let lab16 = PixelFormat::new(ColorMode::Lab, SampleType::U16, true); + let px = f([77.0, 128.0, 200.0, 255.0]); + assert_levels(&adjusted_pixel(lab8, &px, &Adjustment::Invert, None), &[178.0, 127.0, 55.0, 255.0], "Lab 8-bit invert"); + assert_levels(&adjusted_pixel(lab16, &px, &Adjustment::Invert, None), &[178.0, 128.0, 56.0, 255.0], "Lab 16-bit invert"); + // A neutral grey posterizes warm: a and b step to +42. + let grey = f([128.0, 128.0, 128.0, 255.0]); + assert_levels(&adjusted_pixel(lab8, &grey, &Adjustment::Posterize { levels: 4 }, None), &[170.0, 170.0, 170.0, 255.0], "Lab posterize"); + // Threshold compares L alone and keeps black and white neutral. + let level = Adjustment::Threshold { level: 128.0 / 255.0 }; + assert_levels(&adjusted_pixel(lab8, &f([128.0, 30.0, 220.0, 255.0]), &level, None), &[255.0, 128.0, 128.0, 255.0], "Lab threshold light"); + assert_levels(&adjusted_pixel(lab16, &f([127.0, 30.0, 220.0, 255.0]), &level, None), &[0.0, 128.0, 128.0, 255.0], "Lab threshold dark"); +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3a8b7ce695..378f71c18e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -55,10 +55,10 @@ choice!(TypeUnit { Points = "points", Pixels = "pixels", Millimeters = "mm" } de choice!(PointSize { PostScript = "postScript", Traditional = "traditional" } default PostScript); choice!(Interpolation { BicubicAutomatic = "bicubicAutomatic", Nearest = "nearestNeighbor", Bilinear = "bilinear", Bicubic = "bicubic", BicubicSmoother = "bicubicSmoother", BicubicSharper = "bicubicSharper", PreserveDetails = "preserveDetails" } default BicubicAutomatic); choice!(ColorPicker { Adobe = "adobe", System = "system" } default Adobe); -choice!(Theme { Pro = "pro", ProMedium = "proMedium", ProLightGray = "proLightGray", ProLight = "proLight", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark" } default ProMedium); +choice!(Theme { Pro = "pro", ProMedium = "proMedium", ProLightGray = "proLightGray", ProLight = "proLight", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark", SoftDark = "softDark", SoftLight = "softLight" } default ProMedium); choice!(AppearanceMode { Auto = "auto", Dark = "dark", Light = "light" } default Dark); -choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark" } default ProMedium); -choice!(LightTheme { StudioLight = "studioLight", ProLightGray = "proLightGray", ProLight = "proLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight" } default StudioLight); +choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark", SoftDark = "softDark" } default ProMedium); +choice!(LightTheme { StudioLight = "studioLight", ProLightGray = "proLightGray", ProLight = "proLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight", SoftLight = "softLight" } default StudioLight); choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default); choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow); choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto); @@ -1509,9 +1509,8 @@ impl Session { st.history.trim(&st.doc); } photocraft_compose::set_effect_cache_budget(budget << 20); - if let Ok(mut engine) = photocraft_text::shared().lock() { - engine.fonts.set_missing_glyph_protection(self.prefs().type_.missing_glyph_protection()); - } + // Without waiting for the system font scan: this runs on the UI thread at launch. + photocraft_text::set_shared_missing_glyph_protection(self.prefs().type_.missing_glyph_protection()); crate::plugin_cmds::sync_prefs(self); } @@ -1542,8 +1541,12 @@ impl Session { && let Some(theme) = interface.get("theme").and_then(Value::as_str).and_then(Theme::parse) { let (mode, slot) = match theme { - Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark => ("dark", "darkTheme"), - Theme::StudioLight | Theme::ProLightGray | Theme::ProLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight => ("light", "lightTheme"), + Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark | Theme::SoftDark => { + ("dark", "darkTheme") + } + Theme::StudioLight | Theme::ProLightGray | Theme::ProLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight | Theme::SoftLight => { + ("light", "lightTheme") + } }; interface.insert("appearanceMode".into(), json!(mode)); interface.insert(slot.into(), json!(theme.name())); @@ -1666,6 +1669,14 @@ impl Session { next.interface.light_theme = LightTheme::BreezeLight; next.interface.appearance_mode = AppearanceMode::Light; } + Theme::SoftDark => { + next.interface.dark_theme = DarkTheme::SoftDark; + next.interface.appearance_mode = AppearanceMode::Dark; + } + Theme::SoftLight => { + next.interface.light_theme = LightTheme::SoftLight; + next.interface.appearance_mode = AppearanceMode::Light; + } } } self.prefs.edit(|p| *p = next); diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 02138b637f..4186c167ed 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -44,7 +44,7 @@ use std::io::Read; use std::sync::{Arc, Mutex, PoisonError}; use photocraft_paint::tile::PREVIEW_SIDE; -use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Pattern, StoredTile, TipShape}; +use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, TipShape}; use serde::{Deserialize, Serialize}; use crate::Session; @@ -333,6 +333,9 @@ struct StoredPreset { /// before #1851, which load with every preset directly in its group. #[serde(default, skip_serializing_if = "Vec::is_empty")] folder: Vec, + /// [`BrushPreset::captures`]; missing for presets saved without the New Brush Preset dialog. + #[serde(default, skip_serializing_if = "PresetCaptures::is_default")] + captures: PresetCaptures, } /// What a group file records about each tip it references, so loading needs only the preview. @@ -669,7 +672,14 @@ pub fn open(backend: Box) -> Opened { hashes.push(i.preview.clone()); } } - items.push(BrushPreset { name: sp.name, brush, builtin: false, group: g.group.clone(), folder: sanitize_folder(sp.folder) }); + items.push(BrushPreset { + name: sp.name, + brush, + builtin: false, + group: g.group.clone(), + folder: sanitize_folder(sp.folder), + captures: sp.captures, + }); } None => missing += 1, } @@ -1006,7 +1016,13 @@ impl PresetStore { } let key = |r: &Option| r.as_ref().map(|t| t.key.clone()); let tips = TipRefs { tip: key(&refs[0]), dual_tip: key(&refs[1]), texture: key(&refs[2]) }; - stored.push(StoredPreset { name: p.name.clone(), brush: settings_only(&mut p.brush), tips, folder: p.folder.clone() }); + stored.push(StoredPreset { + name: p.name.clone(), + brush: settings_only(&mut p.brush), + tips, + folder: p.folder.clone(), + captures: p.captures.clone(), + }); tiles.push(refs); } let gf = GroupFile { format: GROUP_FORMAT.into(), version: 1, group: group.to_string(), presets: stored, tips: infos }; diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index 513bc56c15..860ebd4fc3 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -45,7 +45,7 @@ fn tip16(w: u32, h: u32, seed: u32) -> GrayTile { fn sampled(name: &str, group: &str, tip: GrayTile) -> BrushPreset { let brush = BrushSettings { size: 42.0, spacing: 0.1, tip: TipShape::Sampled(tip), ..Default::default() }; - BrushPreset { name: name.into(), brush, builtin: false, group: group.into(), folder: Vec::new() } + BrushPreset { name: name.into(), brush, builtin: false, group: group.into(), folder: Vec::new(), captures: Default::default() } } fn user(s: &Session) -> Vec { @@ -168,6 +168,22 @@ fn commands_persist_and_delete_removes_from_disk() { assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_none()); } +/// The New Brush Preset dialog's boxes (#4490) are saved with the preset and come back with it; +/// a preset saved without them reloads with the default captures and writes no field for them. +#[test] +fn preset_captures_persist() { + let dir = TempDir::new("captures"); + let (mut s, _) = session(&dir); + s.execute("brush.presets.save", json!({"name": "Bare", "captureSize": false, "toolSettings": false, "color": true})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Plain"})).unwrap(); + let (t, _) = session(&dir); + let find = |n: &str| photocraft_paint::presets::find(&t.tools.presets, n).expect("reloads").captures.clone(); + assert_eq!(find("Bare"), PresetCaptures { size: false, tool_settings: false, color: true }); + assert!(find("Plain").is_default()); + let written: String = dir.files().iter().filter(|f| !f.starts_with("tips/")).map(|f| std::fs::read_to_string(dir.0.join(f)).unwrap_or_default()).collect(); + assert_eq!(written.matches("\"captures\"").count(), 1, "only the preset with boxes records them"); +} + #[test] fn imported_abr_group_persists() { let dir = TempDir::new("abr"); diff --git a/crates/engine/src/presets/swatches.rs b/crates/engine/src/presets/swatches.rs index f46aad32da..eb666e1ec5 100644 --- a/crates/engine/src/presets/swatches.rs +++ b/crates/engine/src/presets/swatches.rs @@ -166,7 +166,14 @@ impl SwatchColor { .and_then(Value::as_array) .ok_or("a colour needs `values`")? .iter() - .map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f as f32).ok_or("colour values must be finite numbers")) + // Beyond f32 range a value turns into inf as f32 (an HSB hue then wraps to NaN, which + // the prefs file can't store), so reject it here. + .map(|x| { + x.as_f64() + .filter(|f| f.is_finite() && f.abs() <= f64::from(f32::MAX)) + .map(|f| f as f32) + .ok_or("colour values must be finite numbers within f32 range") + }) .collect::>()?; let need = |n: usize| if vals.len() == n { Ok(()) } else { Err(format!("a {model} colour has {n} values, got {}", vals.len())) }; let pct = |v: f32| (v / 100.0).clamp(0.0, 1.0); @@ -281,7 +288,9 @@ pub fn hex(c: [f32; 3]) -> String { // ------------------------------------------------------------------ defaults -/// The default library: 40 colours in four groups (our own palette). +/// The default library: 46 colours in five groups. "RGB" holds the exact primaries and +/// secondaries (#FF0000, #00FF00, ...) as Photoshop's RGB group does; the other four are our own +/// palette, whose "Pure" hues are vivid but not the pure channel values (#4302). pub fn builtin() -> Vec> { let rgb = |n: &str, c: [u8; 3]| Swatch::new(n, SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0))); let grays = [ @@ -326,8 +335,19 @@ pub fn builtin() -> Vec> { let row = |prefix: &str, cs: [[u8; 3]; 10]| -> Vec { hues.iter().zip(cs).map(|(h, c)| rgb(&if prefix.is_empty() { (*h).to_string() } else { format!("{prefix} {h}") }, c)).collect() }; + // Right after Grays, near the top of the panel (Grays stays first: new swatches land there). + // Photoshop's order: red, yellow, green, cyan, blue, magenta. + let exact: [(&str, [u8; 3]); 6] = [ + ("RGB Red", [255, 0, 0]), + ("RGB Yellow", [255, 255, 0]), + ("RGB Green", [0, 255, 0]), + ("RGB Cyan", [0, 255, 255]), + ("RGB Blue", [0, 0, 255]), + ("RGB Magenta", [255, 0, 255]), + ]; vec![ Group::new("Grays", grays.iter().map(|(n, v)| rgb(n, [*v; 3])).collect()), + Group::new("RGB", exact.iter().map(|(n, c)| rgb(n, *c)).collect()), Group::new("Pastel", row("Pastel", pastel)), Group::new("Pure", row("", pure)), Group::new("Dark", row("Dark", dark)), @@ -339,15 +359,27 @@ mod tests { use super::*; #[test] - fn default_library_has_the_forty_colours() { + fn default_library_has_the_forty_six_colours() { let b = builtin(); - assert_eq!(b.iter().map(|g| g.items.len()).sum::(), 40); + assert_eq!(b.iter().map(|g| g.items.len()).sum::(), 46); let names: Vec<&str> = b.iter().flat_map(|g| g.items.iter().map(|s| s.name.as_str())).collect(); let mut uniq = names.clone(); uniq.sort_unstable(); uniq.dedup(); - assert_eq!(uniq.len(), 40, "names are unique: {names:?}"); - assert_eq!(b[2].items[0].color, SwatchColor::Rgb([230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0])); + assert_eq!(uniq.len(), 46, "names are unique: {names:?}"); + assert_eq!(b[3].name, "Pure"); + assert_eq!(b[3].items[0].color, SwatchColor::Rgb([230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0])); + } + + #[test] + fn the_rgb_group_holds_exact_primaries_and_secondaries() { + // #4302: picking green should give #00FF00, as in Photoshop's RGB group. + let b = builtin(); + let g = &b[1]; + assert_eq!(g.name, "RGB"); + let conv = SwatchConv::builtin(); + let hexes: Vec = g.items.iter().map(|s| hex(conv.rgb(&s.color))).collect(); + assert_eq!(hexes, ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff"]); } #[test] @@ -403,6 +435,8 @@ mod tests { json!({"model": "rgb", "values": ["a", 2, 3]}), json!({"model": "xyz", "values": [1, 2, 3]}), json!({"model": "gray", "values": []}), + json!({"model": "hsb", "values": [1e39, 100, 100]}), + json!({"model": "rgb", "values": [0, -1e39, 0]}), ] { assert!(SwatchColor::from_param(&bad).is_err(), "{bad}"); } diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 96dfa77fd5..73324711d9 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -409,6 +409,13 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let mut sent = false; let mut spool = Value::Null; if send && !dry { + // The last line of defence: a test or fuzz run must never print a page (`dryRun` still + // renders and reports the command). + if printing_disabled() { + return Err(other( + "printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true", + )); + } spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } @@ -429,6 +436,29 @@ fn remember_print(s: &mut Session, p: &Value) { s.file_menu.last_print = Some(remembered); } +/// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer. +static PRINTING_DISABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Refuse to spool anything from this process from now on (tests, fuzzing). The +/// `PHOTOCRAFT_NO_PRINT` environment variable does the same for a whole shell (CI). +pub fn disable_printing() { + PRINTING_DISABLED.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// Whether spooling is blocked by [`disable_printing`] or `PHOTOCRAFT_NO_PRINT`. +pub fn printing_disabled() -> bool { + PRINTING_DISABLED.load(std::sync::atomic::Ordering::Relaxed) || std::env::var_os("PHOTOCRAFT_NO_PRINT").is_some_and(|v| !v.is_empty() && v != "0") +} + +/// The printers the system spooler knows (Print's Printer menu): CUPS queues and driverless +/// destinations on macOS and Linux, `Win32_Printer` on Windows, none in the browser. +fn printers(_: &mut Session, _: &Value) -> Result { + let list = craft_print::printers(); + let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); + let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); + Ok(json!({"printers": printers, "default": default})) +} + fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } @@ -600,6 +630,16 @@ pub fn specs() -> Vec { 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), + CommandSpec { + id: "file.printers", + label: "Printers", + menu: &[], + shortcut: None, + params: r##"{} → {printers:[{name, default}], default:name|null} (the system spooler's printers; a name is Print's "printer")"##, + enabled: crate::commands::always, + journal: false, + run: printers, + }, spec!( "file.printOneCopy", "Print One Copy", diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 988f11635a..bf5f1e3857 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -345,8 +345,38 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { .args(["--exact", "print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"]) .env(CHILD, dir.path()) .env("PATH", dir.path()) + // The child spools into the fake `lp` above, so a shell that blocks real printing + // (`PHOTOCRAFT_NO_PRINT`, see `disabled_printing_never_spools`) must not stop it. + .env_remove("PHOTOCRAFT_NO_PRINT") .output() .unwrap(); assert!(result.status.success(), "{}\n{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"), "child must select the transport test"); } + +#[test] +fn disabled_printing_never_spools() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + super::disable_printing(); + assert!(super::printing_disabled()); + let e = s.execute("file.print", json!({})).unwrap_err().to_string(); + assert!(e.contains("printing is disabled"), "{e}"); + assert!(s.execute("file.printOneCopy", json!({})).is_err()); + // A dry run still renders and reports the command it would have run. + let r = s.execute("file.print", json!({"dryRun": true})).unwrap(); + assert_eq!(r["sent"], false); + assert!(r["command"].is_array(), "{r}"); +} + +#[test] +fn printers_lists_the_spooler_destinations_without_a_document() { + let mut s = Session::new(); + let r = s.execute("file.printers", json!({})).unwrap(); + let list = r["printers"].as_array().unwrap(); + assert!(list.iter().all(|p| p["name"].is_string() && p["default"].is_boolean())); + match r["default"].as_str() { + Some(d) => assert!(list.iter().any(|p| p["name"] == d && p["default"] == true)), + None => assert!(list.iter().all(|p| p["default"] == false)), + } +} diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 833e2db62e..c25608bb2e 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -27,6 +27,7 @@ mod content_aware_move; mod patch; mod pattern_stamp; mod remove; +pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview}; pub use patch::preview as patch_preview; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -145,7 +146,7 @@ fn damage_json(r: Rect) -> Value { /// Which pixels Clone Stamp / Healing Brush sample. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SampleLayers { +pub(crate) enum SampleLayers { Current, CurrentAndBelow, All, @@ -162,7 +163,7 @@ fn sample_layers(p: &Value, cmd: &str) -> Result { /// Composite pixels of `rect` in `fmt` (native channels): the whole document, or the target layer /// and everything below it. -fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { +pub(crate) fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { let mut doc = pre.clone(); if which == SampleLayers::CurrentAndBelow && let Some(id) = id @@ -297,15 +298,27 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); + // The live stroke the canvas already drew for exactly this commit: take its pixels instead of + // rendering the stroke again (the hitch on release). + let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); + let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let (bounds, cov) = stroke_coverage(&stroke); // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; - Ok(clone_result(dmg, off, aligned, p, &stroke)) + let mut r = clone_result(dmg, off, aligned, p, &stroke); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) } /// What a live retouching stroke paints through its coverage. @@ -337,6 +350,11 @@ pub struct LiveRetouch { /// Where the doc shows what finishing the stroke now would add (a lone first dab, the /// smoothing catch-up), redrawn on every step as the Brush's live stroke does. tail: Rect, + /// The document and revision the stroke started on. + base: (photocraft_doc::DocId, u64), + brush: Box, + /// Every point rendered so far, in order. + points: Vec, } impl LiveRetouch { @@ -358,7 +376,8 @@ impl LiveRetouch { "paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?), other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush and History Brush")), }; - let pre = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let st = s.active().ok_or(EngineError::NoDocument)?; + let (pre, base) = (st.doc.clone(), (st.doc.id, st.revision)); let mut doc = (*pre).clone(); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; @@ -366,6 +385,7 @@ impl LiveRetouch { // As `stroke_coverage`, which the commit uses. let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); let opacity = stroke.brush.opacity; + let brush = Box::new(stroke.brush.clone()); let paint = match paint { LivePaint::History(src) if src.format() != pre_surf.format() => LivePaint::History(src.convert(pre_surf.format())), other => other, @@ -384,6 +404,9 @@ impl LiveRetouch { sel, lock, tail: Rect::EMPTY, + base, + brush, + points: Vec::new(), }; live.push(&stroke.points)?; Ok(live) @@ -394,15 +417,46 @@ impl LiveRetouch { self.renderer.bounds().union(&self.tail) } + /// Every point rendered so far, in order. + pub fn points(&self) -> &[StrokePoint] { + &self.points + } + + /// The finished stroke as its commit would take it over ([`Session::offer_live_retouch`]). + /// Only the Clone Stamp and History Brush draw what they commit (the Healing Brush blends on + /// release), and only while the brush, layer and clone source are still the ones the stroke + /// started with: the document revision doesn't cover those. + pub(crate) fn into_commit(mut self, s: &Session) -> Result { + let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?; + let source_unchanged = match &self.paint { + LivePaint::Clone { map, .. } if self.cmd == "paint.cloneStamp" => { + let f = stroke.points.first().ok_or_else(|| bad(&self.cmd, "`points` is empty"))?; + crate::presets::clone_source::resolve(s, &self.params, (f.x, f.y), &self.cmd)?.0 == *map + } + LivePaint::History(_) if self.cmd == "paint.historyBrush" => true, + _ => return Err(bad(&self.cmd, "only Clone Stamp and History Brush strokes commit their live pixels")), + }; + if !source_unchanged || *self.brush != stroke.brush || id != self.id { + return Err(bad(&self.cmd, "the brush, layer or clone source changed during the stroke")); + } + let bounds = self.bounds(); + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + let surface = surf.clone(); + Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds }) + } + /// Render more points; returns the rectangle that changed. Each changed area is redrawn from /// the pre-stroke pixels with the stroke's coverage so far, so overlapping dabs build up as in /// the commit, and pixels painted earlier in the stroke are never re-cloned. What finishing /// the stroke now would add is drawn too (a lone first dab shows on the press), and redrawn on /// every step. /// Invalid coordinates reject the whole batch without changing the preview. + /// Only the position and pressure of `pts` are used: the commands take no tilt or times. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { crate::brush_cmds::check_coords(pts, &self.cmd)?; - self.renderer.push(pts); + let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); + self.points.extend_from_slice(&pts); + self.renderer.push(&pts); let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let mut dmg = Rect::EMPTY; if !old.is_empty() { @@ -519,15 +573,25 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.historyBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; let mode = blend_param(p, CMD)?; + let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); + let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; let (bounds, cov) = stroke_coverage(&stroke); let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; - Ok(json!({ "damage": damage_json(dmg) })) + let mut r = json!({ "damage": damage_json(dmg) }); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) } // --------------------------------------------------------------------------------------------- diff --git a/crates/engine/src/retouch_cmds/content_aware_move.rs b/crates/engine/src/retouch_cmds/content_aware_move.rs index 120c8b7c02..ddca9c6e0f 100644 --- a/crates/engine/src/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/retouch_cmds/content_aware_move.rs @@ -96,6 +96,8 @@ struct Plan { mode: Mode, structure: u8, color: u8, + /// Most pixels the fills may sample around the content ([`MAX_MARGIN`], less for a preview). + margin: i32, } /// Transform On Drop's `scaleX` / `scaleY` (%) and `rotate` (degrees): the map from a source point @@ -193,7 +195,7 @@ fn landed_mask(cov: &[f32], area: Rect, inv: &Homography, rect: Rect) -> Vec Result { +fn plan(s: &Session, p: &Value, margin: i32) -> Result { let mode = match string(p, "mode", "move") { "move" => Mode::Move, "extend" => Mode::Extend, @@ -242,7 +244,7 @@ fn plan(s: &Session, p: &Value) -> Result { } }; let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color }) + Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin }) } /// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content @@ -293,7 +295,12 @@ fn erode(w: usize, h: usize, m: &[bool], r: usize) -> Vec { /// Where a fill samples from around `r`: Photoshop's Content-Aware window /// ([`crate::fill_cmds::sampling_window`]), at most [`MAX_MARGIN`] pixels around `r`. pub(super) fn window(r: Rect, canvas: Rect) -> Rect { - crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(MAX_MARGIN)) + window_within(r, canvas, MAX_MARGIN) +} + +/// [`window`] with at most `margin` pixels around `r`. +fn window_within(r: Rect, canvas: Rect, margin: i32) -> Rect { + crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(margin)) } /// The pixels a step works on: the targeted surface, or with Sample All Layers the visible @@ -433,7 +440,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra // 1. The content at its new place: the content with its original surroundings laid over the // window there; never sampling the content's old place. - let win = window(plan.dest, plan.canvas); + let win = window_within(plan.dest, plan.canvas, plan.margin); let (content, moved) = match &inv { None => { let mut content = read(doc, plan.target, p, win.translate(-dx, -dy))?; @@ -443,7 +450,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra Some(inv) => { // Only the content and a ring around it (the colour fit's reference) matter: read // the source behind that, a few pixels past the area, and resample it. - let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(MAX_MARGIN)).intersect(&plan.canvas); + let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(plan.margin)).intersect(&plan.canvas); let src = if src.is_empty() { plan.area } else { src }; (resample(&read(doc, plan.target, p, src)?, inv, win), landed_mask(&cov, plan.area, inv, win)) } @@ -454,7 +461,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra // 2. Move: fill the place the content left (where the content didn't land on it). if plan.mode == Mode::Move { ctx.check()?; - let win = window(plan.area, plan.canvas); + let win = window_within(plan.area, plan.canvas, plan.margin); let (w, h) = (win.width() as usize, win.height() as usize); let img = read(doc, plan.target, p, win)?; let landed = match &inv { @@ -478,7 +485,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra } pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { - let plan = plan(s, p)?; + let plan = plan(s, p, MAX_MARGIN)?; let label = if plan.mode == Mode::Move { "Content-Aware Move" } else { "Content-Aware Extend" }; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); @@ -520,3 +527,40 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { }, ) } + +/// Largest selection (bounding box, in pixels) the live preview computes. +const PREVIEW_MAX_PIXELS: u64 = 256 * 256; +/// Sampling margin of a preview: a fraction of the command's, so a preview is a small solve. +const PREVIEW_MARGIN: i32 = 48; + +/// A live preview of `paint.contentAwareMove` that can run on any thread: the document as of +/// [`preview`], with the move's params. Cheaper than the command (a small sampling margin, and +/// only small selections), so the result is close to, not the same as, what the command makes. +pub struct ContentAwarePreview { + doc: std::sync::Arc, + plan: Plan, + params: Value, +} + +impl ContentAwarePreview { + /// The active document with the move applied (nothing is recorded, the session is not + /// touched) and the rectangle it changed. Stops with `Cancelled` once `ctx` is cancelled. + pub fn run(&self, ctx: &crate::jobs::JobCtx) -> Result<(Document, Rect)> { + let mut work = (*self.doc).clone(); + let sel = self.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?; + let (_, damage, _) = run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?; + Ok((work, damage)) + } +} + +/// Prepare the preview of `paint.contentAwareMove` with params `p` on the active document. Errors +/// like the command does, and for a selection larger than a preview takes. +pub fn preview(s: &Session, p: &Value) -> Result { + let plan = plan(s, p, PREVIEW_MARGIN)?; + let pixels = u64::from(plan.area.width()) * u64::from(plan.area.height()); + if pixels > PREVIEW_MAX_PIXELS { + return Err(bad(CMD, format!("the selection is too large to preview ({pixels} pixels, at most {PREVIEW_MAX_PIXELS})"))); + } + let d = s.active().ok_or(EngineError::NoDocument)?; + Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone() }) +} diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index a0fac91b2a..5febba9c5a 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -917,6 +917,31 @@ fn content_aware_move_moves_the_object_and_fills_its_place_at_all_depths() { } } +#[test] +fn content_aware_move_preview_shows_the_result_without_touching_the_session() { + use crate::jobs::JobCtx; + let mut s = session(128, 64, 8, "rgb"); + paint_layer(&mut s, with_object(20, 26)); + s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap(); + let before = s.active().unwrap().revision; + let p = json!({"offset": [70, 0]}); + let (doc, damage) = content_aware_move_preview(&s, &p).unwrap().run(&JobCtx::new()).unwrap(); + let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y); + assert!(is_object(px(&doc, 95, 31)) && !is_object(px(&doc, 25, 31)), "the preview shows the move"); + assert!(damage.contains(95, 31) && damage.contains(25, 31), "{damage:?}"); + // The session is untouched. + assert!(is_object(rgba(&s, 25, 31)) && !is_object(rgba(&s, 95, 31))); + assert_eq!(s.active().unwrap().revision, before); + // A cancelled preview stops; a selection past the preview's size and bad params are errors. + let ctx = JobCtx::new(); + ctx.cancel(); + assert!(matches!(content_aware_move_preview(&s, &p).unwrap().run(&ctx), Err(EngineError::Cancelled))); + assert!(content_aware_move_preview(&s, &json!({"offset": [0, 0]})).is_err()); + assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "rotate": "x"})).is_err()); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap(); + assert!(content_aware_move_preview(&s, &json!({"offset": [1, 0]})).is_err()); +} + #[test] fn content_aware_move_transform_on_drop_scales_and_rotates_the_content() { // A horizontal 16×6 bar in a 36×36 selection, dropped 80 px right and turned 90° with 125% @@ -1397,14 +1422,18 @@ fn live_dodge_matches_the_commit_across_cells() { for x in 0..200 { let (got, want) = (a.rgba(x, y), b.rgba(x, y)); changed |= (want[0] - texture(x, y)[0]).abs() > 0.02; - // The commit also lays the stroke's last dab, which the live stroke shows on release. - if (x - 150).pow(2) + (y - 70).pow(2) > 25 * 25 { + // The commit also lays the stroke's last dab, which the live stroke shows on release + // (a soft tip reaches past its 20 px radius). + let reach = photocraft_paint::tip_reach(20.0, 0.3).ceil() as i32 + 2; + if (x - 150).pow(2) + (y - 70).pow(2) > reach * reach { worst = (0..4).map(|c| (got[c] - want[c]).abs()).fold(worst, f32::max); } } } assert!(changed); - assert!(worst <= 1e-4, "live and commit differ by {worst}"); + // A soft tip's footprint spans more live cells, so sums land in a different order: within + // 2e-4 (a twentieth of an 8-bit level). + assert!(worst <= 2e-4, "live and commit differ by {worst}"); } #[test] @@ -1640,3 +1669,125 @@ fn remove_to_a_new_layer_leaves_the_target_untouched() { assert!(near(d.doc.layer(nid).unwrap().surface().unwrap().rgba(46, 30), bg, 0.03)); assert!(near(d.doc.layer(target).unwrap().surface().unwrap().rgba(46, 30), square, 0.001)); } + +/// A Clone Stamp or History Brush stroke drawn live and offered to its commit: the commit takes +/// the live pixels, and they are what running the command afresh paints, bit for bit, at every +/// depth, with and without a feathered selection. +#[test] +fn a_live_clone_or_history_stroke_commits_its_own_pixels() { + let all = [[30.0, 30.0], [50.0, 34.0], [70.0, 28.0], [90.0, 40.0], [60.0, 50.0], [35.0, 44.0], [36.0, 45.0]]; + let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); + for depth in DEPTHS { + for (cmd, extra) in [("paint.cloneStamp", json!({"source": [20, 15], "mode": "multiply"})), ("paint.historyBrush", json!({"state": 1}))] { + for selected in [false, true] { + let setup = || { + let mut s = session(120, 70, depth, "rgb"); + paint_layer(&mut s, texture); + s.execute("paint.stroke", json!({"points": [[0, 40], [120, 40]], "size": 20, "hardness": 1.0, "color": "#c03030"})).unwrap(); + if selected { + s.execute("select.rect", json!({"x": 40, "y": 20, "width": 50, "height": 40, "ellipse": true, "feather": 3})).unwrap(); + } + s + }; + let params = |v: &[[f64; 2]]| { + let mut p = json!({"points": v, "size": 14, "hardness": 40, "opacity": 70}); + for (k, x) in extra.as_object().unwrap() { + p[k] = x.clone(); + } + p + }; + let mut s = setup(); + let mut live = LiveRetouch::begin(&s, cmd, ¶ms(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..4])).unwrap(); + live.push(&pts(&all[4..])).unwrap(); + s.offer_live_retouch(live).unwrap(); + let r = s.execute(cmd, params(&all)).unwrap(); + s.clear_live_stroke(); + assert_eq!(r["fromLive"], true, "{cmd} {depth}-bit selected={selected}"); + let mut fresh = setup(); + let r = fresh.execute(cmd, params(&all)).unwrap(); + assert!(r.get("fromLive").is_none()); + let (a, b) = (s.active().unwrap(), fresh.active().unwrap()); + let bounds = a.doc.bounds(); + let got = a.doc.layer(a.active_layer.unwrap()).unwrap().surface().unwrap().read_region(bounds); + let want = b.doc.layer(b.active_layer.unwrap()).unwrap().surface().unwrap().read_region(bounds); + let diff = got.iter().zip(&want).position(|(x, y)| x.to_bits() != y.to_bits()); + assert!(diff.is_none(), "{cmd} {depth}-bit selected={selected}: first difference at value {diff:?}"); + assert_eq!(a.history.past_len(), b.history.past_len()); + } + } + } +} + +/// An offered live stroke is taken only by the commit it was drawn for, and only once. +#[test] +fn a_live_retouch_offer_is_taken_only_by_its_own_commit() { + let mut s = session(96, 64, 8, "rgb"); + paint_layer(&mut s, texture); + let all = [[50.0, 30.0], [60.0, 34.0], [70.0, 30.0]]; + let params = |v: &[[f64; 2]], opacity: u32| json!({"points": v, "source": [20, 20], "size": 12, "hardness": 50, "opacity": opacity}); + let offer = |s: &mut Session| { + let live = LiveRetouch::begin(s, "paint.cloneStamp", ¶ms(&all, 70)).unwrap(); + s.offer_live_retouch(live).unwrap(); + }; + let from_live = |r: Value| r.get("fromLive").is_some(); + // Other points, other params, another command: rendered afresh. + offer(&mut s); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all[..2], 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 60)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + let mut history = params(&all, 70); + history.as_object_mut().unwrap().remove("source"); + assert!(!from_live(s.execute("paint.historyBrush", history).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + // The document changed after the stroke started. + offer(&mut s); + s.execute("paint.stroke", json!({"points": [[5, 5], [9, 9]], "size": 4})).unwrap(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + // A withdrawn offer, and an offer used once. + offer(&mut s); + s.clear_live_stroke(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + assert!(from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + // The Healing Brush blends on release: it has no live pixels to offer. + let healing = LiveRetouch::begin(&s, "paint.healingBrush", ¶ms(&all, 70)).unwrap(); + assert!(s.offer_live_retouch(healing).is_err()); + // The brush changed during the stroke (the document revision doesn't show it). + let live = LiveRetouch::begin(&s, "paint.cloneStamp", ¶ms(&all, 70)).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"spacing": 0.5}})).unwrap(); + assert!(s.offer_live_retouch(live).is_err()); +} + +/// The live stroke renders only the position and pressure of its points, as its commit parses +/// them: a pen's tilt and the times don't change what it shows. +#[test] +fn a_live_retouch_stroke_ignores_tilt_and_times() { + let mut s = session(64, 48, 8, "rgb"); + paint_layer(&mut s, texture); + let p = json!({"points": [[20, 20]], "source": [8, 8], "size": 10, "hardness": 50}); + let mut plain = LiveRetouch::begin(&s, "paint.cloneStamp", &p).unwrap(); + let mut pen = LiveRetouch::begin(&s, "paint.cloneStamp", &p).unwrap(); + let pts = [[30.0, 24.0], [44.0, 30.0]]; + plain.push(&pts.map(|q| StrokePoint::new(q[0], q[1], 0.8))).unwrap(); + pen.push(&pts.map(|q| { + let mut sp = StrokePoint::new(q[0], q[1], 0.8); + (sp.tilt_x, sp.tilt_y, sp.rotation, sp.time) = (40.0, -20.0, 90.0, 120.0); + sp + })) + .unwrap(); + assert_eq!(plain.points(), pen.points()); + let id = s.active().unwrap().active_layer.unwrap(); + let b = s.active().unwrap().doc.bounds(); + let read = |l: &LiveRetouch| l.doc.layer(id).unwrap().surface().unwrap().read_region(b); + assert_eq!(read(&plain), read(&pen)); +} diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 38c355071c..472ecb672a 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -24,8 +24,11 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. +/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. 32-bit samples can +/// lie outside 0..1, so each channel is clamped first (NaN reads as 0), like the RGB readout. pub fn device_cmyk(r: f32, g: f32, b: f32) -> [i32; 4] { + let unit = |v: f32| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; + let (r, g, b) = (unit(r), unit(g), unit(b)); let k = 1.0 - r.max(g).max(b); let f = |v: f32| if k >= 1.0 { 0.0 } else { (1.0 - v - k) / (1.0 - k) }; [f(r), f(g), f(b), k].map(|v| (v * 100.0).round() as i32) @@ -418,6 +421,36 @@ mod tests { assert_eq!(device_cmyk(0.5, 0.5, 0.5), [0, 0, 0, 50]); } + /// 32-bit samples can lie outside 0..1 (or be NaN); the CMYK readout clamps them like the RGB + /// readout does instead of reporting K:-50% or C:120% (#4191). + #[test] + fn device_cmyk_clamps_out_of_range_and_nan_samples() { + assert_eq!(device_cmyk(1.5, 1.5, 1.5), [0, 0, 0, 0]); + assert_eq!(device_cmyk(2.0, 0.0, 0.0), [0, 100, 100, 0]); + let in_range = |c: [i32; 4]| c.iter().all(|v| (0..=100).contains(v)); + for (r, g, b) in [(-0.2, 0.5, 0.5), (-1.0, -1.0, -1.0), (f32::NAN, 0.5, 0.5), (f32::NAN, f32::NAN, f32::NAN), (f32::INFINITY, f32::NEG_INFINITY, 0.5)] { + let c = device_cmyk(r, g, b); + assert!(in_range(c), "{r} {g} {b} -> {c:?}"); + } + assert_eq!(device_cmyk(f32::NAN, f32::NAN, f32::NAN), [0, 0, 0, 100]); + } + + #[test] + fn hdr_pixel_on_a_32_bit_document_reads_in_range() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 32, "background": "white"})).unwrap(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let hdr = photocraft_raster::from_rgba(&surf.format(), [2.0, 2.0, 2.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &hdr); + Ok(()) + }) + .unwrap(); + let r = s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + assert_eq!(r["point"]["rgb"], json!([255, 255, 255])); + assert_eq!(r["point"]["cmyk"], json!([0, 0, 0, 0])); + } + #[test] fn points_work_at_every_depth_and_mode() { for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 8), ("cmyk", 8), ("lab", 8)] { diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index e2c9e70591..5feae1a75f 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -195,6 +195,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { } let selected = s.edit("Transform Selection", |doc, _| { if let Some(sel) = &doc.selection { + crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection")?; doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty()); } Ok(doc.selection.is_some()) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e632251982..e108c4d7be 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -387,6 +387,20 @@ fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result }) } +/// The box of a smart object's source in source pixels (what its warp's `us`/`vs` span), or `None` +/// when the source can't be read. +pub(crate) fn source_box(doc: &Document, sm: &SmartObject) -> Option<[f64; 4]> { + let (name, bytes) = source_bytes(&doc.metadata, &sm.source)?; + let fmt = doc.pixel_format(); + let img = match sm.stack_mode { + Some(mode) => stack_image(&name, &bytes, fmt, mode), + None => source_image(&name, &bytes, fmt), + } + .ok()?; + let r = img.bounds; + Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]) +} + /// Renders a smart object from its source. `Ok(None)` when the source is unavailable (a missing /// linked file, or a PSD placed layer without embedded data): callers keep the existing cache. pub fn render(doc: &Document, sm: &SmartObject) -> Result> { @@ -1055,6 +1069,73 @@ fn move_filter(s: &mut Session, p: &Value) -> Result { }) } +/// Dragging smart filters onto another smart object in the Layers panel (#4271): they move there, +/// or are copied with `copy` (⌥-drag in Photoshop). One filter (`filter`) lands on top of the +/// target's filters; all of them (the Smart Filters row) are added on top too, with the filter +/// mask when the target has none. Both smart objects are re-rendered in one undo step. +fn transfer_filters(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.smartFilter.transfer"; + let id_of = |key: &str| p.get(key).and_then(Value::as_u64).map(LayerId).ok_or_else(|| bad(CMD, format!("`{key}` must be a layer id"))); + let (from, to) = (id_of("from")?, id_of("to")?); + if from == to { + return Err(bad(CMD, "the smart filters are already on that layer")); + } + // The other `layer.smartFilter.*` commands name a filter `index`; here a guessed `index` + // would otherwise be dropped and every filter moved. + if p.get("index").is_some() { + return Err(bad(CMD, "name the filter with `filter` (no `filter`: all of them)")); + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let src = smart(doc, from)?; + smart(doc, to).map_err(|_| bad(CMD, "smart filters can only be dropped on a smart object"))?; + let n = src.smart_filters.len(); + if n == 0 { + return Err(other("the smart object has no smart filters")); + } + let index = match p.get("filter") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().map(|i| i as usize).filter(|i| *i < n).ok_or_else(|| bad(CMD, format!("`filter` must be 0..{n} (0 = bottom filter)")))?), + }; + let (filters, mask) = match index { + Some(i) => (vec![src.smart_filters[i].clone()], None), + None => (src.smart_filters.clone(), src.filter_mask.clone()), + }; + let label = match (copy, index.is_some()) { + (true, true) => "Copy Smart Filter", + (true, false) => "Copy Smart Filters", + (false, true) => "Move Smart Filter", + (false, false) => "Move Smart Filters", + }; + crate::jobs::edit_job( + s, + label, + move |doc, _, ctx| { + let dst = smart_mut(doc, to)?; + dst.smart_filters.extend(filters); + if dst.filter_mask.is_none() { + dst.filter_mask = mask; + } + refresh_or_fail_in(doc, to, ctx)?; + if !copy { + let src = smart_mut(doc, from)?; + match index { + Some(i) => { + src.smart_filters.remove(i); + } + None => { + src.smart_filters.clear(); + src.filter_mask = None; + } + } + refresh_or_fail_in(doc, from, ctx)?; + } + Ok(()) + }, + move |()| json!({"from": from.0, "to": to.0}), + ) +} + // ---------- enablement ---------- fn active_smart(s: &Session) -> std::result::Result<&SmartObject, String> { @@ -1074,6 +1155,9 @@ fn has_linked(s: &Session) -> std::result::Result<(), String> { _ => Err("the smart object is embedded".into()), } } +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} fn has_smart_filters(s: &Session) -> std::result::Result<(), String> { if active_smart(s)?.smart_filters.is_empty() { Err("the smart object has no smart filters".into()) } else { Ok(()) } } @@ -1255,6 +1339,14 @@ pub fn specs() -> Vec { ), spec!("layer.smartFilter.delete", "Delete Smart Filter", &[], r##"{"layer":id?,"index":u32?}"##, has_smart_filters, delete_filter), spec!("layer.smartFilter.move", "Move Smart Filter", &[], r##"{"layer":id?,"index":u32?,"to":u32}"##, has_smart_filters, move_filter), + spec!( + "layer.smartFilter.transfer", + "Move or Copy Smart Filters", + &[], + r##"{"from":id,"to":id,"filter":u32? (default: all, with the filter mask),"copy":bool=false}"##, + has_doc, + transfer_filters + ), ] } diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 90bb325a2c..a6cdbf48d5 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -247,6 +247,69 @@ fn smart_filter_commands_check_an_explicit_layer_target() { assert_eq!(active(&s), curves); } +/// #4271: dragging smart filters onto another smart object moves them, ⌥-dragging copies them; +/// one filter or all of them (with the filter mask), each in one undo step. +#[test] +fn smart_filters_move_or_copy_to_another_smart_object() { + let mut s = session(8); + paint(&mut s); + let a = convert(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("filter.other.offset", json!({"horizontal": 5, "undefinedAreas": "transparent"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s); + let b = convert(&mut s); + let pixel = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let so = |s: &Session, id: u64| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("not smart: {}", other.kind_name()), + }; + let commands = |sm: &SmartObject| sm.smart_filters.iter().map(|f| f.command.clone()).collect::>(); + let (offset, blur) = ("filter.other.offset", "filter.blur.gaussianBlur"); + assert_eq!(commands(&so(&s, a)), [offset, blur]); + assert!(so(&s, a).filter_mask.is_some()); + let before = flat(&s); + + // ⌥-drag the blur: B gets a copy (re-rendered), A keeps it. + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 1, "copy": true})).unwrap(); + assert_eq!(commands(&so(&s, b)), [blur]); + assert_eq!(commands(&so(&s, a)), [offset, blur]); + assert!(so(&s, b).filter_mask.is_none(), "one filter comes without the mask"); + assert!(max_diff(&flat(&s), &before) > 1e-3, "B is blurred"); + s.undo(); + assert!(so(&s, b).smart_filters.is_empty()); + assert_eq!(flat(&s), before); + + // Drag the offset: it moves to the top of B's filters. + s.redo(); + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 0})).unwrap(); + assert_eq!(commands(&so(&s, a)), [blur]); + assert_eq!(commands(&so(&s, b)), [blur, offset]); + + // Drag the Smart Filters row: all of A's filters and its filter mask move to B. + let mask = so(&s, a).filter_mask; + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b})).unwrap(); + assert!(so(&s, a).smart_filters.is_empty() && so(&s, a).filter_mask.is_none()); + assert_eq!(commands(&so(&s, b)), [blur, offset, blur]); + assert_eq!(so(&s, b).filter_mask, mask); + s.undo(); + assert_eq!(so(&s, a).smart_filters.len(), 1); + assert_eq!(so(&s, b).smart_filters.len(), 2); + + // Refused: onto itself, onto a layer that isn't a smart object, a bad index, no filters. + let transfer = |s: &mut Session, p: Value| s.execute("layer.smartFilter.transfer", p); + assert!(transfer(&mut s, json!({"from": a, "to": a})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": pixel})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": b, "filter": 5})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": b, "index": 0})).is_err(), "`index` isn't taken for `filter`"); + assert!(transfer(&mut s, json!({"from": pixel, "to": b})).is_err()); + s.execute("layer.smartFilter.clearSmartFilters", json!({"layer": a})).unwrap(); + assert!(transfer(&mut s, json!({"from": a, "to": b})).is_err()); + assert_eq!(so(&s, b).smart_filters.len(), 2, "nothing changed"); +} + #[test] fn selection_becomes_the_filter_mask() { let mut s = session(8); @@ -622,6 +685,30 @@ fn warp_on_a_distorted_smart_object_follows_the_corners() { assert_eq!(after.perspective, before); } +/// A `mesh` whose points are exactly the placement leaves a sheared smart object unchanged: its +/// knots span the contents, not the bounding frame of the warp box seen from the contents (#4113). +#[test] +fn identity_mesh_on_a_skewed_smart_object_changes_nothing() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + let r = active_smart(&s).cache.unwrap().content_bounds(); + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + let quad = [[x0, y0], [x1, y0 + 10.0], [x1, y1 + 10.0], [x0, y1]]; + s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": quad})).unwrap(); + let before = flat(&s); + let sm = active_smart(&s); + let (h, b) = (placement(&sm), crate::smart_cmds::source_box(&s.active().unwrap().doc, &sm).unwrap()); + let place = |u: f64, v: f64| { + let (x, y) = h.apply(b[0] + u * (b[2] - b[0]), b[1] + v * (b[3] - b[1])); + [x, y] + }; + let mesh = photocraft_geom::warp::BezierMesh::fit(&place, vec![0.0, 1.0], vec![0.0, 0.25, 1.0]); + let rect = [x0, y0, x1, y1 + 10.0]; + s.execute("layer.smartObjects.warp", json!({"layer": id, "style": "custom", "rect": rect, "mesh": mesh})).unwrap(); + assert!(mean_diff(&before, &flat(&s)) < 0.01, "mean diff {}", mean_diff(&before, &flat(&s))); +} + fn convert_to_layers(s: &mut Session) -> Value { s.execute("layer.smartObjects.convertToLayers", json!({})).unwrap() } diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 6c15c6be56..b04aae7a84 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -130,8 +130,124 @@ fn rect_param(p: &Value) -> Option { Some(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32)) } +fn point_param(p: &Value) -> Option<(f32, f32)> { + let a = p.get("point")?.as_array()?; + let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + (x.is_finite() && y.is_finite()).then_some((x, y)) +} + +/// The layer [`with_doc_sampler`] samples for `layer` / `all_layers`: `None` = the composite. +pub fn sampled_target(doc: &Document, layer: Option, all_layers: bool) -> Option { + layer.filter(|id| !all_layers && doc.layer(*id).and_then(Layer::surface).is_some()) +} + +/// Object Finder (#4087): the object Object Selection would select at document point `(x, y)`, +/// sampling `layer` (or the composite). Pure, so the UI can run it on a worker thread while the +/// pointer hovers; `select.object` with `"point"` commits the same result. +pub fn find_object(doc: &Document, layer: Option, all_layers: bool, x: f32, y: f32) -> Option { + with_doc_sampler(doc, layer, all_layers, |smp, doc| grabcut::object_at(smp, doc.bounds(), x, y, grabcut::OBJECT_AT_WORK_PX)) +} + +/// Most objects remembered by [`ObjectFinderCache`]. +pub const OBJECT_FINDER_ENTRIES: usize = 12; + +/// Object Finder results for one document's pixels (#4087): hovering over an object already +/// found, or clicking it, answers at once. Keyed like [`SubjectCache`] (valid while the pixels +/// are the same, the document held weakly) plus the sampled layer; a lookup hits any cached +/// object that covers the point, so one computation serves the whole object. +#[derive(Debug, Default)] +pub struct ObjectFinderCache { + doc: std::sync::Weak, + doc_id: Option, + target: Option, + regions: Vec>, +} + +impl ObjectFinderCache { + fn valid(&self, doc: &Document, target: Option) -> bool { + self.doc_id == Some(doc.id) && self.target == target && self.doc.upgrade().is_some_and(|old| crate::layer_multi_cmds::same_pixels(&old, doc)) + } + + /// A cached object of `doc` (sampling `target`) covering `(x, y)`. + pub fn lookup(&self, doc: &Document, target: Option, x: f32, y: f32) -> Option> { + if !x.is_finite() || !y.is_finite() || !self.valid(doc, target) { + return None; + } + let (px, py) = (x.floor() as i32, y.floor() as i32); + self.regions.iter().find(|r| r.at(px, py) >= 0.5).cloned() + } + + /// Remembers `region`, found in `doc` sampling `target`. A different document or pixels + /// start over; the oldest object is dropped beyond [`OBJECT_FINDER_ENTRIES`]. + pub fn insert(&mut self, doc: &std::sync::Arc, target: Option, region: std::sync::Arc) { + if !self.valid(doc, target) { + self.regions.clear(); + } + self.doc = std::sync::Arc::downgrade(doc); + self.doc_id = Some(doc.id); + self.target = target; + if self.regions.len() >= OBJECT_FINDER_ENTRIES { + self.regions.remove(0); + } + self.regions.push(region); + } + + /// Number of remembered objects. + pub fn len(&self) -> usize { + self.regions.len() + } + + /// Whether nothing is remembered. + pub fn is_empty(&self) -> bool { + self.regions.is_empty() + } + + /// Forget the results if they were computed from the document `id` (it is closing). + pub(crate) fn forget_doc(&mut self, id: photocraft_doc::DocId) { + if self.doc_id == Some(id) { + *self = Self::default(); + } + } + + /// Follows `doc` past a selection-only edit (same pixels), so the next lookup still hits. + fn follow(&mut self, doc: &std::sync::Arc) { + if self.doc_id == Some(doc.id) && self.doc.upgrade().is_some_and(|old| crate::layer_multi_cmds::same_pixels(&old, doc)) { + self.doc = std::sync::Arc::downgrade(doc); + } + } +} + +/// `select.object` with `"point"`: the Object Finder's object under the point (cached). +fn object_at_point(s: &mut Session, p: &Value, (x, y): (f32, f32)) -> Result { + let all_layers = b(p, "sampleAllLayers", false); + let d = s.active().ok_or(EngineError::NoDocument)?; + let target = sampled_target(&d.doc, d.active_layer, all_layers); + let region = match s.object_finder.lookup(&d.doc, target, x, y) { + Some(r) => Some(r), + None => { + let doc = std::sync::Arc::clone(&d.doc); + let found = find_object(&doc, d.active_layer, all_layers, x, y).map(std::sync::Arc::new); + if let Some(r) = &found { + s.object_finder.insert(&doc, target, std::sync::Arc::clone(r)); + } + found + } + }; + let out = apply(s, "Object Selection", region.as_deref(), mode(p, "replace"))?; + if let Some(d) = s.active() { + let doc = std::sync::Arc::clone(&d.doc); + s.object_finder.follow(&doc); + } + Ok(out) +} + fn object_selection(s: &mut Session, p: &Value) -> Result { - let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h]"))?; + if p.get("rect").is_none() + && let Some(pt) = point_param(p) + { + return object_at_point(s, p, pt); + } + let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h] or \"point\": [x, y]"))?; let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| grabcut::object_select(smp, doc.bounds(), rect, 160_000))?; apply(s, "Object Selection", region.as_ref(), mode(p, "replace")) } @@ -373,7 +489,7 @@ pub fn specs() -> Vec { "select.object", "Object Selection", [], - r##"{"rect":[x,y,w,h],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false}"##, + r##"{"rect":[x,y,w,h] | "point":[x,y],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false} (point: the Object Finder's object under the point, as hovering previews it)"##, has_doc, object_selection ), @@ -544,6 +660,59 @@ mod tests { assert!(cov(&s, 10, 10) >= 0.99); } + #[test] + fn object_selection_point_selects_the_object_under_it() { + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.9, "depth {depth}: IoU {v}"); + } + // The background is no object: nothing changes. + let (mut s, _) = disc(8); + let r = s.execute("select.object", json!({"point": [3, 3]})).unwrap(); + assert_eq!(r["changed"], false); + assert!(s.active().unwrap().doc.selection.is_none()); + assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); + } + + #[test] + fn object_selection_point_honours_the_modes() { + let (mut s, _) = disc(8); + s.execute("select.all", json!({})).unwrap(); + s.execute("select.object", json!({"point": [100, 80], "mode": "subtract"})).unwrap(); + assert!(cov(&s, 100, 80) < 0.5 && cov(&s, 3, 3) >= 0.5); + s.execute("select.object", json!({"point": [100, 80], "mode": "add"})).unwrap(); + assert!(cov(&s, 100, 80) >= 0.5 && cov(&s, 3, 3) >= 0.5); + s.execute("select.object", json!({"point": [100, 80], "mode": "intersect"})).unwrap(); + assert!(cov(&s, 100, 80) >= 0.5 && cov(&s, 3, 3) < 0.5); + } + + #[test] + fn object_finder_cache_serves_the_whole_object_until_pixels_change() { + let (mut s, _) = disc(8); + let d = s.active().unwrap(); + let doc = std::sync::Arc::clone(&d.doc); + let target = sampled_target(&doc, d.active_layer, false); + assert!(s.object_finder.lookup(&doc, target, 100.0, 80.0).is_none()); + let r = std::sync::Arc::new(find_object(&doc, d.active_layer, false, 100.0, 80.0).unwrap()); + s.object_finder.insert(&doc, target, std::sync::Arc::clone(&r)); + // Any point on the object hits; the background and another target miss. + assert!(std::sync::Arc::ptr_eq(&s.object_finder.lookup(&doc, target, 120.0, 90.0).unwrap(), &r)); + assert!(s.object_finder.lookup(&doc, target, 3.0, 3.0).is_none()); + assert!(s.object_finder.lookup(&doc, Some(LayerId(9999)), 100.0, 80.0).is_none()); + // The click reuses it, and a selection-only edit keeps it. + s.execute("select.object", json!({"point": [110, 70]})).unwrap(); + assert_eq!(s.object_finder.len(), 1); + s.execute("select.deselect", json!({})).unwrap(); + let d = s.active().unwrap(); + assert!(s.object_finder.lookup(&std::sync::Arc::clone(&d.doc), target, 100.0, 80.0).is_some()); + // A pixel edit drops it. + s.execute("paint.pencil", json!({"points": [[5, 5], [30, 5]], "size": 4, "color": "#000000"})).unwrap(); + assert!(s.object_finder.is_empty()); + } + #[test] fn object_selection_disc() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 4a92441b71..836b843971 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -14,10 +14,11 @@ fn export_bytes(s: &mut Session, p: Value) -> Vec { fn defaults_and_listing() { let mut s = Session::new(); let r = s.execute("swatches.list", json!({})).unwrap(); - assert_eq!(r["count"], 40); + assert_eq!(r["count"], 46); assert_eq!(r["groups"][0]["name"], "Grays"); assert_eq!(r["groups"][0]["swatches"][0], json!({"name": "Black", "model": "rgb", "values": [0.0, 0.0, 0.0], "hex": "#000000"})); - assert_eq!(r["groups"][2]["swatches"][0]["hex"], "#e62828"); + assert_eq!(r["groups"][1]["swatches"][2]["hex"], "#00ff00", "RGB Green is exact (#4302)"); + assert_eq!(r["groups"][3]["swatches"][0]["hex"], "#e62828"); } #[test] @@ -67,7 +68,7 @@ fn rename_delete_move_and_groups() { assert_eq!(s.presets.swatches[0].items[0].name, "White"); let r = s.execute("swatches.move", json!({"swatch": "White", "to": "Dark", "position": 1})).unwrap(); assert_eq!(r, json!({"group": "Dark", "position": 1})); - assert_eq!(s.presets.swatches[3].items[1].name, "White"); + assert_eq!(s.presets.swatches[4].items[1].name, "White"); // Out-of-range positions clamp. let r = s.execute("swatches.move", json!({"swatch": "White", "position": 999_999})).unwrap(); assert_eq!(r["position"], 10); @@ -80,21 +81,21 @@ fn rename_delete_move_and_groups() { assert!(s.execute("swatches.renameGroup", json!({"group": "Favourites", "name": "Dark"})).is_err()); let r = s.execute("swatches.deleteGroup", json!({"group": "Favourites", "keepSwatches": true})).unwrap(); assert_eq!(r["kept"], 2); - assert!(s.presets.swatches[3].items.iter().any(|i| i.name == "Red"), "kept in the group above"); + assert!(s.presets.swatches[4].items.iter().any(|i| i.name == "Red"), "kept in the group above"); s.execute("swatches.deleteGroup", json!({"group": "Mine 1"})).unwrap(); // Delete several by name, and one by index. let r = s.execute("swatches.delete", json!({"swatch": ["Red", "Blue", "Red"]})).unwrap(); assert_eq!(r["deleted"].as_array().unwrap().len(), 2); let r = s.execute("swatches.delete", json!({"index": 0})).unwrap(); assert_eq!(r["deleted"], json!(["Ink Black"])); - assert_eq!(names(&s).len(), 37); + assert_eq!(names(&s).len(), 43); // Reset restores the defaults; append adds them again with unique names. s.execute("swatches.reset", json!({})).unwrap(); - assert_eq!(names(&s).len(), 40); + assert_eq!(names(&s).len(), 46); s.execute("swatches.reset", json!({"append": true})).unwrap(); - assert_eq!(names(&s).len(), 80); + assert_eq!(names(&s).len(), 92); assert!(names(&s).contains(&"Black 2".to_string())); - assert_eq!(s.presets.swatches[4].name, "Grays 1"); + assert_eq!(s.presets.swatches[5].name, "Grays 1"); } #[test] @@ -220,7 +221,7 @@ fn bad_params_fail_gracefully() { ("swatches.delete", json!({"swatch": "Nope"})), ("swatches.delete", json!({"swatch": ["Red", 5]})), ("swatches.delete", json!({"swatch": ["Red", "Nope"]})), - ("swatches.delete", json!({"index": 40})), + ("swatches.delete", json!({"index": 46})), ("swatches.delete", json!({"index": "0"})), ("swatches.delete", json!({"swatch": "Red", "group": "Grays"})), ("swatches.rename", json!({"swatch": "Red"})), @@ -264,7 +265,7 @@ fn bad_params_fail_gracefully() { assert_eq!(s.presets.swatches, before, "failed commands change nothing"); // Empty library: the swatch commands are disabled, not crashing. s.execute("swatches.delete", json!({"swatch": names(&s)})).unwrap(); - for g in ["Grays", "Pastel", "Pure", "Dark"] { + for g in ["Grays", "RGB", "Pastel", "Pure", "Dark"] { s.execute("swatches.deleteGroup", json!({"group": g})).unwrap(); } for id in ["swatches.delete", "swatches.rename", "swatches.move", "swatches.use", "swatches.export", "swatches.renameGroup", "swatches.deleteGroup"] { @@ -289,3 +290,30 @@ fn library_limits() { assert_eq!(total(&g), MAX_SWATCHES); assert_eq!(g[0].name.chars().count(), MAX_NAME); } + +/// A value beyond f32 range used to pass the finite check, become inf as f32 and wrap to a NaN +/// hue, which the prefs file stores as `null`; the next launch then dropped every saved preset. +#[test] +fn values_beyond_f32_range_are_rejected_so_presets_survive_a_restart() { + let mut s = Session::new(); + s.execute("swatches.add", json!({"name": "Keep me", "color": {"model": "lab", "values": [40, 12.5, -7.25]}})).unwrap(); + let before = s.presets.swatches.clone(); + for (model, values) in [ + ("hsb", json!([1e39, 100, 100])), + ("hsb", json!([-1e39, 100, 100])), + ("rgb", json!([1e39, 0, 0])), + ("cmyk", json!([0, 0, 0, -1e300])), + ("lab", json!([50, 1e39, 0])), + ("gray", json!([1e39])), + ] { + let r = s.execute("swatches.add", json!({"name": "Bad", "color": {"model": model, "values": values}})); + assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{model} {values}: {r:?}"); + } + assert_eq!(s.presets.swatches, before, "a rejected colour leaves the library unchanged"); + // The largest f32 hue is still accepted and stays finite. + s.execute("swatches.add", json!({"name": "Big hue", "color": {"model": "hsb", "values": [f64::from(f32::MAX), 100, 100]}})).unwrap(); + let mut t = Session::new(); + t.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(t.presets.swatches, s.presets.swatches, "user swatches survive a save and reload"); + assert!(names(&t).contains(&"Keep me".to_string())); +} diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 44926b09f3..bcf97126cc 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,10 +1,10 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_surface}; +use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with}; use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -234,9 +234,103 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { false } +/// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses +/// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not +/// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused +/// rather than clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px +/// on a side before anything could fail. +pub const MAX_TRANSFORM_PIXELS: u64 = 1 << 30; + +/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one. +#[cfg(not(test))] +const BUDGET: u64 = MAX_TRANSFORM_PIXELS; +#[cfg(test)] +const BUDGET: u64 = 1 << 20; + +/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget, +/// before anything is built. `what` names the surface in the message. +pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> { + let Some(b) = warp_output_bounds(src, h) else { return Ok(()) }; + let (w, ht) = (u64::from(b.width()), u64::from(b.height())); + if w.saturating_mul(ht) > BUDGET { + let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit"); + return Err(crate::allocation::error("transforming pixels", &detail)); + } + Ok(()) +} + +/// [`check_budget`] for every warp [`transform_layer`] would make of `l` (for a group, of every +/// layer inside it), so a transform that doesn't fit is refused before any layer is built and a +/// group that partly fits isn't half-transformed. +fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> Result<()> { + if h.is_identity() { + return Ok(()); + } + // An explicit stack rather than recursion: documents can nest deeply. + let mut stack = vec![(l, doc_sel)]; + while let Some((l, sel)) = stack.pop() { + match &l.content { + LayerContent::Group(g) => stack.extend(g.children.iter().map(|c| (c, None))), + LayerContent::Text(_) | LayerContent::Shape(_) => {} + LayerContent::Smart(sm) => { + if let Some(c) = &sm.cache { + check_budget(c.content_bounds(), h, "smart object")?; + } + if let Some(m) = &sm.filter_mask { + check_budget(m.surface.content_bounds(), h, "smart filter mask")?; + } + } + _ => { + if let Some(surf) = l.surface() { + check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?; + } + } + } + if let Some(m) = &l.mask + && m.linked + { + // With a selection only its region of the mask moves (`warp_gray_selected`). + let src = match sel.filter(|_| !moves_whole(l)) { + Some(sel) => sel.content_bounds(), + None => m.surface.content_bounds(), + }; + check_budget(src, h, "layer mask")?; + } + } + Ok(()) +} + /// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background /// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]). +/// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`]. pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { + transform_layer_with(doc_sel, group, l, h, affine, interp, &Interrupt::NONE) +} + +/// [`transform_layer`] with progress and cancellation for the pixel warp (the slow part on a +/// large layer, #1015). +fn transform_layer_with( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { + check_layer_budget(doc_sel, l, h)?; + apply_transform(doc_sel, group, l, h, affine, interp, ctl) +} + +fn apply_transform( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { crate::allocation::checkpoint("transforming pixels")?; let locks = transform_locks(l, group, false); if locks.position || locks.all { @@ -245,7 +339,7 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L if h.is_identity() { if let LayerContent::Group(g) = &mut l.content { for c in g.children.iter_mut() { - transform_layer(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp, ctl)?; } } return Ok(()); @@ -256,7 +350,7 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - transform_layer(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp, ctl)?; } } LayerContent::Text(t) => { @@ -294,12 +388,12 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L // Only the selected pixels move: lift them, clear them, warp and paste back. Some(sel) => { let (lifted, mut rest) = split_selected(surf, sel)?; - let moved = warp_surface(&lifted, src, h, interp)?; + let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface(surf, src, h, interp)?, + None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?, }; } } @@ -499,43 +593,69 @@ fn transform(s: &mut Session, p: &Value) -> Result { (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let lone = lone.is_some(); - s.edit("Free Transform", |doc, _| { - let sel = doc.selection.clone(); - if lone { - // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. - if !h.is_identity() { - let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; - *surf = match &sel { - Some(sel) => warp_gray_selected(surf, sel, &h, interp)?, - None => warp_gray(surf, &h, interp)?, - }; - if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); - } - } - return Ok(()); - } - let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let is_group = doc.layer(id).is_some_and(Layer::is_group); - let group = group_locks(doc, id); - // Photoshop turns the Background into a normal layer before transforming it. - crate::extra_cmds::background_to_layer(doc, id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?; + let p = p.clone(); + // A background job when started with `Session::start` (#1015): the warp of a large layer runs + // on a worker with progress, and can be cancelled. + crate::jobs::edit_job( + s, + "Free Transform", + move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)), + move |()| json!({"rect": rect}), + ) +} + +/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline. +#[allow(clippy::too_many_arguments)] +fn transform_doc( + doc: &mut Document, + id: Option, + p: &Value, + lone: bool, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { + let sel = doc.selection.clone(); + // The selection outline moves too: price it before anything is built. + if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { + check_budget(sel.content_bounds(), h, "selection")?; + } + if lone { + // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. if !h.is_identity() { - // Type layers re-render from their new transform. - let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - refresh_text(&snapshot, l); - } - // The selection outline moves with the pixels. + let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; + let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); + check_budget(src, h, "mask or channel")?; + *surf = match &sel { + Some(sel) => warp_gray_selected(surf, sel, h, interp)?, + None => warp_gray(surf, h, interp)?, + }; if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); } } - Ok(()) - })?; - Ok(json!({"rect": rect})) + return Ok(()); + } + let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; + let is_group = doc.layer(id).is_some_and(Layer::is_group); + let group = group_locks(doc, id); + // Photoshop turns the Background into a normal layer before transforming it. + crate::extra_cmds::background_to_layer(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + transform_layer_with(if is_group { None } else { sel.as_ref() }, group, l, h, affine, interp, ctl)?; + if !h.is_identity() { + // Type layers re-render from their new transform. + let snapshot = doc.clone(); + if let Some(l) = doc.layer_mut(id) { + refresh_text(&snapshot, l); + } + // The selection outline moves with the pixels. + if let Some(sel) = &doc.selection { + doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); + } + } + Ok(()) } pub fn specs() -> Vec { @@ -577,6 +697,112 @@ mod tests { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() } + // ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ---- + + /// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10. + fn column() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 2, 11, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + /// Stretch x by `k` about x = 10 (the column's left edge). + fn stretch(k: f64) -> Value { + json!({"matrix": [k, 0, 0, 1, -10.0 * k, 0], "interpolation": "nearest"}) + } + + #[test] + fn a_scale_past_the_pixel_budget_is_refused_and_changes_nothing() { + let mut s = column(); + let st = s.active().unwrap(); + let (before, revision, steps) = (st.doc.clone(), st.revision, st.history.past_len()); + // 200000 x 10 px of output: twice the test budget. + let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); + assert!(err.starts_with("not enough memory for transforming pixels"), "{err}"); + assert!(err.contains("would be 200002 x 10 px") && err.ends_with("the document was not changed"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before, "the document is untouched"); + assert_eq!((st.revision, st.history.past_len()), (revision, steps)); + // Within the budget the same stretch still works, nothing clipped. + s.execute("edit.transform", stretch(1e4)).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(0, 2, 10_000, 10)); + } + + #[test] + fn a_group_with_one_layer_past_the_budget_is_not_half_transformed() { + let mut s = column(); + let wide = s.active().unwrap().active_layer.unwrap(); + // Over the column: a 1 px dot, which fits the budget stretched by the same amount. + s.execute("layer.new.layer", json!({})).unwrap(); + let dot = s.active().unwrap().active_layer.unwrap(); + s.edit("paint", |doc, _| { + doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 12, 11, 13), &[0.0, 1.0, 0.0, 1.0]); + // Widen the column to 8 px (x 2..10), so it alone needs 400002 x 10 px. + doc.layer_mut(wide).unwrap().surface_mut().unwrap().fill_rect(Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": wide.0})).unwrap(); + s.execute("layer.select", json!({"layer": dot.0, "mode": "add"})).unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let st = s.active().unwrap(); + assert!(matches!(&st.doc.layer(group).unwrap().content, LayerContent::Group(g) if g.children.len() == 2)); + let (before, revision) = (st.doc.clone(), st.revision); + let k = 5e4; + assert!(s.execute("edit.transform", json!({"layer": group.0, "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err()); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing changed"); + // The layer-level check runs before any child is warped, so even the layer itself + // (not just the edit's copy of the document) is left as it was. + let mut l = st.doc.layer(group).unwrap().clone(); + let h = Homography([k, 0.0, -10.0 * k, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert!(transform_layer(None, Locks::default(), &mut l, &h, None, Interp::Nearest).is_err()); + assert!(&l == st.doc.layer(group).unwrap(), "no child was transformed"); + } + + #[test] + fn moving_a_small_layer_far_off_the_canvas_is_within_budget() { + let mut s = session(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 900_000, -900_000]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(900_010, -899_990, 900_030, -899_980)); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -900_000, 900_000]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "and back, every pixel kept"); + } + + #[test] + fn selections_masks_and_channels_have_the_same_budget() { + // The selection outline of a layer transform. + let mut s = column(); + s.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); + assert!(err.contains("transformed selection would be"), "{err}"); + // Transform Selection. + let err = s.execute("select.transformSelection", stretch(2e5)).unwrap_err().to_string(); + assert!(err.contains("transformed selection would be"), "{err}"); + assert!(s.active().unwrap().doc == before); + // A targeted alpha channel. + let mut s = column(); + s.execute("channel.new", json!({})).unwrap(); + s.edit("paint channel", |doc, _| { + doc.channels[0].surface.fill_rect(Rect::new(10, 2, 11, 10), &[1.0]); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let mut p = stretch(2e5); + p["target"] = json!({"channel": 0}); + let err = s.execute("edit.transform", p).unwrap_err().to_string(); + assert!(err.contains("transformed mask or channel would be"), "{err}"); + assert!(s.active().unwrap().doc == before); + } + #[test] fn scale_via_quad_and_undo() { let mut s = session(); diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs index 528472cc25..fbc9322c62 100644 --- a/crates/engine/src/type_caret_cmds.rs +++ b/crates/engine/src/type_caret_cmds.rs @@ -106,9 +106,7 @@ fn topmost(doc: &Document, x: f64, y: f64) -> Option { let mut ids: Vec = doc .walk() .into_iter() - .filter(|(path, _, layer)| { - matches!(layer.content, LayerContent::Text(_)) && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|ancestor| ancestor.visible)) - }) + .filter(|(path, _, layer)| matches!(layer.content, LayerContent::Text(_)) && doc.shown_at(path)) .map(|(_, _, layer)| layer.id) .collect(); ids.reverse(); diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 4a4cb347bf..4bf193b679 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -133,6 +133,81 @@ fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option std::result::Result<(), String> { + for (key, max) in STEP_KEYS { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(by) if by.is_finite() && by.abs() <= max => {} + _ => return Err(format!("{key} must be a number between -{max} and {max}")), + } + } + } + Ok(()) +} + +/// Relative character steps (Photoshop's ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking +/// over a selection): every character moves from its own value, so mixed values keep their +/// differences. Auto leading starts from the value layout uses, the paragraph's auto-leading +/// percentage of the character's size. Results are clamped to Photoshop's ranges. Returns true +/// if any step key was present. +fn step_char_metrics(t: &mut TextLayer, a: usize, b: usize, p: &Value) -> bool { + let leading_by = f32p(p, "leadingBy").filter(|v| v.is_finite()); + let shift_by = f32p(p, "baselineShiftBy").filter(|v| v.is_finite()); + let tracking_by = f32p(p, "trackingBy").filter(|v| v.is_finite()); + if leading_by.is_none() && shift_by.is_none() && tracking_by.is_none() { + return false; + } + let paragraphs = t.paragraph_runs(); + let default_auto = ParagraphStyle::default().auto_leading; + // Each paragraph's share of `a..b`, with the auto leading it lays out with. + let mut pieces = Vec::new(); + let mut at = 0usize; + for pr in ¶graphs { + let (ps, pe) = (at, at.saturating_add(pr.len)); + at = pe; + let (pa, pb) = (a.clamp(ps, pe), b.clamp(ps, pe)); + if pa < pb { + pieces.push((pa, pb, pr.style.auto_leading)); + } + } + if at < b { + pieces.push((a.max(at), b, default_auto)); + } + if t.text.is_empty() { + // Empty text: the step sets the style the next typed characters get. + pieces = vec![(0, 0, paragraphs.first().map_or(default_auto, |r| r.style.auto_leading))]; + } + for (pa, pb, auto) in pieces { + style_range(t, pa, pb, &|st| { + if let Some(by) = leading_by { + let auto = if auto.is_finite() && auto > 0.0 { auto } else { default_auto }; + let base = st.leading_pt.unwrap_or(auto * st.size_pt); + let base = if base.is_finite() { base } else { 0.0 }; + st.leading_pt = Some((base + by).clamp(LEADING_MIN_PT, LEADING_MAX_PT)); + } + if let Some(by) = shift_by { + let base = if st.baseline_shift_pt.is_finite() { st.baseline_shift_pt } else { 0.0 }; + st.baseline_shift_pt = (base + by).clamp(-BASELINE_SHIFT_MAX_PT, BASELINE_SHIFT_MAX_PT); + } + if let Some(by) = tracking_by { + let base = if st.tracking.is_finite() { st.tracking } else { 0.0 }; + st.tracking = (base + by).clamp(TRACKING_MIN as f32, TRACKING_MAX as f32); + } + }); + } + true +} + /// Applies character-style keys from JSON. Returns true if any key was present. pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { let mut any = false; @@ -466,17 +541,21 @@ pub fn refresh(doc: &Document, t: &mut TextLayer) { t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } +/// Refuses to change a type layer under Lock All (its own or an enclosing group's), with +/// Photoshop's message. Every command that edits a type layer's text calls it first (#3132, #4188). +pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> { + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if doc.effective_locks(id).all { + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", layer.name))); + } + Ok(()) +} + /// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s /// `name`), which is applied in the same step; otherwise an auto-named layer follows its text. fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option) -> Result) -> Result { let id = layer_id(s, p)?; - { - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if doc.effective_locks(id).all { - return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", layer.name))); - } - } + check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; let (r, damage) = s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -578,7 +657,7 @@ fn shown_style_params<'a>(t: &TextLayer, p: &'a Value) -> Result 1e-3 { f64::from(scale) } else { 1.0 }; let mut params = p.clone(); - for key in ["size", "leading"] { + for key in ["size", "leading", "leadingBy"] { if let Some(value) = p.get(key).and_then(Value::as_f64) { let value = value / scale; if !value.is_finite() || value.abs() > f64::from(f32::MAX) { @@ -864,13 +943,14 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: Box::leak( - format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading include each layer's transform scale), {CHAR_PARAMS}, paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) + format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) .into_boxed_str(), ), enabled: has_doc, journal: true, run: |s, p| { check_kerning(p).map_err(|m| bad("type.setStyle", m))?; + check_steps(p).map_err(|m| bad("type.setStyle", m))?; let ids = with_text_targets(s, p, "Set Type Style", |t, (a, b)| { let props = shown_style_params(t, p)?; check_size_tracking(&props).map_err(|m| bad("type.setStyle", m))?; @@ -880,6 +960,7 @@ pub fn specs() -> Vec { apply_char_props(st, &props); }); } + step_char_metrics(t, a, b, &props); let mut pprobe = ParagraphStyle::default(); if apply_para_props(&mut pprobe, p) { style_paragraphs(t, a, b, &|st| { @@ -1612,4 +1693,59 @@ mod tests { ]) ); } + + /// `leadingBy`, `baselineShiftBy` and `trackingBy` step every character from its own value + /// (auto leading from its laid-out value), clamp to Photoshop's ranges, and reject hostile + /// steps without changing anything. + #[test] + fn relative_steps_move_each_character_from_its_own_value() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Hello\nworld", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [8, 11], "leading": 30, "tracking": 50})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [6, 11], "autoLeading": 150})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + s.execute("type.setStyle", json!({"layer": id, "range": [0, 11], "leadingBy": 2, "baselineShiftBy": -2, "trackingBy": 20})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one history step"); + let t = text_layer(&s, id); + let at = |c: usize| { + let byte = byte_at(&t.text, c); + let mut off = 0; + t.char_runs() + .into_iter() + .find(|r| { + off += r.len; + off > byte + }) + .unwrap() + .style + }; + // Auto leading 120% × 10 pt (first paragraph), 150% × 10 pt (second), and a set 30 pt. + assert_eq!(at(0).leading_pt, Some(14.0)); + assert_eq!(at(6).leading_pt, Some(17.0)); + assert_eq!(at(9).leading_pt, Some(32.0)); + assert_eq!((at(0).baseline_shift_pt, at(9).baseline_shift_pt), (-2.0, -2.0)); + assert_eq!((at(0).tracking, at(9).tracking), (20.0, 70.0)); + // Clamped to the ranges. + s.execute("type.setStyle", json!({"layer": id, "leadingBy": -5000, "baselineShiftBy": 10000, "trackingBy": -11000})).unwrap(); + let t = text_layer(&s, id); + assert!(t.char_runs().iter().all(|r| r.style.leading_pt == Some(LEADING_MIN_PT) + && r.style.baseline_shift_pt == BASELINE_SHIFT_MAX_PT + && r.style.tracking == TRACKING_MIN as f32)); + // A collapsed range in non-empty text changes no character. + let before = text_layer(&s, id).char_runs(); + s.execute("type.setStyle", json!({"layer": id, "range": [3, 3], "trackingBy": 100})).unwrap(); + assert_eq!(text_layer(&s, id).char_runs(), before); + // Hostile steps are refused. + let steps = s.active().unwrap().history.entries().len(); + for bad_step in [json!({"leadingBy": "up"}), json!({"trackingBy": 1e30}), json!({"baselineShiftBy": null}), json!({"leadingBy": [1]})] { + let mut p = bad_step.clone(); + p["layer"] = json!(id); + assert!(matches!(s.execute("type.setStyle", p), Err(EngineError::BadParams { .. })), "{bad_step}"); + } + assert_eq!(s.active().unwrap().history.entries().len(), steps); + // Empty text: the step sets the style the next typed characters get. + let empty = s.execute("type.create", json!({"x": 0, "y": 80, "text": "", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": empty, "range": [0, 0], "leadingBy": -2})).unwrap(); + assert_eq!(text_layer(&s, empty).char_runs()[0].style.leading_pt, Some(10.0)); + } } diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index e460a975b6..1e7556b1d4 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -14,7 +14,9 @@ use photocraft_text::render::PathEl; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets}; +use crate::type_cmds::{ + check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, +}; use crate::{EngineError, Result, Session}; /// Photoshop's Paste Lorem Ipsum filler. @@ -42,6 +44,7 @@ fn has_point_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Point => Ok(()), TextShape::Box { .. } => Err("the type layer is already paragraph text".into()), + TextShape::Path { .. } => Err("detach the type layer from its path first".into()), } } @@ -49,6 +52,7 @@ fn has_box_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Box { .. } => Ok(()), TextShape::Point => Err("the type layer is already point text".into()), + TextShape::Path { .. } => Err("the type layer is path text".into()), } } @@ -67,6 +71,7 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { /// Edits a type layer as one history step and re-renders it. fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { let id = layer_param(s, p)?; + check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -276,7 +281,7 @@ fn contours(els: &[PathEl]) -> Vec> { /// which reproduces the nonzero fill under Photoshop's per-subpath path operations. pub fn text_path(doc: &Document, t: &TextLayer) -> Path { let l = layout(doc, t); - let warp = photocraft_text::render::layout_warp(&l, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&l, t); let mut subpaths = Vec::new(); for glyph in photocraft_text::render::outlines(&l, &t.transform, warp.as_ref()) { let cs = contours(&glyph); @@ -318,6 +323,95 @@ fn convert_to_shape(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "subpaths": n})) } +// ---------- type on a path ---------- + +/// Largest anchor count of a path that type can follow (a hostile document cannot make the +/// layout walk millions of segments). +const MAX_TYPE_PATH_KNOTS: usize = 100_000; + +/// The subpath `type.attachToPath` follows, in document space: the work path (default) or a +/// shape layer's path (`"shape":id`), subpath `"subpath"` (default 0). +fn path_source(doc: &Document, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let path = match p.get("shape") { + None | Some(Value::Null) => doc.work_path.as_ref().ok_or_else(|| bad("the document has no work path; draw a path or pass \"shape\":layerId"))?, + Some(v) => { + let id = v.as_u64().ok_or_else(|| bad("\"shape\" must be a layer id"))?; + match doc.layer(LayerId(id)).map(|l| &l.content) { + Some(LayerContent::Shape(sh)) => &sh.path, + Some(_) => return Err(bad("\"shape\" is not a shape layer")), + None => return Err(EngineError::NoLayer(LayerId(id))), + } + } + }; + let index = match p.get("subpath") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("\"subpath\" must be a non-negative index"))?, + }; + let sub = path.subpaths.get(index).ok_or_else(|| bad("the path has no such subpath"))?; + if sub.knots.len() < 2 { + return Err(bad("the subpath needs at least two anchor points")); + } + if sub.knots.len() > MAX_TYPE_PATH_KNOTS { + return Err(bad("the subpath has too many anchor points")); + } + Ok(sub.clone()) +} + +fn attach_to_path(s: &mut Session, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let start = match p.get("start") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("\"start\" must be a finite number of pixels"))? as f32, + }; + let flip = match p.get("flip") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?, + }; + let id = layer_param(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + if doc.effective_locks(id).all { + return Err(EngineError::Other("the type layer is locked".into())); + } + let sub = path_source(doc, p)?; + with_text(s, p, "Type on a Path", |t, _| { + // The path lives in document space; the type layer keeps it in its own text space, so + // the type lands on the path where it is drawn. + let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; + let map = |q: Point| inv.apply(q); + let knots = sub.knots.iter().map(|k| Knot { anchor: map(k.anchor), in_ctrl: map(k.in_ctrl), out_ctrl: map(k.out_ctrl), smooth: k.smooth }).collect(); + let path = Subpath { closed: sub.closed, knots, op: PathOp::Combine }; + if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) { + return Err(EngineError::Other("the path cannot be mapped into the type layer".into())); + } + t.shape = TextShape::Path { path, start, flip }; + Ok(()) + })?; + Ok(json!({"layer": id.0, "start": start, "flip": flip})) +} + +fn is_path_text(s: &Session) -> std::result::Result<(), String> { + match active_text(s)?.shape { + TextShape::Path { .. } => Ok(()), + _ => Err("the type layer is not on a path".into()), + } +} + +fn detach_from_path(s: &mut Session, p: &Value) -> Result { + let id = layer_param(s, p)?; + with_text(s, p, "Release Type from Path", |t, _| { + if !matches!(t.shape, TextShape::Path { .. }) { + return Err(EngineError::Other("the type layer is not on a path".into())); + } + // Point text starts where the type started on the path. + let origin = photocraft_text::warp::Warp::on_path(&t.shape).map_or((0.0, 0.0), |w| w.apply(0.0, 0.0)); + t.shape = TextShape::Point; + t.transform = then_translate(&t.transform, origin.0, origin.1); + Ok(()) + })?; + Ok(json!({"layer": id.0})) +} + // ---------- warp ---------- fn warp_text(s: &mut Session, p: &Value) -> Result { @@ -585,6 +679,15 @@ pub fn specs() -> Vec { has_format_target, warp_text ), + spec!( + "type.attachToPath", + "Type on a Path", + &[], + r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##, + has_text, + attach_to_path + ), + spec!("type.detachFromPath", "Release Type from Path", &[], LP, is_path_text, detach_from_path), spec!("type.updateAllTextLayers", "Update All Text Layers", &["Type"], "{}", any_text, |s, _| update_all(s)), spec!("type.replaceAllMissingFonts", "Replace All Missing Fonts", &["Type"], "{} (with the default family)", any_text, |s, _| replace_fonts( s, diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 993ae41e8d..c0c091b10c 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -282,3 +282,48 @@ fn rasterize_type_layer() { assert!(matches!(s.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Raster(_))); assert!(!s.is_enabled("type.rasterizeTypeLayer")); } + +#[test] +fn type_attaches_to_a_path_and_releases_it() { + let mut s = session(8); + let id = text_layer(&mut s, "Hello path"); + assert!(s.is_enabled("type.attachToPath")); + assert!(!s.is_enabled("type.detachFromPath")); + // No work path yet, a bad subpath and bad params are errors, not panics. + assert!(s.execute("type.attachToPath", json!({})).is_err()); + let flat = ink(&s, id); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(150.0, 10.0), (150.0, 110.0)])])); + Ok(()) + }) + .unwrap(); + assert!(s.execute("type.attachToPath", json!({"subpath": 5})).is_err()); + assert!(s.execute("type.attachToPath", json!({"start": "far"})).is_err()); + assert!(s.execute("type.attachToPath", json!({"shape": 9999})).is_err()); + s.execute("type.attachToPath", json!({"start": 4})).unwrap(); + assert!(matches!(text(&s, id).shape, TextShape::Path { start, .. } if start == 4.0)); + let on_path = ink(&s, id); + assert!(on_path.height() > on_path.width(), "the type runs down the vertical path: {on_path:?}"); + assert!(on_path.height() > flat.height()); + assert!(!s.is_enabled("type.convertToPointText")); + s.execute("type.detachFromPath", json!({})).unwrap(); + assert_eq!(text(&s, id).shape, TextShape::Point); + assert!(ink(&s, id).width() > ink(&s, id).height()); + assert!(s.undo()); + assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); +} + +#[test] +fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { + // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. + let mut s = session(8); + let id = text_layer(&mut s, "Hi"); + s.execute("layer.setProps", json!({"layer": id.0, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + for params in [json!({}), json!({"layer": id.0})] { + let result = s.execute("type.pasteLoremIpsum", params); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{result:?}"); + assert_eq!(text(&s, id).text, "Hi"); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index cbebb0664d..12a63a364c 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{norm_text, refresh, replace_text}; +use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -94,6 +94,7 @@ fn change(s: &mut Session, p: &Value) -> Result { let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`change` needs `replace`"))?.to_string(); let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let t = text(&doc, id).ok_or_else(|| bad(cmd, format!("layer {} is not a type layer", id.0)))?; + check_text_unlocked(&doc, id)?; let (a, b) = (byte_at(t, start), byte_at(t, end.max(start))); if let Some(w) = p.get("word").and_then(Value::as_str) && &t[a..b] != w @@ -199,6 +200,7 @@ fn insert_text(s: &mut Session, p: &Value) -> Result { let new = norm_text(&new); let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let t = text(&doc, id).ok_or_else(|| bad(cmd, format!("layer {} is not a type layer", id.0)))?; + check_text_unlocked(&doc, id)?; let n = t.chars().count(); let (a, b) = match (p.get("range").and_then(Value::as_array), p.get("at").and_then(Value::as_u64)) { (Some(r), _) if r.len() == 2 => { @@ -272,6 +274,28 @@ mod tests { text(&s.active().unwrap().doc, LayerId(id)).unwrap().to_string() } + #[test] + fn all_locked_layer_rejects_insert_text_and_spelling_change_without_history() { + // #4188: Lock All stopped type.edit (#3132) but not these two text edits. + let (mut s, id) = session("Teh"); + // A second, unlocked type layer keeps Check Spelling enabled. + s.execute("type.create", json!({"x": 10, "y": 90, "text": "zzz", "size": 12})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + for (cmd, params) in [ + ("type.insertText", json!({"layer": id, "text": "b"})), + ("edit.checkSpelling", json!({"action": "change", "layer": id, "start": 0, "end": 3, "word": "Teh", "replace": "The"})), + ] { + let result = s.execute(cmd, params); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{cmd}: {result:?}"); + assert_eq!(text_of(&s, id), "Teh", "{cmd}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "{cmd}"); + } + s.execute("layer.setProps", json!({"layer": id, "locked": false})).unwrap(); + s.execute("type.insertText", json!({"layer": id, "text": "b"})).unwrap(); + assert_eq!(text_of(&s, id), "Tehb"); + } + #[test] fn lists_changes_and_learns_words() { let (mut s, id) = session("Teh quick brwon fox, teh end. Photocraft"); diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index b732ff2629..14579e5954 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1415,6 +1415,16 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.vectorMask.edit"; let id = layer_id(s, p)?; let path = p.get("path").map(parse_path).transpose().map_err(|e| bad(CMD, e))?; + // Same range as a pixel mask's feather (`layer.layerMask.edit`), checked before any edit (#4201). + let feather = p + .get("feather") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .map(|n| n as f32) + .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) + }) + .transpose()?; s.edit("Edit Vector Mask", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; @@ -1430,8 +1440,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { if let Some(d) = f64p(p, "density") { vm.density = (d / 100.0).clamp(0.0, 1.0) as f32; } - if let Some(f) = f64p(p, "feather") { - vm.feather = f.max(0.0) as f32; + if let Some(f) = feather { + vm.feather = f; } if let Some(b) = p.get("invert").and_then(Value::as_bool) && b @@ -1631,7 +1641,7 @@ fn combine_shapes(s: &mut Session, p: &Value, op: &str, label: &str) -> Result Result { - let tol = f64p(p, "tolerance").unwrap_or(2.0).clamp(0.5, 10.0); + let tol = f64p(p, "tolerance").or_else(|| f64p(p, "simplifyTolerance")).unwrap_or(2.0).clamp(0.5, 10.0); let d = s.active().ok_or(EngineError::NoDocument)?; let area = d.doc.bounds(); let mask = sel::mask_from_surface(d.doc.selection.as_ref(), area); @@ -1821,7 +1831,9 @@ pub fn specs() -> Vec { "layer.vectorMask.edit", "Edit Vector Mask", [], - leak(format!(r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":px?,"invert":true?}}. {PATH_FORM}"##)), + leak(format!( + r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. {PATH_FORM}"## + )), has_layer, vector_mask_edit ), @@ -1884,14 +1896,7 @@ pub fn specs() -> Vec { has_layer, merge_components ), - spec!( - "select.convertToShape", - "Convert Selection to Shape", - [], - r##"{"tolerance":px=2,"fill":"#rrggbb"=foreground,"name":str?} → shape.info"##, - has_selection, - selection_to_shape - ), + spec!("select.convertToShape", "Convert to Shape", [], r##"{"tolerance":0.5..10 px=2} → shape.info"##, has_selection, selection_to_shape), CommandSpec { id: "layer.vectorMask.info", label: "Vector Mask Info", diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 00cb58d8cb..4c2a9a9071 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -363,6 +363,30 @@ fn selection_to_work_path_roundtrip_iou() { let r = s.execute("select.convertToShape", json!({"fill": "#00ff00"})).unwrap(); assert_eq!(r["fill"], "#00ff00"); assert_eq!(r["path"]["subpaths"].as_array().unwrap().len(), 3); + + // tolerance and simplifyTolerance parameter + s.execute("path.toSelection", json!({"name": "src"})).unwrap(); + let r_fine = s.execute("select.convertToShape", json!({"tolerance": 0.5, "name": "Fine Shape"})).unwrap(); + assert_eq!(r_fine["name"], "Fine Shape"); + let knots = |r: &Value| r["path"]["subpaths"].as_array().unwrap().iter().map(|sp| sp["knots"].as_array().unwrap().len()).sum::(); + let fine_knots = knots(&r_fine); + + s.execute("path.toSelection", json!({"name": "src"})).unwrap(); + let r_coarse = s.execute("select.convertToShape", json!({"simplifyTolerance": 5.0, "name": "Coarse Shape"})).unwrap(); + assert_eq!(r_coarse["name"], "Coarse Shape"); + let coarse_knots = knots(&r_coarse); + assert!(fine_knots > coarse_knots, "fine tolerance {fine_knots} must have more knots than coarse {coarse_knots}"); + + // Undo restores state + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.last().unwrap().name, "Fine Shape"); +} + +#[test] +fn convert_to_shape_requires_selection() { + let mut s = session(100, 100, 8); + assert!(!s.is_enabled("select.convertToShape")); + assert!(s.execute("select.convertToShape", json!({})).is_err()); } #[test] @@ -701,6 +725,28 @@ fn vector_mask_commands_and_compositing() { assert!(s.execute("layer.vectorMask.revealAll", json!({})).is_err()); } +/// #4201: the vector mask's feather takes the pixel mask's range (finite 0..=1000 px); anything +/// else is refused before the edit, so no infinite feather can block a .pcraft save. +#[test] +fn vector_mask_edit_rejects_out_of_range_feather() { + let mut s = session(32, 32, 8); + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.vectorMask.revealAll", json!({})).unwrap(); + let feather = |s: &Session| doc(s).layer(id).unwrap().vector_mask.as_ref().unwrap().feather; + let steps = s.active().unwrap().history.past_len(); + for bad in [json!(1e39), json!(-1), json!(1001), json!("x"), json!(null)] { + assert!(s.execute("layer.vectorMask.edit", json!({"feather": bad})).is_err(), "feather {bad}"); + assert_eq!(feather(&s), 0.0, "feather {bad}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "feather {bad}"); + } + let r = s.execute("layer.vectorMask.edit", json!({"feather": 12.5})).unwrap(); + assert_eq!(r["feather"], 12.5); + assert_eq!(feather(&s), 12.5); + s.execute("layer.vectorMask.edit", json!({"feather": 1000})).unwrap(); + assert_eq!(feather(&s), 1000.0); +} + #[test] fn translate_vectors_moves_shapes_and_masks() { let mut s = session(100, 100, 8); @@ -774,6 +820,31 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } +#[test] +fn shape_create_negative_size_rect_covers_the_ellipse_box() { + // Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and + // polygon with the same `rect` spanned 30..50. + for depth in [8, 16, 32] { + let mut s = session(100, 60, depth); + let ell = s.execute("shape.create", json!({"kind": "ellipse", "rect": [50, 10, -20, 30], "fill": "#0000ff"})).unwrap()["bounds"].clone(); + assert_eq!(ell, json!([30, 10, 20, 30]), "{depth}-bit"); + for params in [ + json!({"kind": "rect", "rect": [50, 10, -20, 30], "fill": "#0000ff"}), + json!({"kind": "roundedRect", "rect": [50, 10, -20, 30], "radii": 5, "fill": "#0000ff"}), + json!({"kind": "rect", "rect": [50, 40, -20, -30], "fill": "#0000ff"}), + ] { + let r = s.execute("shape.create", params.clone()).unwrap(); + assert_eq!(r["bounds"], ell, "{params} at {depth}-bit"); + let sh = shape(&s, r["layer"].as_u64().unwrap()); + let c = sh.cache.as_ref().unwrap(); + let fmt = c.format(); + let alpha = |x: i32, y: i32| photocraft_raster::to_rgba(&fmt, &c.read_region(photocraft_doc::Rect::from_xywh(x, y, 1, 1)))[3]; + assert!(alpha(40, 25) > 0.99, "{params} at {depth}-bit"); + assert!(alpha(60, 25) < 0.01, "{params} at {depth}-bit"); + } + } +} + /// Two overlapping 40×40 rectangle shape layers ("Rectangle 1" black below, "Rectangle 2" grey /// above, overlapping by 20×20) with both selected, like the Layers panel in #2400. fn two_selected_rects(depth: u32) -> (Session, u64, u64) { diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index dbb51d82d2..2a9f8ae8b4 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -72,6 +72,17 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document".into()) } +/// Grow the timeline to at least `frames` frames (never shrink it). Only a work area that reached +/// the old end follows the new end; a narrowed one stays where the user put it (#4170). +fn grow_duration(t: &mut Timeline, frames: usize) { + let old = t.duration; + t.duration = t.duration.max(frames.max(1)); + if t.work_end >= old { + t.work_end = t.duration; + } + t.clamp(); +} + /// Edit the active video layer's [`VideoData`] at the playhead, then re-sync the displayed frame. fn edit_video(s: &mut Session, label: &str, f: impl FnOnce(&mut VideoData, usize) -> Result<()>) -> Result { let count = s.edit(label, |doc, active| { @@ -83,9 +94,7 @@ fn edit_video(s: &mut Session, label: &str, f: impl FnOnce(&mut VideoData, usize f(v, cur)?; let n = v.frames.len(); if let Some(t) = &mut doc.timeline { - t.duration = t.duration.max(n.max(1)); - t.work_end = t.work_end.max(t.duration); - t.clamp(); + grow_duration(t, n); } sync(doc); Ok(n) @@ -295,8 +304,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { if doc.timeline.is_none() { doc.timeline = Some(Timeline::new(n, fps)); } else if let Some(t) = &mut doc.timeline { - t.duration = t.duration.max(n); - t.clamp(); + grow_duration(t, n); } let name = doc.next_layer_name(std::path::Path::new(&path).file_stem().and_then(|s| s.to_str()).unwrap_or("Video")); let mut l = Layer::raster(name, fmt); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 3380d3b9d0..22d639d49b 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -430,3 +430,71 @@ fn restoring_blank_video_frames_still_produces_blank_frames() { s.execute("layer.videoLayers.restoreFrame", json!({})).unwrap(); assert_eq!(layer_px(&s, id)[3], 0.0); } + +fn work_area(s: &Session) -> (usize, usize, usize) { + let t = s.active().unwrap().doc.timeline.as_ref().unwrap(); + (t.work_start, t.work_end, t.duration) +} + +#[test] +fn video_edits_keep_a_narrowed_work_area() { + // #4170: a video edit pushed the work-area end to the full duration. + for (cmd, params) in [ + ("layer.videoLayers.showAlteredVideo", json!({})), + ("layer.videoLayers.restoreFrame", json!({})), + ("layer.videoLayers.interpretFootage", json!({"fps": 24})), + ] { + let mut s = session(); + s.execute("timeline.create", json!({"duration": 30, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + assert_eq!(work_area(&s), (2, 10, 30)); + s.execute(cmd, params).unwrap(); + assert_eq!(work_area(&s), (2, 10, 30), "{cmd} moved the work area"); + } +} + +#[test] +fn a_full_work_area_grows_with_the_duration() { + let mut s = session(); + s.execute("timeline.create", json!({"duration": 30, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + assert_eq!(work_area(&s), (0, 30, 30)); + s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap(); + assert_eq!(work_area(&s), (0, 31, 31)); + // A narrowed work area stays put when the duration grows. + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap(); + assert_eq!(work_area(&s), (2, 10, 32)); +} + +#[test] +fn longer_footage_grows_only_a_full_work_area() { + let dir = tmpdir("workarea"); + for i in 0..5 { + write_png(&format!("{dir}/f{i:02}.png"), 16, 12, "#808080"); + } + // A work area covering everything follows the longer footage. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 3, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(work_area(&s), (0, 5, 5)); + // A narrowed one stays put. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 3, "fps": 30})).unwrap(); + s.execute("timeline.setProps", json!({"workStart": 1, "workEnd": 2})).unwrap(); + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(work_area(&s), (1, 2, 5)); +} + +#[test] +fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() { + let mut t = Timeline::new(10, 30.0); + grow_duration(&mut t, 4); + assert_eq!((t.duration, t.work_end), (10, 10)); + grow_duration(&mut t, 0); + assert_eq!((t.duration, t.work_end), (10, 10)); + t.work_end = 6; + grow_duration(&mut t, 12); + assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12)); +} diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 844da50345..5142b0a940 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -112,16 +112,19 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). -fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> { +fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp, frames: Option<&Frames>) -> Result<()> { let locks = l.locks.union(group); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } let map = |x: f64, y: f64| w.map(x, y); + // A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform + // and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along. + let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l)); match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - warp_layer(None, locks, c, w, rect, interp)?; + warp_layer(None, locks, c, w, rect, interp, frames)?; } } LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())), @@ -135,7 +138,14 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, let sb = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]); let src_warp = match w.style { WarpStyle::None => None, - WarpStyle::Custom => Some(Warp::custom(w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p)), sb)), + WarpStyle::Custom => { + let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p)); + // A `mesh` parameter's knots span the contents, not the warp box's image (#4113). + match frames.and_then(|f| f.get(&l.id)).and_then(|&c| content_frame_warp(&mesh, c, sb)) { + Some(cw) => Some(cw), + None => Some(Warp::custom(mesh, sb)), + } + } _ => Some(Warp { bounds: sb, mesh: None, ..w.clone() }), }; sm.warp = src_warp.filter(|w| !w.is_identity()); @@ -174,7 +184,11 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, if let Some(m) = l.mask.as_mut() && m.linked { - m.surface = warp_mesh_gray(&m.surface, &map, interp); + let whole = |m: &Surface| Ok(warp_mesh_gray(m, &map, interp)); + m.surface = match mask_sel { + Some(sel) => crate::transform_cmds::move_gray_selected(&m.surface, sel, &|lifted, src| Ok(warp_mesh_surface(lifted, src, &map, interp)), &whole)?, + None => whole(&m.surface)?, + }; } if let Some(vm) = l.vector_mask.as_mut() && vm.linked @@ -184,6 +198,75 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, Ok(()) } +/// Source boxes of the smart objects under a warped layer, by layer id. +type Frames = std::collections::HashMap; + +fn source_frames(doc: &Document, l: &Layer, out: &mut Frames) { + match &l.content { + LayerContent::Group(g) => g.children.iter().for_each(|c| source_frames(doc, c, out)), + LayerContent::Smart(sm) => { + if let Some(b) = crate::smart_cmds::source_box(doc, sm) { + out.insert(l.id, b); + } + } + _ => {} + } +} + +/// Rescales `knots` (spanning the box starting at `c0`, `cs` long) to span the frame `t0`, `ts` +/// (which contains it). Returns the knots and whether the frame extends past the box at the start / end. +fn reframe_knots(knots: &[f64], c0: f64, cs: f64, t0: f64, ts: f64) -> (Vec, bool, bool) { + let pre = (c0 - t0) / ts > 1e-9; + let post = (t0 + ts - (c0 + cs)) / ts > 1e-9; + let mut out: Vec = Vec::with_capacity(knots.len() + 2); + if pre { + out.push(0.0); + } + out.extend(knots.iter().map(|k| (c0 + k * cs - t0) / ts)); + if post { + out.push(1.0); + } + // Keep the ends exactly 0 and 1. + if let Some(f) = out.first_mut() { + *f = 0.0; + } + if let Some(l) = out.last_mut() { + *l = 1.0; + } + (out, pre, post) +} + +/// `items` with three more copies of the first / last one at the front (`pre`) / back (`post`). +fn pad(items: &[T], pre: bool, post: bool) -> Option> { + let mut out = Vec::with_capacity(items.len() + 6); + if pre { + out.extend(std::iter::repeat_n(items.first()?.clone(), 3)); + } + out.extend_from_slice(items); + if post { + out.extend(std::iter::repeat_n(items.last()?.clone(), 3)); + } + Some(out) +} + +/// A stored smart-object warp from `mesh` (points in source space) whose knots span the source +/// box `c`: the knots are re-expressed over the frame `hull(c, b)` (`b` = the warp box's image in +/// source space), with a degenerate patch padding every side where the frame extends past `c`. +/// The surface over `c` is exactly the mesh's (#4113). +fn content_frame_warp(mesh: &BezierMesh, c: [f64; 4], b: [f64; 4]) -> Option { + let t = [c[0].min(b[0]), c[1].min(b[1]), c[2].max(b[2]), c[3].max(b[3])]; + let (cw, ch) = (c[2] - c[0], c[3] - c[1]); + if !(cw > 0.0 && ch > 0.0 && t.iter().all(|v| v.is_finite())) { + return None; + } + let (us, left, right) = reframe_knots(&mesh.us, c[0], cw, t[0], t[2] - t[0]); + let (vs, top, bottom) = reframe_knots(&mesh.vs, c[1], ch, t[1], t[3] - t[1]); + let rows: Vec> = mesh.points.chunks(mesh.nx()).map(|r| pad(r, left, right)).collect::>()?; + let rows = pad(&rows, top, bottom)?; + let m = BezierMesh { us, vs, points: rows.into_iter().flatten().collect() }; + m.is_valid().then(|| Warp::custom(m, t)) +} + /// Largest warp frame side: far above any real layer, far below what would exhaust memory. const MAX_WARP_SIDE: u32 = 100_000; @@ -263,13 +346,21 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(json!({"layer": id.0, "changed": false})); } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); + let content_knots = p.get("mesh").is_some() && p.get("warp").is_none(); s.edit("Warp", |doc, _| { let sel = doc.selection.clone(); + let frames = content_knots.then(|| { + let mut f = Frames::new(); + if let Some(l) = doc.layer(id) { + source_frames(doc, l, &mut f); + } + f + }); let is_group = doc.layer(id).is_some_and(Layer::is_group); let group = crate::transform_cmds::group_locks(doc, id); crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?; + warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp, frames.as_ref())?; let snapshot = doc.clone(); if let Some(l) = doc.layer_mut(id) { crate::transform_cmds::refresh_text(&snapshot, l); @@ -455,7 +546,7 @@ fn grid(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; + const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; const S: &str = r##"{"warp":{…}? (the warp being edited; returned split),"rect":[x0,y0,x1,y1]?,"at":[x,y]? (document point; default = middle of the patch)} — without `warp`, edits the active smart object's warp. A 1×1 custom mesh is divided at the thirds first (the guides drawn on the default grid), so splitWarpCrosswise on an identity custom warp returns a 4×4 mesh (knots at 0, 1/3, 1/2, 2/3 and 1), not a 2×2. A preset is fitted as one patch and split without that step."##; const G: &str = r##"{"warp":{…}? (the warp being edited; returned with the new mesh),"rect":[x0,y0,x1,y1]?,"size":n|"default"|"3"|"4"|"5" (square patches, 1..=64; 1 and "default" are the single-patch grid)} — without `warp`/`style`/`mesh`, edits the active smart object's warp. The surface is kept and the new lines are real splits at i/n."##; vec![ @@ -750,4 +841,54 @@ mod tests { let back: Warp = serde_json::from_value(r["warp"].clone()).unwrap(); assert_eq!(back.mesh.unwrap().us.len(), 2); } + + /// A selection limits the warp of a linked mask too, as it does the pixels (#4175; Free + /// Transform and Perspective Warp did this already, #3083): the moved part of the mask lines + /// up with the moved pixels and the unselected part stays put. + #[test] + fn linked_mask_with_a_selection_warps_only_the_selected_region() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 20), &[1.0, 0.0, 0.0, 1.0]); + // Hide the left quarter (selected) and the right quarter (not selected). + let m = l.mask.as_mut().unwrap(); + m.surface.fill_rect(Rect::new(10, 10, 15, 20), &[0.0]); + m.surface.fill_rect(Rect::new(25, 10, 30, 20), &[0.0]); + m.linked = true; + Ok(()) + }) + .unwrap(); + let mask_at = |s: &Session, x, y| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0) + }; + let alpha_at = |s: &Session, x, y| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[3] + }; + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + // A mesh that translates the selected box 50 px down. + let b = [10.0, 10.0, 20.0, 20.0]; + let mesh = BezierMesh::identity(b, 1, 1).map_points(|p| [p[0], p[1] + 50.0]); + s.execute("edit.transform.warp", json!({"rect": [10, 10, 20, 20], "mesh": mesh})).unwrap(); + // Moved pixels and their mask line up. + assert!(alpha_at(&s, 12, 65) > 0.99); + assert!(mask_at(&s, 12, 65) < 0.01, "selected hidden quarter moved"); + assert!(mask_at(&s, 17, 65) > 0.99, "selected visible quarter moved"); + // The vacated area reads as revealed, with transparent pixels. + assert!(alpha_at(&s, 12, 15) < 0.01); + assert!(mask_at(&s, 12, 15) > 0.99); + // The unselected part of the mask stays, aligned with the unselected pixels. + assert!(alpha_at(&s, 27, 15) > 0.99); + assert!(mask_at(&s, 27, 15) < 0.01, "unselected mask stays put"); + assert!(mask_at(&s, 27, 65) > 0.99, "and isn't copied along"); + for (x, y) in [(11, 61), (13, 68), (16, 61), (18, 68)] { + let want = if x < 15 { 0.0 } else { 1.0 }; + assert!((mask_at(&s, x, y) - want).abs() < 0.01, "({x},{y}): {}", mask_at(&s, x, y)); + } + } } diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index a15cb258cf..595ad21956 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -79,6 +79,10 @@ pub struct WebSettings { pub format: WebFormat, pub palette: PaletteKind, pub colors: usize, + /// Fixed opaque source palette in index order. + pub color_table: Option>, + /// Replacements applied after quantization, preserving pixel indices. + pub color_table_edits: std::collections::BTreeMap, pub dither: Dither, /// 0..=1. pub dither_amount: f32, @@ -104,6 +108,8 @@ impl Default for WebSettings { format: WebFormat::Png24, palette: PaletteKind::Selective, colors: 256, + color_table: None, + color_table_edits: Default::default(), dither: Dither::Diffusion, dither_amount: 0.88, transparency: true, @@ -130,8 +136,13 @@ fn hex3(s: &str) -> Option<[f32; 3]> { impl WebSettings { /// Settings from command parameters (missing keys keep the defaults; Photoshop's preset /// names such as "GIF 128 Dithered" or "JPEG High" are accepted as `"preset"`). + /// + /// Without a preset there is no matte, as in Photoshop's scripted Save for Web + /// (`ExportOptionsSaveForWeb` left at its defaults): a GIF or PNG-8 keeps the colour of its + /// partly transparent pixels instead of lightening them towards white (#4115). Formats + /// without transparency still go over white. pub fn from_params(p: &Value, cmd: &str) -> Result { - let mut st = WebSettings::default(); + let mut st = WebSettings { matte: None, ..WebSettings::default() }; if let Some(preset) = p.get("preset").and_then(Value::as_str) { st = preset_settings(preset).ok_or_else(|| bad(cmd, format!("unknown preset `{preset}`")))?; } @@ -196,6 +207,27 @@ impl WebSettings { m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), }; } + if let Some(table) = p.get("colorTable") { + let table = table.as_array().filter(|a| (1..=256).contains(&a.len())).ok_or_else(|| bad(cmd, "colorTable must contain 1–256 #rrggbb colours"))?; + st.color_table = Some( + table + .iter() + .map(|v| v.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTable entries must be #rrggbb colours"))) + .collect::>>()?, + ); + } + if let Some(edits) = p.get("colorTableEdits") { + let edits = edits + .as_object() + .filter(|m| m.len() <= 256) + .ok_or_else(|| bad(cmd, "colorTableEdits must be an object of palette indices to #rrggbb colours"))?; + let table = st.color_table.as_ref().ok_or_else(|| bad(cmd, "colorTableEdits requires colorTable"))?; + for (index, value) in edits { + let index = index.parse::().ok().filter(|i| *i < table.len()).ok_or_else(|| bad(cmd, "colorTableEdits index is outside colorTable"))?; + let color = value.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTableEdits values must be #rrggbb colours"))?; + st.color_table_edits.insert(index, color); + } + } Ok(st) } } @@ -233,10 +265,23 @@ impl WebSettings { WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); - json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}) + let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); + if let Some(table) = &self.color_table { + p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::>()); + } + if !self.color_table_edits.is_empty() { + p["colorTableEdits"] = + json!(self.color_table_edits.iter().map(|(i, c)| (i.to_string(), rgb_hex(*c))).collect::>()); + } + p } } +/// A palette colour in command/control-channel notation. +pub fn rgb_hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + /// Save for Web's named presets. pub const PRESETS: [&str; 12] = [ "GIF 128 Dithered", @@ -324,6 +369,11 @@ pub struct Optimized { pub height: u32, /// Colours in the palette (indexed formats). pub colors: Option, + /// Opaque source palette before replacements (empty for non-indexed formats). + pub color_table: Vec<[u8; 3]>, + pub transparent_index: Option, + /// Indexed pixels for previews; empty for non-indexed formats or when preview is disabled. + pub indices: Vec, /// What the optimised file looks like, straight RGBA8 (for the dialog's previews). pub preview: Vec, } @@ -359,13 +409,14 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result { - let (w, h) = (rect.width() as usize, rect.height() as usize); - if w == 0 || h == 0 { - return Err(other("empty slice")); + if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) + { + return Err(other("slice is outside the pixel buffer")); } + let (w, h) = (rect.width() as usize, rect.height() as usize); let row = |y: i32| { let o = y as usize * bw; - &px[o + rect.x0 as usize..o + rect.x1 as usize] + px.get(o + rect.x0 as usize..o + rect.x1 as usize).unwrap_or(&[]) }; let region = || (rect.y0..rect.y1).flat_map(row); let matte = st.matte.unwrap_or([1.0, 1.0, 1.0]); @@ -375,6 +426,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let (ww, hh) = (w as u32, h as u32); let e = |e: photocraft_codecs::CodecError| other(e); let mut look: Vec = Vec::new(); + let mut color_table = Vec::new(); + let mut transparent_index = None; + let mut indices = Vec::new(); let (bytes, colors) = match st.format { WebFormat::Png24 => { let alpha = st.transparency && region().any(|p| p[3] < 1.0); @@ -451,16 +505,22 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O }) .collect(); let room = if transparent { st.colors.saturating_sub(1).max(2) } else { st.colors }; - // Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all). - let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count(); - let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 }; - let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect(); - if src.is_empty() { - src = work.clone(); - } - let mut pal = quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)?; - drop(src); - if st.web_snap > 0.0 { + let mut pal = if let Some(table) = &st.color_table { + if table.is_empty() || table.len() > if transparent { 255 } else { 256 } { + return Err(other("colorTable has too many entries for this image's transparency")); + } + table.clone() + } else { + // Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all). + let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count(); + let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 }; + let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect(); + if src.is_empty() { + src = work.clone(); + } + quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)? + }; + if st.web_snap > 0.0 && st.color_table.is_none() { let tol = (st.web_snap * 255.0) as u8; for c in &mut pal { let snap = c.map(|x| ((f32::from(x) / 51.0).round() * 51.0) as u8); @@ -472,16 +532,23 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O if transparent { pal.truncate(255); } + let first = pal.first().copied().ok_or_else(|| other("empty colour table"))?; + color_table = pal.clone(); let t = transparent.then(|| { - pal.push(pal[0]); + pal.push(first); pal.len() - 1 }); + transparent_index = t; let idx = quantize_rows(&mut work, w, &pal, st.dither, st.dither_amount, t); + for (index, color) in &st.color_table_edits { + let slot = pal.get_mut(*index).filter(|_| Some(*index) != t).ok_or_else(|| other("colour edit is outside the opaque palette"))?; + *slot = *color; + } if preview { look = idx .iter() .flat_map(|k| { - let c = pal[*k as usize]; + let c = pal.get(*k as usize).copied().unwrap_or(first); [c[0], c[1], c[2], if Some(*k as usize) == t { 0 } else { 255 }] }) .collect(); @@ -492,10 +559,13 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } else { photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)? }; + if preview { + indices = idx; + } (bytes, Some(pal.len())) } }; - Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, preview: look }) + Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, color_table, transparent_index, indices, preview: look }) } /// Nearest palette entry through a lazily filled 6-bit-per-channel inverse colour map: each @@ -772,6 +842,21 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu save_for_web_impl(s, p, write, false) } +/// Settings to seed the Save for Web dialog from on the next launch: `file_menu` is session +/// state, so a complete export also writes the preferences' remembered dialog settings (like +/// Fill, Liquify and the filter dialogs). The destination is per export, never remembered. +fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) { + let mut m = settings.clone(); + if let Some(o) = m.as_object_mut() { + for k in ["path", "dir", "trustedSliceHtml"] { + o.remove(k); + } + } + s.prefs.edit(|p| { + p.dialogs.insert(cmd.to_string(), m); + }); +} + fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>, native_paths: bool) -> Result { let cmd = "file.export.saveForWebLegacy"; if let Some(authorize) = s.authorize { @@ -811,11 +896,14 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; - return Ok(json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); + return Ok( + json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), + ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; + remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); @@ -906,6 +994,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u write(&web_derived_path(&dir, &format!("{base}.html"), native_paths)?, page.as_bytes())?; html_path = Some(hp); } + remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) @@ -970,6 +1059,7 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { ); o.insert("convertToSrgb".into(), json!(prefs.convert_to_srgb)); o.insert("transparency".into(), json!(true)); + o.insert("matte".into(), json!("#ffffff")); match fmt.as_str() { "jpg" => { o.insert("format".into(), json!("jpeg")); @@ -1378,7 +1468,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs new file mode 100644 index 0000000000..1d319cb382 --- /dev/null +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -0,0 +1,88 @@ +use super::*; + +#[test] +fn color_replacement_preserves_pixel_membership_and_transparency() { + let px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.6, 0.0, 0.4, 1.0], [0.0, 0.0, 0.0, 0.0]]; + for format in ["gif", "png8"] { + for dither in ["none", "diffusion", "pattern", "noise"] { + let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"}); + let original = WebSettings::from_params(&p, "test").unwrap(); + let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap(); + let mut edited = p.clone(); + // Replace red with blue: re-quantization would now send all opaque pixels to blue. + edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"}); + let settings = WebSettings::from_params(&edited, "test").unwrap(); + assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap(); + for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { + if old[3] == 0 { + assert_eq!(new[3], 0); + } else if old[..3] == [255, 0, 0] { + assert_eq!(*new, [0, 0, 255, 255]); + } else { + assert_eq!(*new, [0, 255, 0, 255]); + } + } + assert_eq!(before.indices, after.indices, "palette edits must not remap pixels"); + assert_eq!(after.transparent_index, Some(2)); + assert_eq!(after.color_table, vec![[255, 0, 0], [0, 0, 255]]); + let decoded = photocraft_codecs::decode(&after.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + // Transparent RGB has no significance; opaque preview pixels must equal the file. + for (file, preview) in decoded.data().as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { + assert_eq!(file[3], preview[3]); + if preview[3] != 0 { + assert_eq!(file, preview); + } + } + } + } +} + +#[test] +fn exported_palette_edits_work_at_every_document_depth() { + let dir = tmp("table-edits"); + for depth in [8, 16, 32] { + let mut s = session(depth); + let mut p = json!({"format": "gif", "colors": 16, "dither": "none"}); + let estimate = s.execute("file.export.saveForWebLegacy", p.clone()).unwrap(); + let table = estimate["colorTable"].as_array().unwrap(); + let red = table.iter().position(|c| c == "#ff0000").unwrap(); + p["colorTable"] = estimate["colorTable"].clone(); + p["colorTableEdits"] = json!({red.to_string(): "#00ff00"}); + let path = format!("{dir}/{depth}.gif"); + p["path"] = json!(path); + let original_doc = s.active().unwrap().doc.clone(); + s.execute("file.export.saveForWebLegacy", p).unwrap(); + assert_eq!(s.active().unwrap().doc, original_doc, "export must not edit the document"); + let img = decode(&path).convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + assert_eq!(&img.data()[(16 * 64 + 16) * 4..(16 * 64 + 16) * 4 + 4], &[0, 255, 0, 255]); + assert_eq!(img.data()[3], 0); + } +} + +#[test] +fn malformed_tables_and_out_of_bounds_slices_return_errors() { + for p in [ + json!({"colorTable": []}), + json!({"colorTable": "bad"}), + json!({"colorTable": ["#ff00é"]}), + json!({"colorTable": vec!["#000000"; 257]}), + json!({"colorTable": [123]}), + json!({"colorTableEdits": {"0": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": {"-1": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": {"1": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": []}), + ] { + assert!(WebSettings::from_params(&p, "test").is_err(), "{p}"); + let mut s = session(8); + let doc = s.active().unwrap().doc.clone(); + assert!(s.execute("file.export.saveForWebLegacy", p).is_err()); + assert_eq!(s.active().unwrap().doc, doc); + } + let settings = WebSettings::default(); + for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] { + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err()); + } + let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap(); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err()); +} diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index f64e309e95..c3ffe6edce 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -47,6 +47,23 @@ fn estimates_preserve_last_successful_export_settings() { assert_eq!(s.file_menu.last_web.as_ref(), Some(&saved), "preview estimates must not replace the settings from the last successful export"); } +/// The next launch's dialog seeds from the preferences: `file_menu` is not saved, so a complete +/// export remembers its settings in `prefs.dialogs` too, as Fill and the filter dialogs do. +#[test] +fn a_complete_export_remembers_its_settings_in_the_preferences() { + let dir = tmp("remember_settings"); + let mut s = session(8); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "quality": 42, "path": format!("{dir}/out.jpg")})).unwrap(); + let saved = s.prefs().dialogs.get("file.export.saveForWebLegacy").cloned().expect("remembered"); + assert_eq!(saved["format"], "jpeg"); + assert_eq!(saved["quality"], 42); + assert!(saved.get("path").is_none(), "the destination is per export, not remembered"); + + // Preview estimates remember nothing, like they don't replace `file_menu`. + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "width": 16, "height": 12})).unwrap(); + assert_eq!(s.prefs().dialogs.get("file.export.saveForWebLegacy").unwrap()["format"], "jpeg"); +} + #[test] fn platform_writer_receives_resized_image_at_every_depth() { for depth in [8, 16, 32] { @@ -213,6 +230,44 @@ fn gif_and_png8_keep_transparency_and_palette() { assert_eq!(&img.data()[..4], &[0, 255, 0, 255]); } +/// #4115: Photoshop's scripted Save for Web has no matte unless one is asked for, so a soft black +/// shadow exported to PNG-8 stays black (alpha only 0 or 255) instead of turning grey. A preset +/// keeps its white matte, and a matte asked for still applies. +#[test] +fn png8_has_no_matte_by_default() { + let dir = tmp("matte"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 8, "background": "transparent"})).unwrap(); + s.edit("paint", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("Shadow", fmt); + let surf = l.surface_mut().unwrap(); + for x in 0..256 { + surf.fill_rect(Rect::new(x, 0, x + 1, 8), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 0.0, 1.0 - x as f32 / 255.0])); + } + *active = Some(doc.insert_above(None, l)); + Ok(()) + }) + .unwrap(); + let export = |s: &mut Session, extra: Value| { + let path = format!("{dir}/{}.png", s.journal.len()); + let mut p = json!({"format": "png8", "path": path}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("file.export.saveForWebLegacy", p).unwrap(); + let img = decode(&path).convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + (0..256).map(|x| img.data()[x * 4..x * 4 + 4].to_vec()).collect::>() + }; + let plain = export(&mut s, json!({})); + assert!(plain.iter().all(|p| p == &[0, 0, 0, 255] || p[3] == 0), "black or transparent: {:?}", &plain[60..70]); + assert_eq!(plain[64], [0, 0, 0, 255], "alpha 191 stays black"); + assert_eq!(plain[200][3], 0); + // A matte asked for, or a preset's, lightens the soft pixels as before. + let white = export(&mut s, json!({"matte": "#ffffff"})); + assert!(white[64][0] > 40 && white[64][3] == 255, "{:?}", white[64]); + let preset = WebSettings::from_params(&json!({"preset": "PNG-8 128 Dithered"}), "x").unwrap(); + assert_eq!(preset.matte, Some([1.0, 1.0, 1.0])); +} + #[test] fn jpeg_quality_progressive_and_metadata() { let dir = tmp("jpeg"); @@ -656,3 +711,6 @@ fn overlapping_slices_keep_the_later_slice_in_html() { assert_eq!(area, 64 * 48); assert!(files.iter().any(|f| f.ends_with("images/under_01.png")), "the covered slice is cut into pieces: {files:?}"); } + +#[path = "color_table_tests.rs"] +mod color_table_tests; diff --git a/crates/engine/tests/kra.rs b/crates/engine/tests/kra.rs new file mode 100644 index 0000000000..8bdf10531c --- /dev/null +++ b/crates/engine/tests/kra.rs @@ -0,0 +1,53 @@ +//! Krita documents opened through the session: read-only source, 16-bit layers, editable +//! layers with history, and saving as OpenRaster or native. The files come from +//! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus. Run with +//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test kra -- --ignored --nocapture`. +use photocraft_engine::Session; +use serde_json::json; + +fn open(path: &std::path::Path) -> Session { + let mut session = Session::new(); + let name = path.to_string_lossy().into_owned(); + photocraft_engine::file_cmds::open_bytes_as(&mut session, &name, &std::fs::read(path).unwrap(), None, Some(name.clone())).unwrap(); + session +} + +#[test] +#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"] +fn krita_documents_open_read_only_and_stay_editable() { + let dir = std::path::PathBuf::from(std::env::var("KRITA_TEST_DIR").expect("set KRITA_TEST_DIR")); + let path = dir.join("depth-rgba-u16.kra"); + let mut session = open(&path); + let active = session.active().unwrap(); + assert!(active.source_read_only && active.path.is_none()); + assert_eq!(active.doc.depth, photocraft_color::SampleType::U16); + assert!(session.execute("file.save", json!({})).is_err()); + + // Krita's merged image is the composite (the corpus files carry no ICC profile, so both + // sides are sRGB); crates/io/tests/krita_corpus.rs compares every file. + let ours = photocraft_compose::flatten(&session.active().unwrap().doc); + let zip_bytes = std::fs::read(&path).unwrap(); + let zip = photocraft_format::zip::ZipReader::new(&zip_bytes).unwrap(); + let merged = photocraft_io::import("m.png", &zip.read_by_name("mergedimage.png", 1 << 28).unwrap()).unwrap().document; + let theirs = photocraft_compose::flatten(&merged); + let worst = ours.px.iter().zip(&theirs.px).map(|(a, b)| (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold(0.0f32, f32::max)).fold(0.0, f32::max); + assert!(worst <= 3.0 / 255.0, "{}", worst * 255.0); + + // Imported Krita layers take the ordinary commands and history. + let mut session = open(&dir.join("layers-opacity-visibility.kra")); + let before = session.active().unwrap().doc.clone(); + let id = before.layers.last().unwrap().id; + session.execute("layer.setProps", json!({"layer":id.0,"name":"Edited","opacity":0.25,"blend":"Screen"})).unwrap(); + assert_eq!(session.active().unwrap().doc.layer(id).unwrap().blend, photocraft_color::BlendMode::Screen); + session.execute("layer.select", json!({"layer":id.0})).unwrap(); + session.execute("paint.stroke", json!({"points":[[2,2],[30,30]],"size":6,"hardness":1.0,"color":"#00ff00"})).unwrap(); + assert!(session.undo() && session.undo()); + assert_eq!(session.active().unwrap().doc.layers, before.layers); + // ...and save as OpenRaster or native, never back to .kra. + let doc = &session.active().unwrap().doc; + assert!(photocraft_io::export(doc, "x.kra", &Default::default()).is_err()); + let ora = photocraft_io::export(doc, "x.ora", &Default::default()).unwrap(); + assert_eq!(photocraft_io::import("x.ora", &ora.bytes).unwrap().document.layers.len(), doc.layers.len()); + let native = photocraft_io::export(doc, "x.pcraft", &Default::default()).unwrap(); + assert_eq!(photocraft_io::import("x.pcraft", &native.bytes).unwrap().document.layers, doc.layers); +} diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index b301b95389..c73980a2f2 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -9,6 +9,8 @@ use std::sync::mpsc; use std::time::Duration; fn fresh() -> Session { + // Belt and braces with the EXTERNAL skip below: nothing in this process may reach a printer. + photocraft_engine::print_cmds::disable_printing(); let mut s = Session::new(); s.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); @@ -58,8 +60,12 @@ fn no_command_panics_or_hangs_on_adversarial_params() { json!({"x": 1e5, "y": -1e5, "radius": 300, "amount": 300, "angle": 1e5, "opacity": -100, "scale": -5, "gamma": -1}), json!({"points": [], "from": [0, 0], "to": [0, 0], "stops": [], "matrix": [0, 0, 0, 0, 0, 0], "colors": [], "name": "", "mode": "", "style": ""}), ]; + // Commands whose success reaches outside the process: `file.print` and `file.printOneCopy` + // spool to CUPS, so a fuzz run with `{}` printed a blank page on the default printer. Their + // params are validated like everyone else's; the spooling itself is not fuzzed. + const EXTERNAL: &[&str] = &["file.print", "file.printOneCopy"]; let mut bad: Vec = Vec::new(); - for spec in command_specs() { + for spec in command_specs().iter().filter(|spec| !EXTERNAL.contains(&spec.id)) { for p in ¶ms { if let Err(why) = run_guarded(spec.id, p) { bad.push(format!("{} [{why}] <- {p}", spec.id)); diff --git a/crates/engine/tests/smart_clipboard_sources.rs b/crates/engine/tests/smart_clipboard_sources.rs new file mode 100644 index 0000000000..772b5f06d8 --- /dev/null +++ b/crates/engine/tests/smart_clipboard_sources.rs @@ -0,0 +1,399 @@ +//! Clipboard copies carry only the linked smart-object records their layers reference. +use photocraft_doc::{Document, LayerContent, LayerId, SmartSource}; +use photocraft_engine::{Session, smart_cmds::source_bytes}; +use photocraft_geom::Rect; +use serde_json::json; +use std::sync::Arc; + +fn fixture(depth: u64) -> Document { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("synthetic color", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 6), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let bytes = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes; + let doc = photocraft_io::import("synthetic.psd", &bytes).unwrap().document; + assert!(doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)))); + doc +} +fn smart_id(doc: &Document) -> LayerId { + doc.walk().into_iter().find_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).unwrap() +} +fn bytes(doc: &Document, id: LayerId) -> Arc> { + let LayerContent::Smart(sm) = &doc.layer(id).unwrap().content else { panic!("expected smart object") }; + source_bytes(&doc.metadata, &sm.source).unwrap().1 +} +fn hash(bytes: &[u8]) -> (usize, String) { + (bytes.len(), blake3::hash(bytes).to_hex().to_string()) +} +fn source_session(doc: Document) -> (Session, LayerId) { + let mut s = Session::new(); + let id = smart_id(&doc); + s.add_document(doc, None); + s.select_layer(id).unwrap(); + (s, id) +} +fn roundtrip(doc: &Document, format: &str) -> Document { + if format == "pcraft" { + photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(doc, &Default::default()).unwrap()).unwrap() + } else { + let out = photocraft_io::export(doc, format, &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{format}: {:?}", out.warnings); + photocraft_io::import(&format!("clipboard.{format}"), &out.bytes).unwrap().document + } +} + +#[test] +fn clipboard_valid_psd_smart_sources_open_reopen_and_undo() { + for depth in [8, 16, 32] { + for key in [*b"lnk2", *b"lnk3", *b"lnkD"] { + let mut doc = fixture(depth); + for (_, k, _) in &mut doc.metadata.psd_global_blocks { + if matches!(&*k, b"lnk2" | b"lnk3" | b"lnkD") { + *k = key; + } + } + let (mut s, original) = source_session(doc); + let expected = bytes(&s.active().unwrap().doc, original); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected), "depth {depth}, key {key:?}"); + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap(); + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]); + s.set_active(1); + for format in ["pcraft", "psd", "psb"] { + let back = roundtrip(&s.active().unwrap().doc, format); + let (mut reopened, id) = source_session(back); + reopened.execute("layer.smartObjects.editContents", json!({"layer": id.0})).unwrap(); + assert_eq!(photocraft_compose::flatten(&reopened.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0], "depth {depth}, {format}"); + } + } + } +} + +#[test] +fn clipboard_batch_and_same_document_duplicates_keep_sources() { + for depth in [8, 16, 32] { + let (mut s, original) = source_session(fixture(depth)); + let duplicate = s.execute("layer.duplicate", json!({"layer": original.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": original.0})).unwrap(); + s.execute("layer.select", json!({"layer": duplicate, "mode": "add"})).unwrap(); + let expected = bytes(&s.active().unwrap().doc, original); + let count = s.active().unwrap().doc.metadata.psd_global_blocks.len(); + s.execute("edit.copy", json!({})).unwrap(); + let copied = s.clipboard.as_ref().unwrap().layers.as_ref().unwrap(); + assert!(!copied.metadata.psd_global_blocks.is_empty()); + assert!( + copied.metadata.psd_global_blocks.iter().all(|(_, key, data)| s + .active() + .unwrap() + .doc + .metadata + .psd_global_blocks + .iter() + .any(|(_, dk, dd)| { dk == key && Arc::ptr_eq(dd, data) })), + "an exclusive linked block should stay shared, including large PSBs" + ); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.metadata.psd_global_blocks.len(), count, "same-document paste shouldn't duplicate blocks"); + for (_, _, l) in s.active().unwrap().doc.walk() { + if matches!(l.content, LayerContent::Smart(_)) { + assert_eq!(hash(&bytes(&s.active().unwrap().doc, l.id)), hash(&expected)); + } + } + s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let ids: Vec<_> = + s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect(); + assert_eq!(ids.len(), 2); + for id in ids { + assert_eq!(hash(&bytes(&s.active().unwrap().doc, id)), hash(&expected)); + s.execute("layer.smartObjects.editContents", json!({"layer": id.0})).unwrap(); + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]); + s.set_active(1); + } + } +} + +#[test] +fn clipboard_converts_parent_without_losing_contents_and_disk_reopen() { + for mode in ["rgb", "grayscale", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let (mut s, id) = source_session(fixture(16)); + let expected = bytes(&s.active().unwrap().doc, id); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth, "mode": mode})).unwrap(); + let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected), "{mode}, depth {depth}"); + s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap(); + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]); + s.set_active(1); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pasted.pcraft"); + s.execute("file.saveACopy", json!({"path": path})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openAs", json!({"path": path})).unwrap(); + let reopened_id = smart_id(&reopened.active().unwrap().doc); + assert_eq!(hash(&bytes(&reopened.active().unwrap().doc, reopened_id)), hash(&expected)); + reopened.execute("layer.smartObjects.editContents", json!({"layer": reopened_id.0})).unwrap(); + assert_eq!(photocraft_compose::flatten(&reopened.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]); + } + } +} + +#[test] +fn clipboard_group_keeps_nested_linked_sources() { + let mut doc = fixture(16); + let id = smart_id(&doc); + let expected = bytes(&doc, id); + let child = doc.remove(id).unwrap(); + let group = photocraft_doc::Layer::group("synthetic group", vec![child]); + let group_id = group.id; + doc.layers.push(group); + let (mut s, _) = source_session(doc); + s.select_layer(group_id).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 6, "depth": 16})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let pasted = smart_id(&s.active().unwrap().doc); + assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected)); + s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap(); + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn copy_excludes_unselected_linked_records_from_native_save() { + let mut doc = fixture(8); + let original = smart_id(&doc); + let expected = bytes(&doc, original); + let mut unselected = doc.layer(original).unwrap().duplicate(); + unselected.name = "Unselected private synthetic document".into(); + unselected.visible = false; + let LayerContent::Smart(sm) = &mut unselected.content else { panic!("smart") }; + sm.source = SmartSource::Linked { path: "unselected-private-source".into() }; + doc.layers.push(unselected); + let mut private_child = photocraft_engine::smart_cmds::decode_source("source.psb", &expected).unwrap(); + for layer in &mut private_child.layers { + if let Some(surface) = layer.surface_mut() { + surface.fill_rect(Rect::new(0, 0, 8, 6), &[0.0, 0.0, 1.0, 1.0]); + } + } + let private_bytes = photocraft_io::export(&private_child, "psb", &Default::default()).unwrap().bytes; + assert_ne!(hash(&expected), hash(&private_bytes)); + let private_source = + photocraft_io::linked::LinkedFile { uuid: "unselected-private-source".into(), file_name: "private-synthetic.psb".into(), bytes: private_bytes.clone() }; + // Files may put both selected and unselected entries in one global linked-layer block. + let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap(); + let selected_record = block.clone(); + let mut combined = block.to_vec(); + combined.extend(photocraft_io::linked::encode_linked_file(&private_source)); + combined.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]); // unrelated unreadable tail + *block = Arc::new(combined); + let (mut s, _) = source_session(doc); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let back = roundtrip(&s.active().unwrap().doc, "pcraft"); + let retained = photocraft_io::linked::find_linked_file(&back.metadata, "unselected-private-source"); + if let Some(file) = &retained { + assert_eq!(hash(&file.bytes), hash(&private_bytes)); + } + assert!(retained.is_none(), "copying one selected layer must not retain another unselected smart object's embedded file in saved destination .pcraft"); + let linked: Vec<_> = back.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")).collect(); + assert_eq!(linked.len(), 1); + assert_eq!(hash(&linked[0].2), hash(&selected_record), "unidentified tail bytes must not follow the selected record"); +} + +#[test] +fn copy_keeps_selected_external_alias_and_unknown_records_without_reading_their_source() { + for key in [*b"lnk2", *b"lnk3", *b"lnkD", *b"lnkE"] { + for kind in [*b"liFE", *b"liFA", *b"next"] { + let mut doc = fixture(8); + let id = smart_id(&doc); + let LayerContent::Smart(sm) = &doc.layer(id).unwrap().content else { panic!("smart") }; + let source = sm.source.clone(); + let (_, _, raw) = doc.metadata.psd_global_blocks.iter().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap(); + let mut selected = raw.to_vec(); + selected[8..12].copy_from_slice(&kind); + selected[12..16].copy_from_slice(&42u32.to_be_bytes()); + let mut data = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { + uuid: "unselected-external".into(), + file_name: "unselected.psb".into(), + bytes: vec![9; 8], + }); + data.extend(&selected); + doc.metadata.psd_global_blocks.retain(|(_, k, _)| !matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")); + doc.metadata.psd_global_blocks.push((*b"8BIM", key, Arc::new(data))); + let (mut s, _) = source_session(doc); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap()); + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(pasted).unwrap().content else { panic!("smart") }; + assert_eq!(sm.source, source, "source identity is retained; missing external contents aren't read during Copy"); + let back = roundtrip(&s.active().unwrap().doc, "pcraft"); + let linked: Vec<_> = back.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")).collect(); + assert_eq!(linked.len(), 1); + assert_eq!(linked[0].1, key); + assert_eq!(hash(&linked[0].2), hash(&selected)); + } + } +} + +#[test] +fn copy_refuses_a_truncated_selected_record_without_changing_document_or_clipboard() { + let (mut s, _) = source_session(fixture(8)); + s.execute("edit.copy", json!({})).unwrap(); + let before_clip = s.clipboard.clone().unwrap(); + s.edit("truncate selected linked record", |doc, _| { + let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap(); + let mut truncated = block.to_vec(); + truncated[..8].copy_from_slice(&u64::MAX.to_be_bytes()); + *block = Arc::new(truncated); + Ok(()) + }) + .unwrap(); + let before_doc = s.active().unwrap().doc.clone(); + let error = s.execute("edit.copy", json!({})).unwrap_err(); + assert!(error.to_string().contains("truncated selected"), "{error}"); + assert_eq!(s.active().unwrap().doc, before_doc); + let after_clip = s.clipboard.as_ref().unwrap(); + assert_eq!(after_clip.bounds, before_clip.bounds); + assert_eq!(after_clip.layers, before_clip.layers); +} + +#[test] +fn repeated_same_document_subset_pastes_do_not_duplicate_embedded_files() { + let mut doc = fixture(16); + let original = smart_id(&doc); + let expected = bytes(&doc, original); + let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap(); + let selected_record = block.clone(); + let mut combined = block.to_vec(); + combined.extend(photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { + uuid: "unselected".into(), + file_name: "another.psb".into(), + bytes: expected.to_vec(), + })); + *block = Arc::new(combined); + let (mut s, _) = source_session(doc); + let metadata = s.active().unwrap().doc.metadata.psd_global_blocks.clone(); + s.execute("edit.copy", json!({})).unwrap(); + let copied = s.clipboard.as_ref().unwrap().layers.as_ref().unwrap(); + assert_eq!(copied.metadata.psd_global_blocks.len(), 1); + assert_eq!(hash(&copied.metadata.psd_global_blocks[0].2), hash(&selected_record)); + for _ in 0..3 { + let before = s.active().unwrap().doc.clone(); + let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected)); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.metadata.psd_global_blocks.len(), metadata.len(), "subsets should not append a second linked block"); + for ((signature, key, bytes), (expected_signature, expected_key, expected_bytes)) in after.metadata.psd_global_blocks.iter().zip(&metadata) { + assert_eq!(signature, expected_signature); + assert_eq!(key, expected_key); + assert_eq!(hash(bytes), hash(expected_bytes)); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + } + let back = roundtrip(&s.active().unwrap().doc, "pcraft"); + let ids: Vec<_> = back.metadata.psd_global_blocks.iter().flat_map(|(_, _, data)| photocraft_io::linked::block_uuids(data)).collect(); + assert_eq!(ids.len(), 2, "one record per original file after native save/reopen"); +} + +#[test] +fn empty_source_ids_do_not_carry_unidentified_global_files() { + let mut private = Session::new(); + private.execute("file.new", json!({"width":8,"height":6,"background":"black"})).unwrap(); + let bytes = photocraft_format::save_to_bytes(&private.active().unwrap().doc, &Default::default()).unwrap(); + let mut source = Session::new(); + source.execute("file.new", json!({"width":8,"height":6})).unwrap(); + source.execute("layer.new.layer", json!({})).unwrap(); + source.execute("edit.fill", json!({"contents":"foreground"})).unwrap(); + source.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + source + .edit("malformed empty source", |doc, active| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("smart") }; + sm.source = SmartSource::Linked { path: String::new() }; + doc.metadata.psd_global_blocks.push(( + *b"8BIM", + *b"lnk2", + Arc::new(photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { + uuid: String::new(), + file_name: "unidentified-private.pcraft".into(), + bytes: bytes.clone(), + })), + )); + Ok(()) + }) + .unwrap(); + let state = source.active().unwrap(); + let LayerContent::Smart(sm) = &state.doc.layer(state.active_layer.unwrap()).unwrap().content else { panic!("smart") }; + assert!(photocraft_engine::smart_cmds::source_bytes(&state.doc.metadata, &sm.source).is_none(), "an empty UUID cannot reference this record"); + source.execute("edit.copy", json!({})).unwrap(); + source.execute("file.new", json!({"width":8,"height":6})).unwrap(); + source.execute("edit.paste", json!({})).unwrap(); + let saved = photocraft_format::save_to_bytes(&source.active().unwrap().doc, &Default::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&saved).unwrap(); + assert!(back.metadata.psd_global_blocks.is_empty(), "unidentified empty-ID global bytes must not enter the copied document"); +} + +#[test] +fn ambiguous_invalid_utf8_ids_do_not_replace_the_clipboard() { + let mut private = Session::new(); + private.execute("file.new", json!({"width":8,"height":6,"background":"black"})).unwrap(); + let private_bytes = photocraft_format::save_to_bytes(&private.active().unwrap().doc, &Default::default()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":6})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents":"foreground"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let before_clip = s.clipboard.clone().unwrap(); + s.edit("ambiguous source IDs", |doc, active| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("smart") }; + let SmartSource::Embedded { bytes, .. } = &sm.source else { panic!("embedded") }; + let mut first = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { + uuid: "x".into(), + file_name: "selected.pcraft".into(), + bytes: bytes.to_vec(), + }); + let mut second = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { + uuid: "x".into(), + file_name: "unrelated.pcraft".into(), + bytes: private_bytes.clone(), + }); + first[17] = 0xff; + second[17] = 0xfe; + first.extend(second); + sm.source = SmartSource::Linked { path: "\u{fffd}".into() }; + doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(first))); + Ok(()) + }) + .unwrap(); + let before_doc = s.active().unwrap().doc.clone(); + let copied = s.execute("edit.copy", json!({})); + if copied.is_ok() { + let data = &s.clipboard.as_ref().unwrap().layers.as_ref().unwrap().metadata.psd_global_blocks[0].2; + let files = photocraft_io::linked::parse_linked_files(data); + assert_eq!(files.len(), 2); + assert_eq!(files[0].uuid, files[1].uuid); + eprintln!("two distinct malformed raw IDs were retained under one lossy source label"); + } + let error = copied.expect_err("ambiguous source identity must fail before replacing the clipboard"); + assert!(error.to_string().contains("invalid UTF-8"), "{error}"); + assert_eq!(s.active().unwrap().doc, before_doc); + assert_eq!(s.clipboard.as_ref().unwrap().layers, before_clip.layers); +} diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index 5f4c7bf6e5..f7b86048b9 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -468,3 +468,150 @@ fn displace_psd_filter_renders_with_its_embedded_map() { } } } + +/// A version-2 `liFD` item, as in Photoshop CS2's `lnkD` block: the fields every version has, and +/// none of the version 5–7 fields after the file data. +fn lifd_v2(f: &photocraft_io::linked::LinkedFile) -> Vec { + let mut item = b"liFD".to_vec(); + item.extend_from_slice(&2u32.to_be_bytes()); + item.push(f.uuid.len() as u8); + item.extend_from_slice(f.uuid.as_bytes()); + let name: Vec = f.file_name.encode_utf16().collect(); + item.extend_from_slice(&(name.len() as u32).to_be_bytes()); + item.extend(name.iter().flat_map(|u| u.to_be_bytes())); + item.extend_from_slice(&photocraft_io::linked::file_type(&f.bytes)); + item.extend_from_slice(b"8BIM"); + item.extend_from_slice(&(f.bytes.len() as u64).to_be_bytes()); + item.push(0); // no open-file descriptor + item.extend_from_slice(&f.bytes); + let mut out = (item.len() as u64).to_be_bytes().to_vec(); + out.extend(item); + out.resize(out.len().next_multiple_of(4), 0); + out +} + +/// `doc` saved as PSD, then rewritten into Photoshop CS2's layout: no `SoLd`, and `lnkD` with +/// version-2 items. +fn cs2_layout(doc: &Document) -> Vec { + let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); + let mut file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + for rec in file.layers_mut() { + rec.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE")); + } + assert!(file.layers().iter().any(|l| l.block(b"PlLd").is_some())); + let links = file.global_blocks.iter_mut().find(|b| &b.key == b"lnk2").unwrap(); + let files = photocraft_io::linked::parse_linked_files(&links.data); + assert_eq!(files.len(), 1); + links.key = *b"lnkD"; + links.data = files.iter().flat_map(lifd_v2).collect(); + file.to_bytes().unwrap() +} + +/// Photoshop CS2 predates `SoLd`: its placed layers carry a `PlLd` block alone, and the embedded +/// file sits in a global `lnkD` block of older `liFD` items. Such a smart object's contents open +/// (they were "unavailable") and it re-renders in place from them (#2465). +#[test] +fn cs2_plld_only_smart_object_opens_its_contents() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + let SmartSource::Embedded { bytes: pcraft, .. } = &before.source else { panic!("embedded") }; + let nested = decode_source("x.pcraft", pcraft).unwrap(); + + let back = photocraft_io::import("cs2.psd", &cs2_layout(&doc)).unwrap().document; + let (bid, after) = smart(&back); + for (a, b) in after.transform.m.iter().zip(before.transform.m) { + assert!((a - b).abs() < 1e-9, "transform {:?} vs {:?}", after.transform, before.transform); + } + let (name, src) = photocraft_engine::smart_cmds::contents_of(&back, photocraft_doc::LayerId(bid)).expect("the contents are available"); + let inner = decode_source(&name, &src).unwrap(); + assert_eq!((inner.size, inner.layers.len()), (nested.size, nested.layers.len())); + // It re-renders where it was placed. + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(&back, &mut l).unwrap()); + let mut rerendered = back.clone(); + *rerendered.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l; + let d = max_diff(&photocraft_compose::flatten(&rerendered).px, &photocraft_compose::flatten(&doc).px); + assert!(d <= 2.0 / 255.0, "re-render differs by {d}"); + // And Edit Contents (the double-click) opens the nested document. + let mut s2 = Session::new(); + s2.add_document(back, None); + s2.select_layer(photocraft_doc::LayerId(bid)).unwrap(); + s2.execute("layer.smartObjects.editContents", json!({})).unwrap(); + assert_eq!(s2.active().unwrap().doc.size, nested.size); +} + +/// CS2 can warp a smart object, and `PlLd` keeps the warp after the corners. Now that a +/// `PlLd`-only layer's contents are available, Free Transform, Image Size and mode changes +/// re-render it from them: it must come back warped, not silently unwarped (#2465). +#[test] +fn cs2_plld_only_warped_smart_object_stays_warped() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + s.execute("layer.smartObjects.warp", json!({"style": "wave", "bend": 60})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + assert!(before.warp.is_some()); + + let back = photocraft_io::import("cs2.psd", &cs2_layout(&doc)).unwrap().document; + let (bid, after) = smart(&back); + assert_eq!(after.warp, before.warp); + assert!(photocraft_engine::smart_cmds::contents_of(&back, photocraft_doc::LayerId(bid)).is_ok(), "the contents are available"); + // Re-rendered from its source, it is still warped where it was placed. + let rerender = |warp| { + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + let LayerContent::Smart(sm) = &mut l.content else { panic!() }; + sm.warp = warp; + assert!(refresh_layer(&back, &mut l).unwrap()); + let mut d = back.clone(); + *d.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l; + max_diff(&photocraft_compose::flatten(&d).px, &photocraft_compose::flatten(&doc).px) + }; + let d = rerender(after.warp.clone()); + assert!(d <= 2.0 / 255.0, "re-render differs by {d}"); + assert!(rerender(None) > 0.1, "an unwarped re-render would differ visibly"); +} + +/// A CS2 `PlLd`-only smart object is saved as it was while nothing changed it, and with its +/// current placement once it is moved: the old record was written back unchanged, so the save +/// reopened at the old place (#2465). +#[test] +fn cs2_plld_only_smart_object_saves_its_new_transform() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + let cs2 = cs2_layout(&s.active().unwrap().doc); + let plld = |bytes: &[u8]| { + let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + let rec = f.layers().iter().find(|l| l.block(b"PlLd").is_some()).cloned().expect("a placed layer"); + (rec.block(b"PlLd").unwrap().data.clone(), rec.block(b"SoLd").is_some()) + }; + let back = photocraft_io::import("cs2.psd", &cs2).unwrap().document; + let (bid, _) = smart(&back); + + // Unedited: the CS2 record is written back as it was. + let same = photocraft_io::export(&back, "psd", &Default::default()).unwrap().bytes; + assert_eq!(plld(&same), (plld(&cs2).0, false)); + + // Moved and scaled: written with the new placement, and it reopens there. + let mut s2 = Session::new(); + s2.add_document(back, None); + s2.execute("edit.transform", json!({"layer": bid, "matrix": [0.5, 0, 0, 0.5, 20, 12]})).unwrap(); + let edited = s2.active().unwrap().doc.clone(); + let (_, want) = smart(&edited); + let saved = photocraft_io::export(&edited, "psd", &Default::default()).unwrap().bytes; + assert!(structure_errors(&saved).is_empty(), "{:?}", structure_errors(&saved)); + assert_ne!(plld(&saved).0, plld(&cs2).0, "the old PlLd record was written back"); + let again = photocraft_io::import("again.psd", &saved).unwrap().document; + let (aid, got) = smart(&again); + for (a, b) in got.transform.m.iter().zip(want.transform.m) { + assert!((a - b).abs() < 1e-9, "transform {:?} vs {:?}", got.transform, want.transform); + } + assert!(photocraft_engine::smart_cmds::contents_of(&again, photocraft_doc::LayerId(aid)).is_ok(), "the contents are available"); +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 47ea5383d7..a26219033b 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -140,7 +140,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { psd_raw: opt_blob(&t.psd_raw, sink), runs: t.runs.clone(), paragraphs: t.paragraphs.clone(), - shape: t.shape, + shape: t.shape.clone(), orientation: t.orientation, antialias: t.antialias, warp: t.warp.clone(), @@ -459,7 +459,7 @@ impl Loader<'_> { psd_raw: self.opt_blob(psd_raw)?, runs: runs.clone(), paragraphs: paragraphs.clone(), - shape: *shape, + shape: shape.clone(), orientation: *orientation, antialias: *antialias, warp: warp.clone(), diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 42dbfae5c3..8b74a00c4b 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -541,6 +541,27 @@ fn text_kerning_roundtrips_and_defaults() { assert!(s.contains("\"kern\":0.0"), "kern defaults to 0"); } +/// Type on a path (#218): the path, start offset and flip survive .pcraft. +#[test] +fn path_text_shape_roundtrips() { + use photocraft_doc::text::TextShape; + use photocraft_doc::vector::Subpath; + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let path = Subpath::polyline(&[(0.0, 0.0), (50.0, 20.0), (120.0, 5.5)]); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + for id in ids { + if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { + t.shape = TextShape::Path { path: path.clone(), start: 12.5, flip: true }; + } + } + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let shape = back.walk().into_iter().find_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t.shape.clone()), + _ => None, + }); + assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, flip: true })); +} + #[test] fn channel_restrictions_and_bevel_elements_roundtrip() { use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect}; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index edfe140be4..e9d1b370ca 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -51,6 +51,7 @@ const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB const F_HDR: u32 = 262144u; // 32-bit float document: HDR blends and Exposure retain values above 1 const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs) const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) +const F_BLEND_IF: u32 = 524288u; // lerp: B back towards A by the Blend If ranges in p0..p3 const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w @group(0) @binding(0) var chunk: Chunk; @@ -627,15 +628,14 @@ fn adjust(c: vec3) -> vec3 { let hsl = rgb_to_hsl(c); var hh: f32; var ss: f32; - // Range edits (LUT rows indexed by the original hue, faded out towards grey). + // Range edits (LUT rows indexed by the original hue), in full however dull the colour. var dh = 0.0; var ds = 0.0; var dl = 0.0; if (p1.x > 0.5) { - let chroma = min((max(c.r, max(c.g, c.b)) - min(c.r, min(c.g, c.b))) * 4.0, 1.0); - dh = lut(0, hsl.x) * chroma; - ds = lut(1, hsl.x) * chroma; - dl = lut(2, hsl.x) * chroma; + dh = lut(0, hsl.x); + ds = lut(1, hsl.x); + dl = lut(2, hsl.x); } let sat = clamp(p0.y + ds, -1.0, 1.0); let light = clamp(p0.z, -1.0, 1.0); @@ -875,6 +875,21 @@ fn fs_lerp(in: VOut) -> @location(0) vec4 { if (al <= 0.0) { return vec4(0.0); } return vec4(clamp(pm.rgb / al, vec3(0.0), vec3(1.0)), al); } + if ((op.flags & F_BLEND_IF) != 0u) { + // compose::apply_blend_if: "This Layer" is C (the layer's own content with its masks), + // or B itself for an adjustment layer; "Underlying Layer" is A. + var this_px = b; + if (op.p4.y < 0.5) { + this_px = textureLoad(tex_c, p, 0); + } + let k = blend_if_weight(this_px, a); + if (k >= 1.0) { + return b; + } + let alpha = a.a + (b.a - a.a) * k; + if (alpha <= 0.0) { return vec4(0.0); } + return vec4(clamp((a.rgb * a.a + (b.rgb * b.a - a.rgb * a.a) * k) / alpha, vec3(0.0), vec3(1.0)), alpha); + } let k = op.opacity * mask_value(doc_px(p)); let wa = a.a * (1.0 - k); let wb = b.a * k; @@ -883,6 +898,57 @@ fn fs_lerp(in: VOut) -> @location(0) vec4 { return vec4((a.rgb * wa + b.rgb * wb) / alpha, alpha); } +// ---- Blend If (photocraft_doc::BlendRange, compose::blend_if_weight) --------------------------- + +// 0 at or below `lo`, 1 at or above `hi`; a hard step at `lo` when unsplit. +fn bi_ramp(lo: f32, hi: f32, x: f32) -> f32 { + if (hi <= lo) { + return select(0.0, 1.0, x >= lo); + } + return clamp((x - lo) / (hi - lo), 0.0, 1.0); +} + +// How much of value `v` (0..255) shows through range `e` (0 = Gray, then the channels) on side +// `s` (0 = this layer, 1 = underlying). The ranges are packed by `plan::blend_if_params`. +fn bi_weight(e: u32, s: u32, v: f32) -> f32 { + var f = array, 4>(op.p0, op.p1, op.p2, op.p3); + let i = (e * 2u + s) * 2u; + let black = f[i / 4u][i % 4u]; + let white = f[(i + 1u) / 4u][(i + 1u) % 4u]; + let b_lo = floor(black / 256.0); + let b_hi = black - b_lo * 256.0; + let w_lo = floor(white / 256.0); + let w_hi = white - w_lo * 256.0; + return bi_ramp(b_lo, b_hi, v) * bi_ramp(255.0 - w_hi, 255.0 - w_lo, 255.0 - v); +} + +// The product of every range over the layer's own colour and the colour beneath it (a side +// without content, alpha 0, doesn't count). `p4.x` marks a one-channel document. +fn blend_if_weight(this_px: vec4, under: vec4) -> f32 { + var k = 1.0; + for (var s = 0u; s < 2u; s = s + 1u) { + let px = select(this_px, under, s == 1u); + if (px.a <= 0.0) { + continue; + } + // The CPU gets 8- and 16-bit levels exactly (k / 255 × 255 = k), but GPU arithmetic can + // land a few millionths below, which flips a hard threshold at that level: values that + // close to a level are that level. Real 8/16-bit values off a level are ≥ 1/257 away. + let raw = clamp(px.rgb, vec3(0.0), vec3(1.0)) * 255.0; + let v = select(raw, round(raw), abs(raw - round(raw)) < vec3(1e-4)); + if (op.p4.x > 0.5) { + k = k * bi_weight(0u, s, v.r) * bi_weight(1u, s, v.r); + } else { + let gray = 0.299 * v.r + 0.587 * v.g + 0.114 * v.b; + k = k * bi_weight(0u, s, gray) * bi_weight(1u, s, v.r) * bi_weight(2u, s, v.g) * bi_weight(3u, s, v.b); + } + if (k <= 0.0) { + return 0.0; + } + } + return k; +} + // ---- layer effects (compose::effects::composite_with_effects) ------------------------------- const INSIDE_EPS: f32 = 0.5 / 255.0; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index fb135dc135..b8c083c7af 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -9,7 +9,8 @@ //! effect maps (shadow / glow / satin / bevel coverage, stroke bands) are built once per layer //! state by [`crate::fx`] and sampled here; exterior effects paint into a copy of the backdrop, //! the layer at fill opacity takes the interior effects, and the two merge with the layer's mode -//! and opacity. Effect passes are clipped to the layer's effect region and the result is copied +//! (or the layer merges first and the effects paint over it: `effects::premerges_layer`) and +//! opacity. Effect passes are clipped to the layer's effect region and the result is copied //! back into the backdrop in place, so a small text layer costs only its own pixels. use photocraft_color::BlendMode; @@ -387,9 +388,30 @@ pub const F_HDR: u32 = 262144; pub const F_QUANT: u32 = 32768; /// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups). pub const F_ADD_DIFF: u32 = 16384; +/// `Lerp`: B mixed back towards A (the backdrop) by the layer's Blend If weight +/// (`compose::apply_blend_if`), from C ("This Layer", or B itself for an adjustment layer, `p4.y`) +/// and A ("Underlying Layer"). The ranges are packed in `p0..p3` (`blend_if_params`); `p4.x` marks +/// a one-channel document. +pub const F_BLEND_IF: u32 = 524288; /// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`. pub const F_TEXT_GAMMA: u32 = 8192; +/// A layer's Blend If ranges for `F_BLEND_IF`: Gray, then the colour channels, each as +/// [this layer, underlying layer]; every range as two floats, `black low × 256 + black high` and +/// `white low × 256 + white high` (exact in f32), so entry `e`, side `s` starts at float +/// `(e × 2 + s) × 2`. Entries past the fourth never apply (RGB has four, one-channel modes two). +fn blend_if_params(bi: &photocraft_doc::BlendIf) -> [[f32; 4]; 4] { + let mut f = [0.0f32; 16]; + for e in 0..4 { + for (s, r) in bi.get(e).iter().enumerate() { + let i = (e * 2 + s) * 2; + f[i] = f32::from(r.black[0]) * 256.0 + f32::from(r.black[1]); + f[i + 1] = f32::from(r.white[0]) * 256.0 + f32::from(r.white[1]); + } + } + [[f[0], f[1], f[2], f[3]], [f[4], f[5], f[6], f[7]], [f[8], f[9], f[10], f[11]], [f[12], f[13], f[14], f[15]]] +} + /// `F_TEXT_GAMMA` for type layers while text gamma blending is on; the gamma goes in `p4.w`. fn gamma_flag(layer: &Layer) -> u32 { if photocraft_compose::text_gamma(layer) != 1.0 { F_TEXT_GAMMA } else { 0 } @@ -514,12 +536,44 @@ impl<'a> Planner<'a> { }) } - /// Blending Options › Blend If has no GPU pass yet: such documents use the CPU compositor. - fn check_blend_if(&self, layer: &Layer) -> Result<(), Unsupported> { - if photocraft_compose::blend_if_active(layer, self.cx.mode) { - return Err(Unsupported(format!("Blend If on `{}` (composited on the CPU)", layer.name))); + /// `compose::apply_blend_if`: `after` (the backdrop with `layer` drawn) mixed back towards + /// `before` (the backdrop without it) where the layer's Blend If ranges hide it. "This Layer" + /// is the layer's own content with its masks; an adjustment layer is judged by its result. + /// Consumes both slots. + fn blend_if(&mut self, layer: &'a Layer, before: Slot, after: Slot) -> Result { + let own = match layer.content { + LayerContent::Adjustment(_) => None, + _ => Some(self.content(layer)?), + }; + let mut p = Pass::new(Kernel::Lerp, 0); + p.a = Some(before); + p.b = Some(after); + p.c = own; + p.flags = F_BLEND_IF; + p.params = blend_if_params(&layer.blend_if); + let one_channel = self.cx.mode != photocraft_color::ColorMode::Rgb; + p.extra = [f32::from(u8::from(one_channel)), f32::from(u8::from(own.is_none())), 0.0, 0.0]; + Ok(self.emit(p)) + } + + /// Channel restrictions and Blend If around `draw`, which composites `layer` onto `backdrop` + /// (`compose::composite_layer` / `composite_atop`: both are relative to the same backdrop). + fn restricted(&mut self, layer: &'a Layer, backdrop: Slot, draw: impl FnOnce(&mut Self, Slot) -> Result) -> Result { + let w = photocraft_compose::channel_weights(layer, self.cx.mode); + let blend_if = photocraft_compose::blend_if_active(layer, self.cx.mode); + if w.is_none() && !blend_if { + return draw(self, backdrop); } - Ok(()) + let before = self.retain(backdrop); + let mut after = draw(self, backdrop)?; + if let Some(w) = w { + let b = if blend_if { self.retain(before) } else { before }; + after = self.restore_channels(b, after, w); + } + if blend_if { + after = self.blend_if(layer, before, after)?; + } + Ok(after) } /// Blending Options › Advanced Blending (knockout, clipped layers blended individually, blend @@ -535,16 +589,8 @@ impl<'a> Planner<'a> { /// composite_layer, honouring the layer's channel restrictions. fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { - self.check_blend_if(layer)?; self.check_advanced(layer, clipped)?; - match photocraft_compose::channel_weights(layer, self.cx.mode) { - Some(w) => { - let before = self.retain(backdrop); - let after = self.layer_any(layer, clipped, backdrop)?; - Ok(self.restore_channels(before, after, w)) - } - None => self.layer_any(layer, clipped, backdrop), - } + self.restricted(layer, backdrop, |s, b| s.layer_any(layer, clipped, b)) } /// `compose::restore_channels`: `after` with the channels a layer leaves out taken from @@ -710,7 +756,6 @@ impl<'a> Planner<'a> { if clip.is_some_and(|c| c.is_empty()) { // Nothing to draw (an empty layer, or a group of them). for c in &visible_clipped { - self.check_blend_if(c)?; self.check_advanced(c, &[])?; } return Ok(backdrop); @@ -893,16 +938,8 @@ impl<'a> Planner<'a> { /// composite_atop: `layer` onto `base`, restricted to the base's alpha, honouring the /// layer's channel restrictions. fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result { - self.check_blend_if(layer)?; self.check_advanced(layer, &[])?; - match photocraft_compose::channel_weights(layer, self.cx.mode) { - Some(w) => { - let before = self.retain(base); - let after = self.atop_any(layer, base)?; - Ok(self.restore_channels(before, after, w)) - } - None => self.atop_any(layer, base), - } + self.restricted(layer, base, |s, b| s.atop_any(layer, b)) } fn atop_any(&mut self, layer: &'a Layer, base: Slot) -> Result { @@ -1095,6 +1132,19 @@ impl<'a> Planner<'a> { } else { init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity) }; + // Blend Interior Effects as Group off: the layer merges in its mode first and the + // interior effects paint over the result (`effects::premerges_layer`). + let premerge = photocraft_compose::effects::premerges_layer(layer); + if premerge { + let mut p = Pass::new(Kernel::Blend, 0); + p.a = Some(self.retain(w)); + p.b = Some(l); + p.mode = layer.blend; + p.flags = gamma_flag(layer); + p.extra[3] = photocraft_compose::text_gamma(layer); + p.clip = Some(clip); + l = self.emit(p); + } for &(_, e) in &rev { if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) @@ -1257,7 +1307,7 @@ impl<'a> Planner<'a> { } } - let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev .iter() .filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None }) @@ -1514,13 +1564,18 @@ fn to_row(t: &[f32]) -> [f32; 4096] { /// Whether the adjustment kernel can evaluate `adj` in a document of colour `mode`: Levels and /// Curves on CMYK ink or Lab channels convert through ICC profiles per pixel, and so do the -/// adjustments a Lab document evaluates on L, a and b (`photocraft_compose::adjust::lab_channel_adjustment`), -/// which only the CPU does. +/// adjustments a Lab document evaluates on L, a and b (`photocraft_compose::adjust::lab_channel_adjustment`) +/// and a CMYK document on its inks (`cmyk_channel_adjustment`), which only the CPU does. pub fn adjustment_on_gpu(adj: &Adjustment, mode: photocraft_color::ColorMode) -> bool { + use photocraft_compose::adjust::{cmyk_channel_adjustment, lab_channel_adjustment}; let tone_in_space = matches!(adj, Adjustment::Levels { space: ToneSpace::Cmyk | ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Cmyk | ToneSpace::Lab, .. }); - let lab_channels = mode == photocraft_color::ColorMode::Lab && photocraft_compose::adjust::lab_channel_adjustment(adj); - !tone_in_space && !lab_channels + let own_channels = match mode { + photocraft_color::ColorMode::Lab => lab_channel_adjustment(adj), + photocraft_color::ColorMode::Cmyk => cmyk_channel_adjustment(adj), + _ => false, + }; + !tone_in_space && !own_channels } /// A tone transfer as the shader's `t_decode`/`t_encode` exponent (0: the sRGB curve). @@ -1684,7 +1739,8 @@ fn gradient_rows(g: &Gradient) -> Vec<[f32; 4096]> { fn check_gradient_stops(stops: &[(f32, T)]) -> Result<(), Unsupported> { let mut positions: Vec = stops.iter().map(|s| s.0).collect(); positions.sort_by(f32::total_cmp); - if positions.windows(2).any(|w| w[0] == w[1]) { + // A 4096-entry LUT cannot keep two stops that fall in the same entry apart (#3053). + if positions.windows(2).any(|w| (w[1] - w[0]).abs() < 1.0 / 4095.0) { return Err(Unsupported("coincident gradient stops (composited on the CPU)".into())); } Ok(()) @@ -1736,6 +1792,12 @@ mod tests { use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_geom::Size; + #[test] + fn stops_closer_than_one_lut_step_stay_on_the_cpu() { + assert!(check_gradient_stops(&[(0.4999_f32, ()), (0.5, ())]).is_err()); + assert!(check_gradient_stops(&[(0.0_f32, ()), (1.0, ())]).is_ok()); + } + #[test] fn lab_channel_adjustments_defer_to_the_cpu_in_lab_documents() { let lab_rules = [ @@ -1747,9 +1809,12 @@ mod tests { ]; for adj in &lab_rules { assert!(!adjustment_on_gpu(adj, ColorMode::Lab), "{adj:?}"); - for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale] { + for mode in [ColorMode::Rgb, ColorMode::Grayscale] { assert!(adjustment_on_gpu(adj, mode), "{adj:?} in {mode:?}"); } + // CMYK documents invert, posterize and threshold their inks, on the CPU. + let inks = matches!(adj, Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. }); + assert_eq!(adjustment_on_gpu(adj, ColorMode::Cmyk), !inks, "{adj:?} in CMYK"); } // Screen-colour adjustments stay on the GPU in Lab documents. assert!(adjustment_on_gpu(&Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, ColorMode::Lab)); @@ -1951,17 +2016,32 @@ mod tests { } #[test] - fn blend_if_falls_back_to_the_cpu() { + fn blend_if_plans_on_the_gpu() { let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); let mut l = Layer::raster("bi", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); l.blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]); d.layers.push(l.clone()); - assert!(plan(&d).unwrap_err().0.contains("Blend If")); + let blend_if = |d: &Document| plan(d).unwrap().passes.iter().filter(|p| p.flags & F_BLEND_IF != 0).count(); + assert_eq!(blend_if(&d), 1); // Clipped layers too. l.clipped = true; d.layers[1].blend_if = Default::default(); d.layers.push(l); - assert!(plan(&d).is_err()); + assert_eq!(blend_if(&d), 1); + // Outside RGB, Grayscale and Duotone the setting round-trips without applying. + d.mode = ColorMode::Lab; + assert_eq!(blend_if(&d), 0); + } + + #[test] + fn blend_if_params_pack_every_range_exactly() { + let mut bi = photocraft_doc::BlendIf::default(); + bi.set(3, [photocraft_doc::BlendRange { black: [255, 255], white: [0, 1] }, photocraft_doc::BlendRange { black: [12, 200], white: [201, 254] }]); + let f: Vec = blend_if_params(&bi).iter().flatten().copied().collect(); + // Entry 3 (B), this layer then underlying; unset entries are full ranges. + assert_eq!(&f[12..16], &[255.0 * 256.0 + 255.0, 1.0, 12.0 * 256.0 + 200.0, 201.0 * 256.0 + 254.0]); + assert_eq!(&f[0..2], &[0.0, 255.0 * 256.0 + 255.0]); } #[test] diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 47837a8b65..944c756662 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -370,7 +370,7 @@ fn prefix_partial_damage_context_occlusion_and_budget_are_conservative() { check(&mut g, &doc, "bounded admission"); assert!(g.comp.composite_cache_bytes() <= 1 << 20); assert!(diff_rect(&mut g, &doc, doc.bounds(), "bounded cache keeps its hits").unwrap().prefix_hits > 0); - doc.layers[0].blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]); + doc.layers[0].advanced.knockout = photocraft_doc::Knockout::Deep; assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).is_err()); assert_eq!(g.comp.composite_cache_bytes(), 0); } @@ -1461,6 +1461,7 @@ fn layer_effects_opacity_fill_blend_and_off_canvas() { (0.6, 0.35, BlendMode::Multiply), (1.0, 0.5, BlendMode::Screen), (0.8, 1.0, BlendMode::Dissolve), + (1.0, 1.0, BlendMode::Color), ] { let mut d = fx_doc(96, 80, SampleType::U8); let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]); @@ -1698,6 +1699,78 @@ fn stroke_effects_on_filled_and_stroked_shapes() { } } +/// Blend If on the GPU matches `compose::apply_blend_if` (#4395): this-layer and underlying +/// ranges, hard and split points, per channel, on raster, masked, adjustment and clipped layers, +/// with channel restrictions, and in grayscale. Hard points are compared on 8-bit documents only: +/// 16/32-bit layers reach the GPU as RGBA16F, so a value within about a tenth of a level of a +/// hard point can land on its other side (as with the GPU Threshold adjustment, also compared at +/// 8 bits); split points are compared at every depth. +#[test] +fn blend_if() { + use photocraft_doc::BlendRange; + let Some(mut g) = gpu() else { return }; + let r = |b0: u8, b1: u8, w0: u8, w1: u8| BlendRange { black: [b0, b1], white: [w0, w1] }; + // (name, entry, [this layer, underlying], has a hard point) + let cases: [(&str, usize, [BlendRange; 2], bool); 7] = [ + ("gray this hard", 0, [r(90, 90, 255, 255), BlendRange::FULL], true), + ("gray under split", 0, [BlendRange::FULL, r(30, 140, 150, 230)], false), + ("red both", 1, [r(0, 60, 200, 200), r(20, 20, 180, 250)], true), + ("red both split", 1, [r(0, 60, 190, 230), r(10, 50, 180, 250)], false), + ("green white split", 2, [r(0, 0, 100, 220), BlendRange::FULL], false), + ("blue under hard", 3, [BlendRange::FULL, r(128, 128, 255, 255)], true), + ("everything hidden", 0, [r(255, 255, 0, 0), BlendRange::FULL], true), + ]; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat { sample: depth, ..PixelFormat::RGBA8 }; + for (name, e, pair, hard) in cases { + if hard && depth != SampleType::U8 { + continue; + } + let mut d = Document::new("bi", Size::new(64, 48), ColorMode::Rgb, depth); + d.layers.push(noise_layer("bg", fmt, Rect::new(0, 0, 64, 48), 1, 0.3)); + let mut l = noise_layer("top", fmt, Rect::new(4, 3, 60, 45), 2, 0.0); + l.blend = BlendMode::Multiply; + l.opacity = 0.8; + l.blend_if.set(e, pair); + d.layers.push(l.clone()); + check(&mut g, &d, &format!("{depth:?} {name}")); + // Edits above it reuse the lower stack, Blend If included (prefix checkpoints). + let mut above = d.clone(); + above.layers.push(noise_layer("above", fmt, Rect::new(20, 0, 40, 48), 4, 0.5)); + check(&mut g, &above, &format!("{depth:?} {name} under another layer")); + above.layers[2].opacity = 0.4; + let hits = diff_rect(&mut g, &above, above.bounds(), &format!("{depth:?} {name} edit above")).unwrap_or_else(|e| panic!("{e}")); + assert!(hits.prefix_hits > 0, "{depth:?} {name}: the edit above reuses the lower stack"); + + // Masked, with channel restrictions. + let mut m = l.clone(); + m.mask = Some(mask(Rect::new(0, 0, 64, 48), 5, 1.0)); + m.excluded_channels = 0b010; + d.layers[1] = m; + check(&mut g, &d, &format!("{depth:?} {name} masked, channels")); + + // An adjustment judged by its result, and a clipped layer. + let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + adj.blend_if.set(e, pair); + d.layers[1] = l.clone(); + d.layers[1].blend_if = Default::default(); + d.layers.push(adj); + let mut c = noise_layer("clip", fmt, Rect::new(10, 10, 50, 40), 3, 0.2); + c.clipped = true; + c.blend_if.set(e, pair); + d.layers.push(c); + check(&mut g, &d, &format!("{depth:?} {name} adjustment, clipped")); + } + } + // Grayscale: the one channel's entries. + let mut d = Document::new("g", Size::new(32, 32), ColorMode::Grayscale, SampleType::U8); + d.layers.push(noise_layer("bg", PixelFormat::GRAYA8, Rect::new(0, 0, 32, 32), 9, 1.0)); + let mut l = noise_layer("top", PixelFormat::GRAYA8, Rect::new(0, 0, 32, 32), 10, 1.0); + l.blend_if.set(1, [r(60, 120, 255, 255), r(0, 0, 140, 200)]); + d.layers.push(l); + check(&mut g, &d, "gray blend if"); +} + #[test] fn channel_restrictions() { let Some(mut g) = gpu() else { return }; diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 9338d110a2..e7fd290f0e 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -86,7 +86,7 @@ pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrup } }; let name = if b.name.trim().is_empty() { default_name } else { b.name.trim().to_string() }; - out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string(), folder: Vec::new() }); + out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string(), folder: Vec::new(), captures: Default::default() }); } } else if f.presets.is_empty() { // Tips without a settings section (early v6 files): one preset per tip. @@ -94,7 +94,14 @@ pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrup step(i)?; let (tip, size) = m.tip(s); let brush = BrushSettings { pressure_size: false, spacing: 0.25, size, tip, ..Default::default() }; - out.presets.push(BrushPreset { name: format!("Sampled Brush {}", i + 1), brush, builtin: false, group: group.to_string(), folder: Vec::new() }); + out.presets.push(BrushPreset { + name: format!("Sampled Brush {}", i + 1), + brush, + builtin: false, + group: group.to_string(), + folder: Vec::new(), + captures: Default::default(), + }); } } else { for (i, d) in f.presets.iter().enumerate() { @@ -105,7 +112,7 @@ pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrup // named after the imported file. Some(brush) => { let folder = f.folders.get(i).cloned().unwrap_or_default(); - out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string(), folder }); + out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string(), folder, captures: Default::default() }); } None => { m.warnings.insert(format!("\"{name}\": its sampled tip is missing from the file; skipped")); diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 28ab181c83..27b6c04477 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -133,6 +133,11 @@ fn parse_entries(data: &[u8]) -> Option> { for _ in 0..count { let start = r.p; let len = r.u32()? as usize; + // The length counts itself and the type: anything shorter would put the cursor back on + // this entry, and a hostile count would then loop until memory runs out. + if len < 8 { + return None; + } let end = start.checked_add(len)?; let raw = data.get(start..end)?; let kind = r.take(4)?; @@ -422,6 +427,22 @@ mod tests { assert!(!anno_unreadable(&d)); } + /// An entry length below 8 (its own `u32` plus the type) put the cursor back on the same + /// entry, so a count of 0xFFFFFFFF looped ~4.3e9 times and ran out of memory. + #[test] + fn short_entry_length_is_unreadable_not_a_loop() { + for len in [0u8, 4, 7] { + for kind in [b"sndA", b"txtA"] { + let mut b = vec![0, 2, 0, 1, 0, 0, 0, 3, 0, 0, 0, len]; + b.extend_from_slice(kind); + b.extend_from_slice(&[0; 64]); + assert!(parse_entries(&b).is_none(), "len {len} {kind:?}"); + } + } + let hostile = [0, 2, 0, 1, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0, b's', b'n', b'd', b'A']; + assert!(parse_anno(&hostile).is_none()); + } + #[test] fn scale_resource_roundtrip_and_keep_rules() { let s = MeasurementScale { pixel_length: 150.0, logical_length: 2.0, units: "mm".into() }; diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 3a6b220d18..87fda102fe 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -527,15 +527,25 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, /// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, /// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. +/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` +/// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { - use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; if key != b"SoLd" && key != b"SoLE" { return None; } let d = data.get(8..).and_then(parse_prefix_versioned)?; - let w = get_desc(&d, "warp")?; + warp_of(get_desc(&d, "warp")?).flatten() +} + +/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners) +/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it +/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4). +fn warp_of(w: &Descriptor) -> Option> { + use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; + if style == WarpStyle::None { + return Some(None); + } let b = get_desc(w, "bounds")?; let g = |k: &str| num(b.get(k)); let bounds = [g("Left")?, g("Top ")?, g("Rght")?, g("Btom")?]; @@ -558,7 +568,7 @@ pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option, w: f64, h if ok { b } else { rect } } -/// Smart object identifier and transform from `SoLd`/`PlLd` (best effort). +/// Smart object identifier and transform from `SoLd`/`SoLE` (best effort). A `PlLd` block +/// stores no source size, so its transform needs the embedded file: see [`parse_plld`]. pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { // SoLd: 'soLD' + version(4) + versioned descriptor. let desc = if key == b"SoLd" || key == b"SoLE" { data.get(8..).and_then(parse_prefix_versioned) } else { None }; @@ -611,16 +622,97 @@ pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { if let (Some(Value::List(pts)), Some(sz)) = (d.get("Trnf"), get_desc(&d, "Sz ")) { let p: Vec = pts.iter().filter_map(|v| num(Some(v))).collect(); let (w, h) = (num(sz.get("Wdth")).unwrap_or(0.0), num(sz.get("Hght")).unwrap_or(0.0)); - if p.len() == 8 && w > 0.0 && h > 0.0 { - let [x0, y0, x1, y1] = placed_frame(parse_placed_warp(key, data).as_ref(), w, h); - let (bw, bh) = (x1 - x0, y1 - y0); - let (a, b, c, d) = ((p[2] - p[0]) / bw, (p[3] - p[1]) / bw, (p[6] - p[0]) / bh, (p[7] - p[1]) / bh); - affine = Affine { m: [a, b, c, d, p[0] - a * x0 - c * y0, p[1] - b * x0 - d * y0] }; + if let (Ok(p), true) = (<[f64; 8]>::try_from(p), w > 0.0 && h > 0.0) { + affine = corners_affine(&p, placed_frame(parse_placed_warp(key, data).as_ref(), w, h)).unwrap_or(Affine::IDENTITY); } } (id, affine) } +/// The affine that takes the source-space box `frame` (left, top, right, bottom; see +/// [`placed_frame`]) onto placed corners `p` (top-left, top-right, bottom-right, bottom-left as +/// x, y pairs). `None` for an empty or non-finite frame, or non-finite corners. +pub fn corners_affine(p: &[f64; 8], frame: [f64; 4]) -> Option { + let [x0, y0, x1, y1] = frame; + let (bw, bh) = (x1 - x0, y1 - y0); + if !(frame.iter().chain(p).all(|v| v.is_finite()) && bw > 0.0 && bh > 0.0) { + return None; + } + let (a, b, c, d) = ((p[2] - p[0]) / bw, (p[3] - p[1]) / bw, (p[6] - p[0]) / bh, (p[7] - p[1]) / bh); + let m = [a, b, c, d, p[0] - a * x0 - c * y0, p[1] - b * x0 - d * y0]; + m.iter().all(|v| v.is_finite()).then_some(Affine { m }) +} + +/// The transform and (Distort/Perspective) projective placement of a `PlLd` record's corners `p` +/// for a source of `size` warped by `warp`: the inverse of [`crate::smart_map::plld_bytes`]'s +/// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a single-patch custom warp's +/// control-point box (which may spill past the source), else the source rect; projective corners +/// frame the source rect, as `parse_smart_perspective` reads them. `PlLd` stores no source size: +/// without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths being +/// its size (a custom warp's box needs no size); position and rotation stay right, only Replace +/// Contents and re-renders depend on the scale. Import and export (which keeps an unedited record +/// verbatim) both place a `PlLd` layer with this. +pub fn plld_transform(p: &[f64; 8], size: Option<(f64, f64)>, warp: Option<&photocraft_geom::warp::Warp>) -> (Affine, Option<[f64; 9]>) { + let (w, h) = size.unwrap_or_else(|| ((p[2] - p[0]).hypot(p[3] - p[1]), (p[6] - p[0]).hypot(p[7] - p[1]))); + let perspective = corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (w, h)); + let frame = if perspective.is_some() { [0.0, 0.0, w, h] } else { placed_frame(warp, w, h) }; + (corners_affine(p, frame).unwrap_or(Affine::IDENTITY), perspective) +} + +/// The source identifier and placed corners of a `PlLd` block, the placed-layer record Photoshop +/// CS2 and older write with no `SoLd` next to it (#2465). Adobe PSD spec, "Placed Layer": `plcL`, +/// version, Pascal unique id (unpadded), page, total pages, anti-alias policy, layer type, then +/// 8 doubles: the x, y of each corner (top-left, top-right, bottom-right, bottom-left). The warp +/// that follows is read by [`parse_plld_warp`]. `None` for anything else or a truncated record. +pub fn parse_plld(data: &[u8]) -> Option<(String, [f64; 8])> { + plld_record(data).map(|(id, corners, _)| (id, corners)) +} + +/// [`parse_plld`] and the offset just past the corners, where the warp starts. +fn plld_record(data: &[u8]) -> Option<(String, [f64; 8], usize)> { + if data.get(..4)? != b"plcL" { + return None; + } + let n = usize::from(*data.get(8)?); + let id = String::from_utf8_lossy(data.get(9..9 + n)?).into_owned(); + // Page number, total pages, anti-alias policy and layer type (4 × u32). + let at = 9 + n + 16; + let mut corners = [0.0; 8]; + for (i, c) in corners.iter_mut().enumerate() { + let b = data.get(at + i * 8..at + i * 8 + 8)?; + *c = f64::from_be_bytes(b.try_into().ok()?); + } + corners.iter().all(|v| v.is_finite()).then_some((id, corners, at + 64)) +} + +/// The warp of a placed layer's `PlLd` record (Photoshop CS2 can warp a smart object). +#[derive(Debug, Clone, PartialEq)] +pub enum PlldWarp { + /// No warp: `warpNone`, a warp that moves nothing, or a record that ends at its corners. + None, + /// The warp, in source pixels like the `SoLd` one. + Warp(photocraft_geom::warp::Warp), + /// A warp is stored but can't be read (or the record itself can't be): re-rendering the + /// layer from its source would drop it. + Unreadable, +} + +/// The warp after a `PlLd` record's corners: a u32 warp version, then a versioned `warp` +/// descriptor (Adobe PSD spec, "Placed Layer"; the layout [`crate::smart_map::plld_bytes`] +/// writes). +pub fn parse_plld_warp(data: &[u8]) -> PlldWarp { + let Some(rest) = plld_record(data).and_then(|(_, _, end)| data.get(end..)) else { return PlldWarp::Unreadable }; + // Only padding after the corners: no warp stored. + if rest.iter().all(|b| *b == 0) { + return PlldWarp::None; + } + match rest.get(4..).and_then(parse_prefix_versioned).as_ref().and_then(warp_of) { + Some(Some(w)) => PlldWarp::Warp(w), + Some(None) => PlldWarp::None, + None => PlldWarp::Unreadable, + } +} + /// The projective placement of a `SoLd` / `SoLE` block whose corners aren't a parallelogram /// (Distort, Perspective): source pixels → document pixels, row-major 3×3. Read from /// `nonAffineTransform` (the placed corners), else `Trnf`. `None` for affine placements, which @@ -638,6 +730,12 @@ pub fn parse_smart_perspective(key: &[u8; 4], data: &[u8]) -> Option<[f64; 9]> { (p.len() == 8 && p.iter().all(|v| v.is_finite())).then(|| [[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]]) }; let q = quad("nonAffineTransform").or_else(|| quad("Trnf"))?; + corners_perspective(q, (w, h)) +} + +/// The projective placement of a `w` × `h` source onto corners `q` that aren't a parallelogram; +/// `None` for affine corners (see [`corners_affine`]) or an empty size. +pub fn corners_perspective(q: [[f64; 2]; 4], (w, h): (f64, f64)) -> Option<[f64; 9]> { // A parallelogram (top-left + bottom-right = top-right + bottom-left) is affine. let tol = 1e-6 * (1.0 + q.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()))); if (0..2).all(|i| (q[0][i] + q[2][i] - q[1][i] - q[3][i]).abs() <= tol) || w <= 0.0 || h <= 0.0 { @@ -916,6 +1014,172 @@ mod more_tests { assert_eq!(id, "uuid-1"); assert_eq!(a.m, [2.0, 0.0, 0.0, 2.0, 10.0, 20.0]); assert_eq!(parse_smart(b"PlLd", &data).0, ""); + assert_eq!(parse_plld(&data), None, "soLD data is not a plcL record"); + } + + /// A `PlLd` record (as Photoshop CS2 writes it, with no `SoLd`, #2465) gives its source id and + /// placed corners; with the source size those are the same transform `SoLd` gives. + #[test] + fn plld_id_and_corners() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + let t = Affine { m: [1.5, 0.25, -0.5, 2.0, 10.0, 20.0] }; + let spec = PlacedSpec { idnt: "uuid-cs2", placed: "", transform: t, perspective: None, size: (40.0, 30.0), dpi: 72.0, warp: None, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + let (id, p) = parse_plld(&data).expect("a plcL record"); + assert_eq!(id, "uuid-cs2"); + assert_eq!(p, [10.0, 20.0, 70.0, 30.0, 55.0, 90.0, -5.0, 80.0]); + let back = corners_affine(&p, [0.0, 0.0, 40.0, 30.0]).unwrap(); + for (a, b) in back.m.iter().zip(t.m) { + assert!((a - b).abs() < 1e-12, "{back:?} vs {t:?}"); + } + assert_eq!(corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (40.0, 30.0)), None, "affine corners"); + assert_eq!(corners_affine(&p, [0.0, 0.0, 0.0, 30.0]), None); + assert_eq!(corners_affine(&p, [0.0, 0.0, f64::NAN, 30.0]), None); + assert_eq!(corners_affine(&p, [5.0, 0.0, 5.0, 30.0]), None, "an empty frame"); + let mut nan = p; + nan[3] = f64::INFINITY; + assert_eq!(corners_affine(&nan, [0.0, 0.0, 40.0, 30.0]), None); + // The placed-layer record is the one block that can be the principal one: PlLd-only + // layers have no smart transform from `parse_smart`. + assert_eq!(parse_smart(b"PlLd", &data), (String::new(), Affine::IDENTITY)); + } + + /// A `PlLd` record's corners frame the same box as `SoLd`'s `Trnf` (#3677): with a custom + /// warp whose control points spill past the source, the control-point box, not the source + /// rect. The CS2 record then places the layer exactly as the equivalent `SoLd` does, with or + /// without the source size (the box needs none). + #[test] + fn plld_corners_frame_a_spilled_custom_warp_like_sold() { + use crate::smart_map::{PlacedSpec, parse_sold, plld_bytes, sold_bytes}; + use photocraft_geom::warp::{BezierMesh, Warp}; + let bounds = [0.0, 0.0, 50.0, 25.0]; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[0] = [-10.0, -5.0]; + mesh.points[15] = [60.0, 25.0]; + let warp = Warp::custom(mesh, bounds); + let t = Affine { m: [2.0, 0.5, -0.25, 2.0, 120.0, 210.0] }; + let spec = PlacedSpec { idnt: "cs2", placed: "p", transform: t, perspective: None, size: (50.0, 25.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + let plld = plld_bytes(&spec, &mut Vec::new()); + let sold = sold_bytes(None, &spec, &mut Vec::new()); + let (_, p) = parse_plld(&plld).expect("a plcL record"); + assert_eq!(parse_plld_warp(&plld), PlldWarp::Warp(warp.clone())); + let from_sold = parse_smart(b"SoLd", &sold).1; + assert_eq!(parse_sold(&sold).map(|s| s.transform), Some(from_sold)); + let close = |a: Affine, b: Affine| a.m.iter().zip(b.m).all(|(x, y)| (x - y).abs() < 1e-9); + assert!(close(from_sold, t), "{from_sold:?} vs {t:?}"); + for size in [Some((50.0, 25.0)), None] { + let (got, perspective) = plld_transform(&p, size, Some(&warp)); + assert!(close(got, from_sold), "size {size:?}: {got:?} vs SoLd {from_sold:?}"); + assert_eq!(perspective, None); + } + // Framing the source rect instead would stretch it (the bug #3677 fixed for SoLd). + let rect = corners_affine(&p, [0.0, 0.0, 50.0, 25.0]).unwrap(); + assert!(!close(rect, t), "the test needs a mesh that spills: {rect:?}"); + // With no warp, or a preset one, the frame is the source rect, as before. + let plain = PlacedSpec { warp: None, ..spec }; + let (_, q) = parse_plld(&plld_bytes(&plain, &mut Vec::new())).unwrap(); + assert!(close(plld_transform(&q, Some((50.0, 25.0)), None).0, t)); + } + + /// Malformed `PlLd` data (wrong magic, truncated anywhere, a Pascal length past the end, + /// non-finite corners) reads as `None`, never a panic. + #[test] + fn plld_malformed_is_none() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + let spec = + PlacedSpec { idnt: "id", placed: "", transform: Affine::IDENTITY, perspective: None, size: (4.0, 4.0), dpi: 72.0, warp: None, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + // Magic + version + Pascal "id" + 4 × u32 + 8 × f64: everything after is the warp. + let end = 4 + 4 + 1 + 2 + 16 + 64; + assert!(parse_plld(&data[..end]).is_some()); + for n in 0..end { + assert_eq!(parse_plld(&data[..n]), None, "truncated to {n} bytes"); + } + let mut bad = data.clone(); + bad[..4].copy_from_slice(b"soLD"); + assert_eq!(parse_plld(&bad), None); + let mut long = data.clone(); + long[8] = 255; // a Pascal id longer than the record + assert_eq!(parse_plld(&long[..200.min(long.len())]), None); + let mut nan = data.clone(); + nan[end - 8..end].copy_from_slice(&f64::NAN.to_be_bytes()); + assert_eq!(parse_plld(&nan), None); + assert_eq!(parse_plld(&[]), None); + } + + /// Photoshop CS2 can warp a smart object; `PlLd` keeps that warp after the corners (a u32 + /// warp version, then the versioned `warp` descriptor). It reads back as written, and + /// `warpNone`, an identity warp or a record that stops at its corners mean no warp. + #[test] + fn plld_warps_round_trip() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; + let bounds = [0.0, 0.0, 60.0, 30.0]; + let mut arc = Warp::preset(WarpStyle::Arc, 40.0, bounds); + arc.vertical = true; + arc.h_distort = 10.0; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[15] = [67.0, 30.0]; + let custom = Warp::custom(mesh, bounds); + let flat = Warp::preset(WarpStyle::Flag, 0.0, bounds); + for (w, back) in + [(Some(&arc), PlldWarp::Warp(arc.clone())), (Some(&custom), PlldWarp::Warp(custom.clone())), (Some(&flat), PlldWarp::None), (None, PlldWarp::None)] + { + let t = Affine { m: [0.5, 0.0, 0.0, 0.5, 3.0, 4.0] }; + let spec = PlacedSpec { idnt: "w", placed: "", transform: t, perspective: None, size: (60.0, 30.0), dpi: 72.0, warp: w, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + assert_eq!(parse_plld_warp(&data), back, "{w:?}"); + // The corners are unchanged by the warp that follows them. + assert_eq!(parse_plld(&data).map(|(_, p)| p[..2].to_vec()), Some(vec![3.0, 4.0])); + } + let spec = + PlacedSpec { idnt: "id", placed: "", transform: Affine::IDENTITY, perspective: None, size: (4.0, 4.0), dpi: 72.0, warp: None, filter_fx: None }; + let end = 4 + 4 + 1 + 2 + 16 + 64; + let data = plld_bytes(&spec, &mut Vec::new()); + assert_eq!(parse_plld_warp(&data[..end]), PlldWarp::None, "no warp stored"); + assert_eq!(parse_plld_warp(&[data[..end].to_vec(), vec![0; 3]].concat()), PlldWarp::None, "padding"); + } + + /// A `PlLd` warp that is stored but can't be read (truncated, an unknown style, a missing + /// bounds rectangle or mesh) is `Unreadable`, so the import keeps the warped pixels. + #[test] + fn plld_unreadable_warps() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + use photocraft_geom::warp::{Warp, WarpStyle}; + let arc = Warp::preset(WarpStyle::Arc, 40.0, [0.0, 0.0, 4.0, 4.0]); + let spec = PlacedSpec { + idnt: "id", + placed: "", + transform: Affine::IDENTITY, + perspective: None, + size: (4.0, 4.0), + dpi: 72.0, + warp: Some(&arc), + filter_fx: None, + }; + let data = plld_bytes(&spec, &mut Vec::new()); + let end = 4 + 4 + 1 + 2 + 16 + 64; + assert!(matches!(parse_plld_warp(&data), PlldWarp::Warp(_))); + for n in end + 1..data.len() - 4 { + if data[end..n].iter().any(|b| *b != 0) { + assert_eq!(parse_plld_warp(&data[..n]), PlldWarp::Unreadable, "truncated to {n} bytes"); + } + } + let with = |d: Descriptor| [data[..end].to_vec(), vec![0; 4], VersionedDescriptor::new(d).to_bytes()].concat(); + let style = |s: &str| Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new(s) }; + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpSomethingNew")))), PlldWarp::Unreadable); + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpArc")))), PlldWarp::Unreadable, "no bounds"); + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpNone")))), PlldWarp::None); + let bounds = Descriptor::new("classFloatRect") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(4.0)) + .with("Rght", Value::Double(4.0)); + let custom = Descriptor::new("warp").with("warpStyle", style("warpCustom")).with("bounds", Value::Descriptor(bounds)); + assert_eq!(parse_plld_warp(&with(custom)), PlldWarp::Unreadable, "custom with no mesh"); + assert_eq!(parse_plld_warp(&[data[..end].to_vec(), vec![0, 0, 0, 0, 0, 0, 0, 16, 1, 2]].concat()), PlldWarp::Unreadable); + assert_eq!(parse_plld_warp(&data[..end - 1]), PlldWarp::Unreadable, "the record itself"); + assert_eq!(parse_plld_warp(&[]), PlldWarp::Unreadable); } /// `SoLd` data with a 50 × 25 source, the quad `trnf` and, when given, a custom warp whose diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs new file mode 100644 index 0000000000..159e298a06 --- /dev/null +++ b/crates/io/src/camera_raw_xmp.rs @@ -0,0 +1,423 @@ +//! Camera Raw Filter › Save Settings… / Load Settings… (#4274): Adobe Camera Raw settings files +//! (`.xmp`), the `crs:` namespace (`http://ns.adobe.com/camera-raw-settings/1.0/`) on an +//! `x:xmpmeta/rdf:RDF/rdf:Description`, as attributes or as child elements. +//! +//! Loading applies only the settings the file holds, over the current ones, as Camera Raw's +//! Load Settings does; settings PhotoCraft has no control for (lens corrections, calibration, +//! geometry, defringe, …) are ignored and named in the report. Saving writes every supported +//! control, so loading the file in Photoshop reproduces the dialog. +//! +//! White balance: a Camera Raw Filter on a rendered image uses the relative +//! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature +//! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no +//! counterpart and are ignored. + +use photocraft_algo::camera_raw::{CameraRaw, validate_curve}; + +/// The Camera Raw settings namespace. +pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/"; +const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +/// Largest settings file read; real ones are a few kilobytes. +pub const MAX_XMP_BYTES: usize = 4 * 1024 * 1024; + +type Field = fn(&mut CameraRaw) -> &mut f32; + +/// `crs:` scalar name, PhotoCraft field, and the slider range values are clamped to. +const SCALARS: &[(&str, Field, f32, f32)] = &[ + ("IncrementalTemperature", |p| &mut p.temperature, -100.0, 100.0), + ("IncrementalTint", |p| &mut p.tint, -100.0, 100.0), + ("Exposure2012", |p| &mut p.exposure, -5.0, 5.0), + ("Contrast2012", |p| &mut p.contrast, -100.0, 100.0), + ("Highlights2012", |p| &mut p.highlights, -100.0, 100.0), + ("Shadows2012", |p| &mut p.shadows, -100.0, 100.0), + ("Whites2012", |p| &mut p.whites, -100.0, 100.0), + ("Blacks2012", |p| &mut p.blacks, -100.0, 100.0), + ("Texture", |p| &mut p.texture, -100.0, 100.0), + ("Clarity2012", |p| &mut p.clarity, -100.0, 100.0), + ("Dehaze", |p| &mut p.dehaze, -100.0, 100.0), + ("Vibrance", |p| &mut p.vibrance, -100.0, 100.0), + ("Saturation", |p| &mut p.saturation, -100.0, 100.0), + ("ParametricShadows", |p| &mut p.curve_shadows, -100.0, 100.0), + ("ParametricDarks", |p| &mut p.curve_darks, -100.0, 100.0), + ("ParametricLights", |p| &mut p.curve_lights, -100.0, 100.0), + ("ParametricHighlights", |p| &mut p.curve_highlights, -100.0, 100.0), + ("ParametricShadowSplit", |p| &mut p.curve_splits[0], 0.0, 100.0), + ("ParametricMidtoneSplit", |p| &mut p.curve_splits[1], 0.0, 100.0), + ("ParametricHighlightSplit", |p| &mut p.curve_splits[2], 0.0, 100.0), + ("HueAdjustmentRed", |p| &mut p.hsl_hue[0], -100.0, 100.0), + ("HueAdjustmentOrange", |p| &mut p.hsl_hue[1], -100.0, 100.0), + ("HueAdjustmentYellow", |p| &mut p.hsl_hue[2], -100.0, 100.0), + ("HueAdjustmentGreen", |p| &mut p.hsl_hue[3], -100.0, 100.0), + ("HueAdjustmentAqua", |p| &mut p.hsl_hue[4], -100.0, 100.0), + ("HueAdjustmentBlue", |p| &mut p.hsl_hue[5], -100.0, 100.0), + ("HueAdjustmentPurple", |p| &mut p.hsl_hue[6], -100.0, 100.0), + ("HueAdjustmentMagenta", |p| &mut p.hsl_hue[7], -100.0, 100.0), + ("SaturationAdjustmentRed", |p| &mut p.hsl_sat[0], -100.0, 100.0), + ("SaturationAdjustmentOrange", |p| &mut p.hsl_sat[1], -100.0, 100.0), + ("SaturationAdjustmentYellow", |p| &mut p.hsl_sat[2], -100.0, 100.0), + ("SaturationAdjustmentGreen", |p| &mut p.hsl_sat[3], -100.0, 100.0), + ("SaturationAdjustmentAqua", |p| &mut p.hsl_sat[4], -100.0, 100.0), + ("SaturationAdjustmentBlue", |p| &mut p.hsl_sat[5], -100.0, 100.0), + ("SaturationAdjustmentPurple", |p| &mut p.hsl_sat[6], -100.0, 100.0), + ("SaturationAdjustmentMagenta", |p| &mut p.hsl_sat[7], -100.0, 100.0), + ("LuminanceAdjustmentRed", |p| &mut p.hsl_lum[0], -100.0, 100.0), + ("LuminanceAdjustmentOrange", |p| &mut p.hsl_lum[1], -100.0, 100.0), + ("LuminanceAdjustmentYellow", |p| &mut p.hsl_lum[2], -100.0, 100.0), + ("LuminanceAdjustmentGreen", |p| &mut p.hsl_lum[3], -100.0, 100.0), + ("LuminanceAdjustmentAqua", |p| &mut p.hsl_lum[4], -100.0, 100.0), + ("LuminanceAdjustmentBlue", |p| &mut p.hsl_lum[5], -100.0, 100.0), + ("LuminanceAdjustmentPurple", |p| &mut p.hsl_lum[6], -100.0, 100.0), + ("LuminanceAdjustmentMagenta", |p| &mut p.hsl_lum[7], -100.0, 100.0), + // Color Grading keeps the split-toning names for the shadow and highlight wheels' hue and + // saturation, and the balance. + ("SplitToningShadowHue", |p| &mut p.grade_shadows.hue, 0.0, 360.0), + ("SplitToningShadowSaturation", |p| &mut p.grade_shadows.sat, 0.0, 100.0), + ("ColorGradeShadowLum", |p| &mut p.grade_shadows.lum, -100.0, 100.0), + ("ColorGradeMidtoneHue", |p| &mut p.grade_midtones.hue, 0.0, 360.0), + ("ColorGradeMidtoneSat", |p| &mut p.grade_midtones.sat, 0.0, 100.0), + ("ColorGradeMidtoneLum", |p| &mut p.grade_midtones.lum, -100.0, 100.0), + ("SplitToningHighlightHue", |p| &mut p.grade_highlights.hue, 0.0, 360.0), + ("SplitToningHighlightSaturation", |p| &mut p.grade_highlights.sat, 0.0, 100.0), + ("ColorGradeHighlightLum", |p| &mut p.grade_highlights.lum, -100.0, 100.0), + ("ColorGradeGlobalHue", |p| &mut p.grade_global.hue, 0.0, 360.0), + ("ColorGradeGlobalSat", |p| &mut p.grade_global.sat, 0.0, 100.0), + ("ColorGradeGlobalLum", |p| &mut p.grade_global.lum, -100.0, 100.0), + ("ColorGradeBlending", |p| &mut p.grade_blending, 0.0, 100.0), + ("SplitToningBalance", |p| &mut p.grade_balance, -100.0, 100.0), + ("Sharpness", |p| &mut p.sharpen_amount, 0.0, 150.0), + ("SharpenRadius", |p| &mut p.sharpen_radius, 0.5, 3.0), + ("SharpenDetail", |p| &mut p.sharpen_detail, 0.0, 100.0), + ("SharpenEdgeMasking", |p| &mut p.sharpen_masking, 0.0, 100.0), + ("LuminanceSmoothing", |p| &mut p.noise_luminance, 0.0, 100.0), + ("LuminanceNoiseReductionDetail", |p| &mut p.noise_luminance_detail, 0.0, 100.0), + ("ColorNoiseReduction", |p| &mut p.noise_color, 0.0, 100.0), + ("ColorNoiseReductionDetail", |p| &mut p.noise_color_detail, 0.0, 100.0), + ("GrainAmount", |p| &mut p.grain_amount, 0.0, 100.0), + ("GrainSize", |p| &mut p.grain_size, 0.0, 100.0), + ("GrainFrequency", |p| &mut p.grain_roughness, 0.0, 100.0), + ("PostCropVignetteAmount", |p| &mut p.vignette_amount, -100.0, 100.0), + ("PostCropVignetteMidpoint", |p| &mut p.vignette_midpoint, 0.0, 100.0), + ("PostCropVignetteRoundness", |p| &mut p.vignette_roundness, -100.0, 100.0), + ("PostCropVignetteFeather", |p| &mut p.vignette_feather, 0.0, 100.0), + ("PostCropVignetteHighlightContrast", |p| &mut p.vignette_highlights, 0.0, 100.0), +]; + +type Curve = fn(&mut CameraRaw) -> &mut Vec<[f32; 2]>; +const CURVES: &[(&str, Curve)] = &[ + ("ToneCurvePV2012", |p| &mut p.point_curve), + ("ToneCurvePV2012Red", |p| &mut p.point_curve_red), + ("ToneCurvePV2012Green", |p| &mut p.point_curve_green), + ("ToneCurvePV2012Blue", |p| &mut p.point_curve_blue), +]; + +const STYLES: &[(&str, &str)] = &[("1", "highlightPriority"), ("2", "colorPriority"), ("3", "paintOverlay")]; + +/// Bookkeeping fields Camera Raw writes that carry no setting. +const METADATA: &[&str] = &[ + "Version", + "ProcessVersion", + "HasSettings", + "HasCrop", + "WhiteBalance", + "ToneCurveName2012", + "CameraProfile", + "PresetType", + "UUID", + "SupportsAmount", + "SupportsColor", + "SupportsMonochrome", + "SupportsHighDynamicRange", + "SupportsNormalDynamicRange", + "SupportsSceneReferred", + "SupportsOutputReferred", + "CameraModelRestriction", + "Copyright", + "ContactInfo", + "Name", + "ShortName", + "SortName", + "Group", + "Description", +]; + +/// What [`load`] applied. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LoadReport { + /// How many settings were applied. + pub applied: usize, + /// `crs:` settings without a PhotoCraft control (set to something other than their neutral + /// value), or with an unusable value, in file order. + pub ignored: Vec, +} + +fn number(s: &str) -> Option { + let s = s.trim(); + let v: f32 = s.strip_prefix('+').unwrap_or(s).parse().ok()?; + v.is_finite().then_some(v) +} + +/// `"0, 0"` items of an `rdf:Seq` → curve points. +fn curve(node: roxmltree::Node<'_, '_>) -> Option> { + let seq = node.children().find(|c| c.is_element() && c.tag_name().namespace() == Some(RDF_NS) && c.tag_name().name() == "Seq")?; + let mut points = Vec::new(); + for li in seq.children().filter(|c| c.is_element() && c.tag_name().name() == "li") { + let text = li.text().unwrap_or(""); + let (x, y) = text.split_once(',')?; + points.push([number(x)?, number(y)?]); + } + validate_curve(&points).ok()?; + // Camera Raw's linear curve is the identity's two end points; PhotoCraft's is empty. + if points == [[0.0, 0.0], [255.0, 255.0]] { + points.clear(); + } + Some(points) +} + +/// An unsupported setting at its neutral value changes nothing, so it isn't reported: zero, +/// `False`, and the few neutral values that aren't zero. +fn neutral(name: &str, value: &str) -> bool { + let v = value.trim(); + if v.eq_ignore_ascii_case("false") || number(v) == Some(0.0) { + return true; + } + match name { + "GrainSeed" => true, + "VignetteMidpoint" => number(v) == Some(50.0), + "PerspectiveScale" => number(v) == Some(100.0), + _ => name.starts_with("Defringe") && name.contains("Hue"), + } +} + +fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) { + if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { + match number(value) { + Some(v) => { + *field(params) = v.clamp(*lo, *hi); + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if name == "PostCropVignetteStyle" { + match STYLES.iter().find(|(n, _)| *n == value.trim()) { + Some((_, style)) => { + params.vignette_style = (*style).into(); + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if !METADATA.contains(&name) && !neutral(name, value) { + report.ignored.push(name.to_string()); + } +} + +/// Apply a Camera Raw settings file over `params`. Fails on input that isn't XMP or holds no +/// Camera Raw settings; never panics on malformed input. +pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result { + if bytes.len() > MAX_XMP_BYTES { + return Err("the settings file is too large".into()); + } + let text = std::str::from_utf8(bytes).map_err(|_| "not a Camera Raw settings file (not UTF-8 text)".to_string())?; + // Some writers put a byte order mark or an `` wrapper first; roxmltree takes both. + let text = text.trim_start_matches('\u{feff}'); + let doc = roxmltree::Document::parse(text).map_err(|e| format!("not a Camera Raw settings file ({e})"))?; + let mut next = params.clone(); + let mut report = LoadReport::default(); + let mut found = false; + for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") { + for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) { + found = true; + apply(&mut next, &mut report, a.name(), a.value()); + } + for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) { + found = true; + let name = c.tag_name().name(); + if let Some((_, field)) = CURVES.iter().find(|(n, _)| *n == name) { + match curve(c) { + Some(points) => { + *field(&mut next) = points; + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if c.children().any(|g| g.is_element()) { + // Structured settings (masks, looks, lens profiles…) have no counterpart. + if !METADATA.contains(&name) { + report.ignored.push(name.to_string()); + } + } else { + apply(&mut next, &mut report, name, c.text().unwrap_or("")); + } + } + } + if !found { + return Err("no Camera Raw settings in the file".into()); + } + *params = next; + Ok(report) +} + +fn signed(v: f32, decimals: usize) -> String { + let s = format!("{v:.decimals$}"); + if v > 0.0 && !s.starts_with('+') { format!("+{s}") } else { s } +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. +pub fn save(params: &CameraRaw) -> String { + let mut p = params.clone(); + let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" }; + let mut out = String::from( + "\n \n signed(v, 2), + "SharpenRadius" => signed(v, 1), + _ if *lo < 0.0 => signed(v.round(), 0), + _ => format!("{:.0}", v.round()), + }; + out.push_str(&format!(" crs:{name}=\"{text}\"\n")); + } + let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n); + out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style))); + out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n"); + for (name, field) in CURVES { + let points = field(&mut p); + let points = if points.is_empty() { vec![[0.0, 0.0], [255.0, 255.0]] } else { points.clone() }; + out.push_str(&format!(" \n \n")); + for [x, y] in points { + out.push_str(&format!(" {}, {}\n", x.round(), y.round())); + } + out.push_str(&format!(" \n \n")); + } + out.push_str(" \n \n\n"); + out +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + /// A hand-written settings file laid out as Photoshop 26's Camera Raw Filter saves one (the + /// field set and order follow Camera Raw 18; the values are synthetic), plus element-form + /// settings, which XMP allows and some writers use. + const FIXTURE: &[u8] = br#" + + + +12 + + + 0, 0 + 255, 255 + + + + + 0, 0 + 128, 140 + 255, 255 + + + + +"#; + + #[test] + fn reads_a_photoshop_preset() { + let mut p = CameraRaw::default(); + let r = load(&mut p, FIXTURE).unwrap(); + assert_eq!(p.tint, 10.0); + assert_eq!(p.exposure, 0.05); + assert_eq!((p.contrast, p.highlights, p.shadows, p.whites), (-5.0, -20.0, 10.0, 35.0)); + assert_eq!((p.texture, p.clarity, p.vibrance, p.saturation), (30.0, 5.0, 20.0, -5.0)); + assert_eq!((p.hsl_hue[3], p.hsl_sat[3]), (25.0, 20.0)); + assert_eq!((p.sharpen_amount, p.noise_luminance, p.grain_amount), (15.0, 5.0, 10.0)); + assert_eq!(p.grade_blending, 50.0); + // The linear tone curve is PhotoCraft's empty one; an element-form setting is read too. + assert!(p.point_curve.is_empty()); + assert_eq!(p.point_curve_red, vec![[0.0, 0.0], [128.0, 140.0], [255.0, 255.0]]); + assert_eq!(p.dehaze, 12.0); + // Lens vignetting, calibration and geometry have no counterpart. + assert_eq!(r.ignored, vec!["VignetteAmount".to_string(), "RedHue".to_string()]); + } + + #[test] + fn save_then_load_round_trips() { + let mut p = CameraRaw { + temperature: -12.0, + exposure: 1.25, + contrast: 30.0, + sharpen_radius: 1.4, + vignette_amount: -40.0, + vignette_style: "paintOverlay".into(), + curve_splits: [20.0, 45.0, 80.0], + point_curve: vec![[0.0, 10.0], [128.0, 150.0], [255.0, 240.0]], + ..CameraRaw::default() + }; + p.hsl_lum[5] = -33.0; + p.grade_shadows.hue = 210.0; + p.grade_shadows.sat = 40.0; + p.grade_global.lum = 7.0; + let text = save(&p); + assert!(text.contains("crs:Exposure2012=\"+1.25\"")); + assert!(text.contains("crs:WhiteBalance=\"Custom\"")); + let mut back = CameraRaw::default(); + let r = load(&mut back, text.as_bytes()).unwrap(); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(back, p); + } + + #[test] + fn load_applies_only_the_settings_in_the_file() { + let mut p = CameraRaw { contrast: 40.0, ..CameraRaw::default() }; + let xmp = br#" + -9 + abc"#; + let r = load(&mut p, xmp).unwrap(); + assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0)); + assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] }); + } + + #[test] + fn malformed_input_is_an_error_and_leaves_settings_alone() { + let before = CameraRaw { exposure: 0.5, ..CameraRaw::default() }; + for bad in [ + &b""[..], + b"\xff\xfe\x00garbage", + b"", + b"]>&e;", + ] { + let mut p = before.clone(); + assert!(load(&mut p, bad).is_err()); + assert_eq!(p, before); + } + // A broken curve or NaN is skipped, not fatal. + let xmp = br#" + 10"#; + let mut p = before.clone(); + let r = load(&mut p, xmp).unwrap(); + assert_eq!(p, before); + assert_eq!(r.ignored, vec!["Contrast2012".to_string(), "ToneCurvePV2012".to_string()]); + } +} diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 93b8e85f36..5474d00310 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -98,6 +98,12 @@ fn channel_base_split(name: &str) -> Option<(&str, &str)> { (!prefix.is_empty()).then_some((prefix, base)) } +/// The name a part is listed, selected and imported as: its `name` attribute, or +/// `part ` when it has none (a single-part render usually carries no name). +fn part_display_name(part: &codecs::ExrPartInfo) -> String { + part.name.clone().unwrap_or_else(|| format!("part {}", part.index)) +} + /// Lists the entries of a multi-part or channel-grouped EXR for a chooser: flat parts in /// file order, then the channel groups, then the Cryptomatte layers as information. A file /// whose parts are all deep is refused (the deep decoder composites those). @@ -113,7 +119,7 @@ pub fn entries(bytes: &[u8]) -> Result, IoError> { } out.push(Entry { kind: EntryKind::Part, - name: part.name.clone().unwrap_or_else(|| format!("part {}", part.index)), + name: part_display_name(part), view: part.view.clone(), width: part.width, height: part.height, @@ -186,8 +192,14 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result| view.as_ref().is_none_or(|v| sel.view.eq_ignore_ascii_case("both") || v.eq_ignore_ascii_case(&sel.view)); - let part_by_name = - |want: &str| info.iter().find(|p| !p.deep && p.name.as_deref() == Some(want)).ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))); + // A named part wins over an unnamed one listed under the same fallback name ("part 0"). + let part_by_name = |want: &str| { + let flat = || info.iter().filter(|p| !p.deep); + flat() + .find(|p| p.name.as_deref() == Some(want)) + .or_else(|| flat().find(|p| p.name.is_none() && part_display_name(p) == want)) + .ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))) + }; let mut chosen_parts: Vec<&codecs::ExrPartInfo> = Vec::new(); for want in &sel.parts { let p = part_by_name(want)?; @@ -235,7 +247,7 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result CSample { /// Renders the document to a flat codec image (native pixels when possible). pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { + document_to_image_for(doc, warnings, true) +} + +fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool) -> Result { let (w, h) = (doc.size.width, doc.size.height); let canvas = doc.bounds(); let fmt = doc.pixel_format(); @@ -338,16 +342,42 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Profile::parse(bytes).map_err(|e| IoError::Unsupported(format!("invalid CMYK composite profile: {e}")))?, + None => Builtin::CoatedCmyk.profile().clone(), + }; + if profile.color_space != ColorSpace::Cmyk { + return Err(IoError::Unsupported("CMYK composite requires a CMYK ICC profile".into())); + } + let srgb = Builtin::Srgb.profile(); + Transform::new(&profile, srgb, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + Transform::new(srgb, &profile, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + if icc.is_none() { + icc = Some(profile.to_bytes().to_vec()); + } + photocraft_compose::cmyk_space(doc) + } else { + None + }; let gray = fmt.mode == ColorMode::Grayscale; - if fmt.mode == ColorMode::Cmyk || fmt.mode == ColorMode::Lab { - // The compositor renders CMYK/Lab documents in sRGB (CMYK through the built-in - // profile), so the file is tagged sRGB. + if (fmt.mode == ColorMode::Cmyk && !cmyk) || fmt.mode == ColorMode::Lab { + // RGB-only targets use the sRGB composite directly, without reseparating CMYK. warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); } let cs = csample(fmt.sample); - let colors = if gray { 1 } else { 3 }; + let colors = if cmyk { + 4 + } else if gray { + 1 + } else { + 3 + }; // Rendered and quantised in bands (no full-size float composite), with alpha; the alpha // is dropped afterwards, in place, when every pixel turned out opaque. let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; @@ -355,26 +385,35 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Result<(), ()> { opaque &= band.px.iter().all(|p| p[3] >= 1.0); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); - let mut put = |v: f32| { - let v = if v.is_nan() { 0.0 } else { v }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); + let mut put = |v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + let mut converted = [0.0; 5]; + for p in &band.px[range] { + if cmyk { + let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); + for value in converted.iter().take(channels) { + put(*value); + } + continue; + } else if gray { + put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(p[0]); + put(p[1]); + put(p[2]); + } + put(p[3]); } - }; - for p in &band.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - put(p[3]); - } - out + out + }) }); for part in parts { data.extend_from_slice(&part); @@ -384,7 +423,16 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Option { + match name.to_ascii_lowercase().as_str() { + "classic" => Some(Method::Classic), + "perceptual" => Some(Method::Perceptual), + "linear" => Some(Method::Linear), + "smooth" => Some(Method::Smooth), + _ => None, + } + } pub(crate) fn from_code(code: Option<&[u8]>) -> Method { match code { Some(b"Perc") => Method::Perceptual, @@ -140,7 +151,7 @@ fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32, m: Method) /// Dense linear stops reproducing Photoshop's interpolation. Returns the input unchanged when it /// already interpolates linearly in sRGB. -pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> { +pub fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> { let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3); // Classic interpolates in the stops' own model: Lab stops (Lab documents) blend in L*a*b*, which the // compositors' sRGB interpolation can't reproduce (psd-tools 4x4_16bit_lab). diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs index a3af8b119c..64e4facf61 100644 --- a/crates/io/src/kra.rs +++ b/crates/io/src/kra.rs @@ -1,108 +1,342 @@ -//! Krita .kra import: the merged PNG is an editable raster layer, not a replacement -//! for Krita's native layer tree. Never write this document back to the .kra source. -//! The ZIP reader checks entry bounds, sizes and CRC before the PNG decoder runs. +//! Krita (`.kra`, `.krz`) import. Layout observed from files Krita writes (no Krita code used): +//! a ZIP whose first, stored entry is `mimetype` = `application/x-krita`, a `maindoc.xml` node +//! tree, one tiled pixel file per paint layer and mask under `/layers/`, the +//! image's ICC profile in `/annotations/icc`, and Krita's rendering in +//! `mergedimage.png`. +//! +//! Tiled pixel files are a short text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, +//! `PIXELSIZE`, `DATA `) followed by, per tile, a `x,y,LZF,` line and that many +//! bytes: a flag (1 = LZF-compressed, 0 = raw) and the tile's pixels stored as byte planes +//! (every pixel's first byte, then every pixel's second byte, ...). RGB pixels are in +//! Krita's BGRA order, 16-bit samples little-endian. +//! +//! Imported: paint layers (8 and 16-bit RGBA and gray), groups (isolated and pass-through), +//! names, visibility, opacity, blend modes, locks, layer offsets and transparency masks. +//! Every other node kind (vector, filter, fill, file and clone layers, filter, transform and +//! colorize masks) is left out with a warning; Krita's merged image still shows their look. +//! Documents in a colour space we don't read open as Krita's merged image, flagged as a preview. -use photocraft_codecs::{self as codecs, DecodeOptions, Format}; +#[cfg(test)] +mod tests; +mod tiles; + +use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; use photocraft_format::zip::ZipReader; -use photocraft_raster::Interrupt; +use photocraft_geom::Size; +use photocraft_raster::{Interrupt, Surface}; +use crate::ora::{MAX_NODES, MAX_PIXEL_BYTES, MAX_SIDE, Warnings, krita_blend}; use crate::{ImportResult, IoError}; +use tiles::{Space, TileFile}; -const MIMETYPE: &[u8] = b"application/x-krita"; -const MAX_ENTRY: usize = 512 << 20; -const MAX_PIXELS_BYTES: u64 = 1 << 30; +type Result = std::result::Result; + +/// The `mimetype` entry of a Krita archive. +pub(crate) const MIMETYPE: &[u8] = b"application/x-krita"; +pub(crate) const EXTENSIONS: [&str; 2] = ["kra", "krz"]; +const MAX_MAINDOC: usize = 32 << 20; +const MAX_LAYER_FILE: usize = 1 << 30; fn invalid(message: impl Into) -> IoError { IoError::Kra(message.into()) } -/// Krita writes its mimetype as the first, uncompressed ZIP entry. +/// `true` for a Krita archive (by its `mimetype` entry). pub(crate) fn is_kra(bytes: &[u8]) -> bool { crate::ora::zip_mimetype(bytes).is_some_and(|mime| mime.trim_ascii() == MIMETYPE) } -/// Import Krita's official merged preview as a flattened, editable PhotoCraft document. -/// Do not silently pretend this imported a layered or lossless Krita document. -pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { - ctl.check().map_err(|_| IoError::Cancelled)?; - let archive = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?; - let mime = archive.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?; +fn attr<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> { + node.attribute(name).map(str::trim) +} + +fn number(node: roxmltree::Node<'_, '_>, name: &str, default: f64) -> Result { + match attr(node, name) { + None | Some("") => Ok(default), + Some(v) => v.parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))), + } +} + +fn offset(node: roxmltree::Node<'_, '_>, name: &str) -> Result { + let v = number(node, name, 0.0)?.round(); + if v.abs() > f64::from(MAX_SIDE) * 4.0 { + return Err(invalid(format!("{name} offset {v} is out of range"))); + } + Ok(v as i32) +} + +fn truthy(node: roxmltree::Node<'_, '_>, name: &str) -> bool { + matches!(attr(node, name), Some("1" | "true")) +} + +/// Child elements named `tag` inside the node's `` element (`layers` or `masks`). +fn children<'a, 'i>(node: roxmltree::Node<'a, 'i>, container: &str, tag: &'static str) -> impl Iterator> { + node.children().filter(move |c| c.has_tag_name(container)).flat_map(move |c| c.children().filter(move |n| n.has_tag_name(tag))) +} + +struct Reader<'a, 'c> { + zip: ZipReader<'a>, + ctl: &'c Interrupt<'c>, + /// `/layers/`. + dir: String, + /// The document's pixel format (layers in another supported space are converted to it). + fmt: PixelFormat, + nodes: usize, + budget: usize, + warnings: Warnings, +} + +impl Reader<'_, '_> { + fn file(&self, name: &str) -> Result>> { + let path = format!("{}{name}", self.dir); + match self.zip.find(&path) { + None => Ok(None), + Some(e) => self.zip.read(e, MAX_LAYER_FILE).map(Some).map_err(|err| invalid(format!("{path}: {err}"))), + } + } + + /// The default pixel Krita stores next to a tiled file, decoded to `space` samples. + fn default_pixel(&self, name: &str, space: Space) -> Result>> { + let Some(bytes) = self.file(&format!("{name}.defaultpixel"))? else { return Ok(None) }; + if bytes.len() != space.pixel_size() { + return Err(invalid(format!("{name}.defaultpixel has {} bytes, expected {}", bytes.len(), space.pixel_size()))); + } + Ok(Some(space.normalised(&bytes))) + } + + /// A tiled pixel file in `space` as a surface in `fmt`, offset by (`dx`, `dy`). + fn surface(&mut self, name: &str, space: Space, fmt: PixelFormat, dx: i32, dy: i32) -> Result> { + let Some(bytes) = self.file(name)? else { return Ok(None) }; + let file = TileFile::parse(&bytes)?; + if file.pixel_size != space.pixel_size() { + return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size))); + } + let cost = file.decoded_bytes(fmt.bytes_per_pixel())?; + self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?; + let mut surface = match self.default_pixel(name, space)? { + Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)), + _ => Surface::new(fmt), + }; + file.decode_into(&mut surface, space, dx, dy, self.ctl)?; + surface.prune(); + Ok(Some(surface)) + } + + fn props(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer) -> Result<()> { + layer.name = attr(node, "name").filter(|n| !n.is_empty()).map_or_else(|| format!("Layer {}", self.nodes), str::to_string); + layer.visible = attr(node, "visible") != Some("0"); + layer.opacity = (number(node, "opacity", 255.0)? / 255.0).clamp(0.0, 1.0) as f32; + if truthy(node, "locked") { + layer.locks.all = true; + } + let op = attr(node, "compositeop").unwrap_or("normal"); + layer.blend = match krita_blend(op) { + Some((mode, approx)) => { + if approx { + self.warnings.note(&format!("Krita's {op} blend mode is approximated by {}", mode.label())); + } + mode + } + None => { + self.warnings.layer(&layer.name, &format!("Krita's {op} blend mode is not supported; it opens as Normal")); + BlendMode::Normal + } + }; + if attr(node, "channelflags").is_some_and(|f| f.contains('0')) { + self.warnings.layer(&layer.name, "channel locks (Krita's per-channel blending) are ignored"); + } + if truthy(node, "inheritalpha") { + self.warnings.layer(&layer.name, "Inherit Alpha is not supported; these layers blend normally"); + } + if attr(node, "keyframes").is_some_and(|k| !k.is_empty()) { + self.warnings.layer(&layer.name, "animated layers open with the frame Krita saved in the layer"); + } + Ok(()) + } + + /// The node's transparency masks, combined into one layer mask. + fn masks(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer, dx: i32, dy: i32) -> Result<()> { + for mask in children(node, "masks", "mask") { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + let kind = attr(mask, "nodetype").unwrap_or(""); + match kind { + "transparencymask" => {} + // A layer's local selection: not part of its look. + "selectionmask" => continue, + other => { + let what = match other { + "filtermask" => "filter masks", + "transformmask" => "transform masks", + "colorizemask" => "colorize masks", + _ => "unknown masks", + }; + self.warnings.layer(&layer.name, &format!("{what} are not supported and were left out")); + continue; + } + } + if layer.mask.is_some() { + self.warnings.layer(&layer.name, "only the first transparency mask of a layer is kept"); + continue; + } + let Some(file) = attr(mask, "filename") else { continue }; + let (mx, my) = (dx.saturating_add(offset(mask, "x")?), dy.saturating_add(offset(mask, "y")?)); + match self.surface(&format!("{file}.pixelselection"), Space::Alpha8, PixelFormat::GRAY8, mx, my)? { + Some(surface) => { + layer.mask = Some(LayerMask { surface, enabled: attr(mask, "visible") != Some("0"), ..LayerMask::reveal_all() }); + } + None => self.warnings.layer(&layer.name, "a transparency mask's pixels are missing from the file"), + } + } + Ok(()) + } + + /// The `` of `node`, bottom-to-top (maindoc.xml lists the topmost first). + fn layers(&mut self, node: roxmltree::Node<'_, '_>, depth: usize, dx: i32, dy: i32) -> Result> { + if depth > photocraft_doc::MAX_GROUP_DEPTH { + return Err(IoError::Unsupported(format!("layer groups nested deeper than {}", photocraft_doc::MAX_GROUP_DEPTH))); + } + let mut out = Vec::new(); + for child in children(node, "layers", "layer") { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(invalid(format!("more than {MAX_NODES} layers"))); + } + let (x, y) = (dx.saturating_add(offset(child, "x")?), dy.saturating_add(offset(child, "y")?)); + let kind = attr(child, "nodetype").unwrap_or(""); + let mut layer = match kind { + "paintlayer" => { + let mut layer = Layer::raster("", self.fmt); + self.props(child, &mut layer)?; + let space = attr(child, "colorspacename").and_then(Space::from_name); + match (space, attr(child, "filename")) { + (Some(space), Some(file)) => match self.surface(file, space, self.fmt, x, y)? { + Some(s) => layer.content = LayerContent::Raster(s), + None => self.warnings.layer(&layer.name, "the layer's pixels are missing from the file; it opens empty"), + }, + (None, _) => { + let cs = attr(child, "colorspacename").unwrap_or("?"); + self.warnings.layer(&layer.name, &format!("layers in Krita's {cs} colour space are not supported and were left out")); + continue; + } + (_, None) => self.warnings.layer(&layer.name, "the layer names no pixel file; it opens empty"), + } + layer + } + "grouplayer" => { + let mut layer = Layer::group("", Vec::new()); + self.props(child, &mut layer)?; + if truthy(child, "passthrough") { + if layer.blend != BlendMode::Normal { + self.warnings.layer(&layer.name, "a pass-through group's blend mode is ignored, as in Krita"); + } + layer.blend = BlendMode::PassThrough; + } + // A group's own x/y is not applied to its children: Krita moves a group by + // moving their pixels, and its merged image ignores the group offset. + let kids = self.layers(child, depth + 1, dx, dy)?; + if let LayerContent::Group(g) = &mut layer.content { + g.children = kids; + g.expanded = !truthy(child, "collapsed"); + } + layer + } + other => { + let name = attr(child, "name").unwrap_or(other).to_string(); + let what = match other { + "shapelayer" => "vector layers", + "adjustmentlayer" => "filter layers", + "generatorlayer" => "fill layers", + "filelayer" => "file layers", + "clonelayer" => "clone layers", + _ => "unknown layer kinds", + }; + self.warnings.layer(&name, &format!("Krita {what} are not supported yet and were left out")); + continue; + } + }; + self.masks(child, &mut layer, x, y)?; + out.push(layer); + } + out.reverse(); + Ok(out) + } +} + +/// Krita's merged image as a one-layer stand-in, for documents we can't read layer by layer. +fn preview(name: &str, zip: &ZipReader<'_>, why: String) -> Result { + let png = zip.read_by_name("mergedimage.png", MAX_LAYER_FILE).map_err(|e| invalid(format!("{why}, and the merged image can't be read: {e}")))?; + let mut r = crate::flat::import_flat(name, &png)?; + r.document.name = name.to_string(); + r.warnings.insert(0, format!("{why}; the file opens as Krita's merged image, without its layers")); + r.source_read_only = true; + r.preview_only = true; + Ok(r) +} + +pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { + let zip = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?; + let mime = zip.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?; if mime.trim_ascii() != MIMETYPE { return Err(invalid("the archive's mimetype is not application/x-krita")); } - ctl.progress(0.10); - let merged = archive.read_by_name("mergedimage.png", MAX_ENTRY).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; - if codecs::detect(&merged) != Some(Format::Png) { - return Err(invalid("mergedimage.png is not a PNG image")); - } - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut opts = DecodeOptions::default(); - opts.limits.max_width = crate::ora::MAX_SIDE; - opts.limits.max_height = crate::ora::MAX_SIDE; - opts.limits.max_alloc = MAX_PIXELS_BYTES; - let image = codecs::decode_with(&merged, &opts).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut result = crate::flat::image_to_document(name, &image)?; - result.source_read_only = true; - result.warnings.push("Krita layers, masks and editable document structure were not imported; only mergedimage.png is available".into()); - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType}; - use photocraft_format::zip::ZipWriter; - - fn png() -> Vec { - let image = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap(); - codecs::encode(&image, Format::Png, &EncodeOptions::default()).unwrap() - } - - fn archive(mime: &[u8], with_merged: bool) -> Vec { - let mut zip = ZipWriter::new(); - zip.add("mimetype", mime).unwrap(); - zip.add("maindoc.xml", b"").unwrap(); - if with_merged { - zip.add("mergedimage.png", &png()).unwrap(); + let xml = zip.read_by_name("maindoc.xml", MAX_MAINDOC).map_err(|e| invalid(e.to_string()))?; + let xml = std::str::from_utf8(&xml).map_err(|_| invalid("maindoc.xml is not UTF-8"))?; + // maindoc.xml declares Krita's DTD by public id; roxmltree only reads internal subsets and + // guards entity expansion. + let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let tree = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| invalid(format!("maindoc.xml: {e}")))?; + let image = tree.root_element().children().find(|n| n.has_tag_name("IMAGE")).ok_or_else(|| invalid("maindoc.xml has no IMAGE"))?; + let side = |a: &str| -> Result { + let v = number(image, a, 0.0)?; + if !(1.0..=f64::from(MAX_SIDE)).contains(&v) || v.fract() != 0.0 { + return Err(invalid(format!("image {a} {v} must be a whole number of pixels from 1 to {MAX_SIDE}"))); } - zip.finish().unwrap() + Ok(v as u32) + }; + let (w, h) = (side("width")?, side("height")?); + if (w as usize).saturating_mul(h as usize).saturating_mul(8) > MAX_PIXEL_BYTES { + return Err(invalid("the canvas exceeds the 1 GiB pixel limit")); } - - #[test] - fn imports_krita_composite_without_claiming_to_preserve_layers() { - let bytes = archive(MIMETYPE, true); - assert!(is_kra(&bytes)); - // The mimetype is authoritative even without a .kra extension. - for name in ["scene.kra", "download"] { - let r = crate::import(name, &bytes).unwrap(); - assert_eq!((r.document.size.width, r.document.size.height), (2, 1)); - assert_eq!(r.document.name, name); - assert_eq!(r.document.layers.len(), 1); - assert_eq!(r.document.layers[0].name, "Layer 0", "alpha-bearing composite"); - let pixels = r.document.layers[0].surface().unwrap(); - assert!(pixels.rgba(0, 0)[0] > 0.99); - assert!(pixels.rgba(1, 0)[2] > 0.99); - assert!((pixels.rgba(1, 0)[3] - 128.0 / 255.0).abs() < 0.01); - assert!(r.source_read_only, ".kra must not be overwritten by a flat save"); - assert!(!r.preview_only, "the flattened image is editable"); - assert!(r.warnings.iter().any(|w| w.contains("layers") && w.contains("not imported"))); + let cs = attr(image, "colorspacename").unwrap_or(""); + let Some(space) = Space::from_name(cs) else { + return preview(name, &zip, format!("Krita's {cs} colour space is not supported yet")); + }; + let (mode, depth) = space.model(); + let image_name = attr(image, "name").unwrap_or(""); + let mut reader = Reader { + zip, + ctl, + dir: format!("{image_name}/layers/"), + fmt: PixelFormat::new(mode, depth, true), + nodes: 0, + budget: MAX_PIXEL_BYTES, + warnings: Warnings::default(), + }; + let layers = reader.layers(image, 1, 0, 0)?; + ctl.progress(0.9); + let mut document = Document::new(name, Size::new(w, h), mode, depth); + document.layers = layers; + let icc = format!("{image_name}/annotations/icc"); + if let Some(e) = reader.zip.find(&icc) { + let profile = reader.zip.read(e, 16 << 20).map_err(|err| invalid(format!("{icc}: {err}")))?; + if !profile.is_empty() { + document.icc_profile = Some(std::sync::Arc::new(profile)); } } - - #[test] - fn malformed_or_non_krita_archives_do_not_open_as_krita() { - let wrong = archive(b"image/openraster", true); - assert!(!is_kra(&wrong)); - assert!(import("wrong.kra", &wrong, &Interrupt::NONE).is_err()); - assert!(import("missing.kra", &archive(MIMETYPE, false), &Interrupt::NONE).is_err()); - assert!(import("broken.kra", b"not a zip", &Interrupt::NONE).is_err()); + let (xres, yres) = (number(image, "x-res", 72.0)?, number(image, "y-res", 72.0)?); + if xres > 0.0 && xres < 1.0e6 { + document.resolution_dpi = xres as f32; + if let Some(w) = crate::unequal_resolution_warning(xres, yres) { + reader.warnings.note(&w); + } } - - #[test] - fn a_png_with_a_misleading_kra_extension_remains_a_png() { - let r = crate::import("renamed.kra", &png()).unwrap(); - assert_eq!(r.document.size.width, 2); - assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source"); + if document.layers.is_empty() { + reader.warnings.note("no layers of the Krita document could be imported"); } + if image.children().any(|n| n.has_tag_name("assistants") || n.has_tag_name("ReferenceImages")) { + reader.warnings.note("drawing assistants and reference images were left out"); + } + Ok(ImportResult { document, warnings: reader.warnings.finish(), source_read_only: true, preview_only: false }) } diff --git a/crates/io/src/kra/tests.rs b/crates/io/src/kra/tests.rs new file mode 100644 index 0000000000..4316a74c10 --- /dev/null +++ b/crates/io/src/kra/tests.rs @@ -0,0 +1,287 @@ +use super::tiles::{Space, lzf_decompress}; +use super::*; +use photocraft_codecs::{ChannelLayout, Format, Image, SampleType as CSample}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_format::zip::ZipWriter; +use photocraft_geom::Rect; + +const T: usize = 4; // small tiles keep the fixtures short + +/// LZF with literal runs only (valid liblzf output). +fn lzf_literal(data: &[u8]) -> Vec { + let mut out = Vec::new(); + for run in data.chunks(32) { + out.push((run.len() - 1) as u8); + out.extend_from_slice(run); + } + out +} + +/// A tiled pixel file from (x, y, interleaved Krita pixels) tiles; odd tiles LZF, even raw. +fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec)]) -> Vec { + let mut out = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE {pixel_size}\nDATA {}\n", tiles.len()).into_bytes(); + for (i, (x, y, px)) in tiles.iter().enumerate() { + let n = T * T; + let mut planar = vec![0; n * pixel_size]; + for p in 0..n { + for k in 0..pixel_size { + planar[k * n + p] = px[p * pixel_size + k]; + } + } + let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [vec![0], planar].concat() }; + out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes()); + out.extend(body); + } + out +} + +fn archive(maindoc: &str, files: &[(&str, Vec)]) -> Vec { + let mut z = ZipWriter::new(); + z.add("mimetype", MIMETYPE).unwrap(); + z.add("maindoc.xml", maindoc.as_bytes()).unwrap(); + for (name, data) in files { + z.add(name, data).unwrap(); + } + z.finish().unwrap() +} + +fn maindoc(cs: &str, layers: &str) -> String { + format!( + "\n\n\ + {layers}" + ) +} + +/// One tile of BGRA8 pixels: pixel p = (b, g, r, a) from `f(p)`. +fn bgra(f: impl Fn(usize) -> [u8; 4]) -> Vec { + (0..T * T).flat_map(f).collect() +} + +fn layer<'a>(d: &'a Document, name: &str) -> &'a Layer { + fn find<'a>(ls: &'a [Layer], name: &str) -> Option<&'a Layer> { + ls.iter().find_map(|l| if l.name == name { Some(l) } else { l.children().and_then(|c| find(c, name)) }) + } + find(&d.layers, name).unwrap_or_else(|| panic!("no layer {name}")) +} + +#[test] +fn reads_layers_groups_masks_and_properties() { + let doc = maindoc( + "RGBA", + r#" + + + + + + + "#, + ); + let red = bgra(|p| [0, 0, 255, (p * 16) as u8]); + let files = [ + ("img/layers/layer2", tile_file(4, &[(0, 0, red.clone()), (4, 0, bgra(|_| [10, 20, 30, 255]))])), + ("img/layers/layer2.defaultpixel", vec![0; 4]), + ("img/layers/layer3", tile_file(4, &[(-4, 4, bgra(|_| [1, 2, 3, 200]))])), + ("img/layers/mask4.pixelselection", tile_file(1, &[(-4, 4, (0..T * T).map(|p| (p * 10) as u8).collect())])), + ("img/layers/mask4.pixelselection.defaultpixel", vec![0]), + ("img/annotations/icc", b"not really a profile".to_vec()), + ]; + let r = crate::import("art.kra", &archive(&doc, &files)).unwrap(); + let d = &r.document; + assert!(r.source_read_only && !r.preview_only); + assert_eq!((d.size.width, d.size.height, d.mode, d.depth), (12, 8, ColorMode::Rgb, SampleType::U8)); + assert_eq!(d.resolution_dpi, 300.0); + assert!(d.icc_profile.is_some()); + // Bottom-to-top: the unsupported nodes are left out. + let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Masked", "Through"]); + let through = layer(d, "Through"); + assert_eq!(through.blend, BlendMode::PassThrough); + assert!(matches!(&through.content, LayerContent::Group(g) if !g.expanded)); + let inner = layer(d, "Inner"); + assert_eq!((inner.blend, inner.locks.all), (BlendMode::Screen, true)); + assert!((inner.opacity - 128.0 / 255.0).abs() < 1e-6); + // The layer's offset (1, 0) moves its tiles; the group's (2, 1) does not, as in Krita. + let s = inner.surface().unwrap(); + assert_eq!(s.content_bounds(), Rect::from_xywh(1, 0, 8, 4)); + let px = s.to_interleaved(Rect::from_xywh(1, 0, 2, 1)); + assert_eq!(px, [255, 0, 0, 0, 255, 0, 0, 16]); + assert_eq!(s.to_interleaved(Rect::from_xywh(5, 0, 1, 1)), [30, 20, 10, 255]); + let masked = layer(d, "Masked"); + assert!(!masked.visible); + assert_eq!(masked.blend, BlendMode::Difference); + let mask = masked.mask.as_ref().unwrap(); + assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(-4, 4, 2, 1)), [0, 10]); + assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(5, 5, 1, 1)), [0], "outside the tiles the default (hide) applies"); + let all = r.warnings.join("\n"); + for needle in ["vector layers", "LABA", "pass-through group's blend mode"] { + assert!(all.contains(needle), "{needle} not in {all}"); + } +} + +#[test] +fn sixteen_bit_and_gray_spaces_convert_exactly() { + let px16: Vec = (0..T * T).flat_map(|p| [1000u16 + p as u16, 2000, 3000, 65535].iter().flat_map(|v| v.to_le_bytes()).collect::>()).collect(); + let doc = maindoc( + "RGBA16", + r#""#, + ); + let gray: Vec = (0..T * T).flat_map(|p| [p as u8 * 10, 255]).collect(); + let r = + crate::import("a.kra", &archive(&doc, &[("img/layers/l", tile_file(8, &[(0, 0, px16)])), ("img/layers/g", tile_file(2, &[(0, 0, gray)]))])).unwrap(); + let d = &r.document; + assert_eq!(d.depth, SampleType::U16); + let s = layer(d, "L").surface().unwrap(); + assert_eq!(s.format(), PixelFormat::RGBA16); + let got: Vec = s.to_interleaved(Rect::from_xywh(1, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + // Krita's B, G, R, A becomes R, G, B, A. + assert_eq!(got, [3000, 2000, 1001, 65535]); + // A gray layer in an RGB document is converted. + let g = layer(d, "G").surface().unwrap(); + assert_eq!(g.format(), PixelFormat::RGBA16); + let v: Vec = g.to_interleaved(Rect::from_xywh(2, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [20 * 257, 20 * 257, 20 * 257, 65535]); + + let doc = maindoc("GRAYAU16", r#""#); + let g16: Vec = (0..T * T).flat_map(|p| [(p as u16 * 4000).to_le_bytes(), 40000u16.to_le_bytes()].concat()).collect(); + let r = crate::import("g.kra", &archive(&doc, &[("img/layers/g", tile_file(4, &[(0, 0, g16)]))])).unwrap(); + assert_eq!((r.document.mode, r.document.depth), (ColorMode::Grayscale, SampleType::U16)); + let s = layer(&r.document, "G16").surface().unwrap(); + let v: Vec = s.to_interleaved(Rect::from_xywh(3, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [12000, 40000]); +} + +#[test] +fn a_nonzero_default_pixel_fills_untouched_areas() { + let doc = maindoc("RGBA", r#""#); + let files = [("img/layers/l", tile_file(4, &[])), ("img/layers/l.defaultpixel", vec![255, 0, 0, 255])]; + let r = crate::import("a.kra", &archive(&doc, &files)).unwrap(); + let s = layer(&r.document, "L").surface().unwrap(); + assert_eq!(s.to_interleaved(Rect::from_xywh(100, 100, 1, 1)), [0, 0, 255, 255]); +} + +fn merged_png() -> Vec { + let img = Image::from_raw(12, 8, ChannelLayout::Rgba, CSample::U8, vec![90; 12 * 8 * 4]).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &Default::default()).unwrap() +} + +#[test] +fn unsupported_colour_spaces_open_as_the_merged_image() { + let doc = maindoc("CMYKA", ""); + let r = crate::import("c.kra", &archive(&doc, &[("mergedimage.png", merged_png())])).unwrap(); + assert!(r.preview_only && r.source_read_only); + assert_eq!(r.document.layers.len(), 1); + assert!(r.warnings.first().is_some_and(|w| w.contains("CMYKA") && w.contains("merged image")), "{:?}", r.warnings); + assert!(crate::import("c.kra", &archive(&doc, &[])).is_err(), "no merged image to fall back to"); +} + +#[test] +fn export_is_refused() { + let d = Document::new("x", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + for name in ["x.kra", "x.KRZ"] { + assert!(matches!(crate::export(&d, name, &Default::default()), Err(IoError::Unsupported(_))), "{name}"); + } +} + +#[test] +fn lzf_decodes_literals_and_overlapping_references() { + // "abc" then a 6-byte back-reference 3 back: abcabcabc. + assert_eq!(lzf_decompress(&[2, b'a', b'b', b'c', 0x80, 2], 9).unwrap(), b"abcabcabc"); + // A long back-reference (len 7 + extra) over a single byte: run-length. + assert_eq!(lzf_decompress(&[0, b'x', 0xe0, 3, 0], 13).unwrap(), vec![b'x'; 13]); + for bad in [&[0x20, 5][..], &[5, 1, 2], &[0xe0], &[2, 1, 2, 3, 0x20]] { + assert!(lzf_decompress(bad, 64).is_err(), "{bad:?}"); + } + assert!(lzf_decompress(&[2, 1, 2, 3], 2).is_err(), "overrun"); + assert!(lzf_decompress(&[2, 1, 2, 3], 4).is_err(), "short"); +} + +#[test] +fn malformed_files_fail_cleanly() { + let one = |cs: &str, data: Vec| { + archive(&maindoc(cs, r#""#), &[("img/layers/l", data)]) + }; + let cases: Vec<(&str, Vec)> = vec![ + ("not a zip", b"nope".to_vec()), + ("no maindoc", { + let mut z = ZipWriter::new(); + z.add("mimetype", MIMETYPE).unwrap(); + z.finish().unwrap() + }), + ("bad xml", archive("", &[])), + ("zero size", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"0\""), &[])), + ("huge", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"300000\"").replace("height=\"8\"", "height=\"300000\""), &[])), + ("version 1", one("RGBA", b"VERSION 1\n".to_vec())), + ("no header", one("RGBA", b"garbage".to_vec())), + ("big tiles", one("RGBA", b"VERSION 2\nTILEWIDTH 99999\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 0\n".to_vec())), + ("pixel size", one("RGBA", tile_file(2, &[]))), + ("too many tiles", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 999999\n").into_bytes())), + ("bad tile line", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,ZIP,4\n").into_bytes())), + ("truncated tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,400\n\0").into_bytes())), + ("raw size", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,3\n\0ab").into_bytes())), + ("flag", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,1\n\x07").into_bytes())), + ("far tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n2000000000,0,LZF,1\n\x01").into_bytes())), + ("bad opacity", archive(&maindoc("RGBA", r#""#), &[])), + ]; + for (what, bytes) in cases { + assert!(crate::import("x.kra", &bytes).is_err(), "{what}"); + } + let deep = maindoc("RGBA", &format!("{}{}", r#""#.repeat(150), "".repeat(150))); + assert!(matches!(crate::import("x.kra", &archive(&deep, &[])), Err(IoError::Unsupported(_)))); +} + +#[test] +fn truncated_and_corrupted_files_never_panic() { + let doc = maindoc( + "RGBA", + r#""#, + ); + let bytes = archive( + &doc, + &[ + ("img/layers/l", tile_file(4, &[(0, 0, bgra(|p| [p as u8; 4])), (4, 4, bgra(|_| [9; 4]))])), + ("img/layers/m.pixelselection", tile_file(1, &[(0, 0, vec![7; T * T])])), + ("mergedimage.png", merged_png()), + ], + ); + assert!(crate::import("ok.kra", &bytes).is_ok()); + for cut in (0..bytes.len()).step_by((bytes.len() / 300).max(1)) { + let _ = crate::import("x.kra", bytes.get(..cut).unwrap_or_default()); + } + let mut state = 0x1234_5678_u32; + for _ in 0..400 { + let mut b = bytes.clone(); + for _ in 0..3 { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let i = state as usize % b.len(); + b[i] ^= (state >> 24) as u8 | 1; + } + let _ = crate::import("x.kra", &b); + } +} + +#[test] +fn space_names() { + assert_eq!(Space::from_name("RGBA"), Some(Space::Rgba8)); + assert_eq!(Space::from_name("RGBAF32"), None); + assert!(is_kra(&archive("", &[]))); +} + +#[test] +fn a_png_with_a_misleading_kra_extension_remains_a_png() { + for name in ["renamed.kra", "renamed.krz"] { + let r = crate::import(name, &merged_png()).unwrap(); + assert_eq!(r.document.size.width, 12); + assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source"); + } +} + +#[test] +fn the_mimetype_wins_over_the_extension() { + let bytes = archive(&maindoc("RGBA", ""), &[]); + assert!(is_kra(&bytes)); + assert!(crate::import("download", &bytes).is_ok_and(|r| r.source_read_only)); +} diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs new file mode 100644 index 0000000000..b369341b99 --- /dev/null +++ b/crates/io/src/kra/tiles.rs @@ -0,0 +1,259 @@ +//! Krita's tiled pixel files and the LZF compression inside them. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::{Interrupt, Surface}; + +use super::{Result, invalid}; +use crate::IoError; + +/// The pixel layouts we read, by Krita colour-space id. Samples are straight (unpremultiplied). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Space { + /// `RGBA`: B, G, R, A bytes. + Rgba8, + /// `RGBA16`: B, G, R, A as little-endian u16. + Rgba16, + /// `GRAYA`: gray, alpha bytes. + Gray8, + /// `GRAYAU16`: gray, alpha as little-endian u16. + Gray16, + /// Masks and selections: one byte. + Alpha8, +} + +impl Space { + pub(crate) fn from_name(name: &str) -> Option { + Some(match name { + "RGBA" => Space::Rgba8, + "RGBA16" => Space::Rgba16, + "GRAYA" => Space::Gray8, + "GRAYAU16" => Space::Gray16, + _ => return None, + }) + } + pub(crate) fn pixel_size(self) -> usize { + match self { + Space::Rgba8 => 4, + Space::Rgba16 => 8, + Space::Gray8 => 2, + Space::Gray16 => 4, + Space::Alpha8 => 1, + } + } + /// The document colour mode and depth for an image in this space. + pub(crate) fn model(self) -> (ColorMode, SampleType) { + match self { + Space::Rgba8 => (ColorMode::Rgb, SampleType::U8), + Space::Rgba16 => (ColorMode::Rgb, SampleType::U16), + Space::Gray8 | Space::Alpha8 => (ColorMode::Grayscale, SampleType::U8), + Space::Gray16 => (ColorMode::Grayscale, SampleType::U16), + } + } + /// The surface format whose bytes [`Self::convert`] writes directly. + fn native(self) -> PixelFormat { + match self { + Space::Alpha8 => PixelFormat::GRAY8, + s => { + let (mode, depth) = s.model(); + PixelFormat::new(mode, depth, true) + } + } + } + /// One interleaved Krita pixel as straight RGBA (0..1). + fn rgba(self, px: &[u8]) -> [f32; 4] { + let b = |i: usize| f32::from(px.get(i).copied().unwrap_or(0)) / 255.0; + let w = |i: usize| f32::from(u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)])) / 65535.0; + match self { + Space::Rgba8 => [b(2), b(1), b(0), b(3)], + Space::Rgba16 => [w(4), w(2), w(0), w(6)], + Space::Gray8 => [b(0), b(0), b(0), b(1)], + Space::Gray16 => [w(0), w(0), w(0), w(2)], + Space::Alpha8 => [b(0), b(0), b(0), 1.0], + } + } + /// A default pixel's samples (Krita order, 0..1), for [`Self::to_format`]. + pub(crate) fn normalised(self, px: &[u8]) -> Vec { + self.rgba(px).to_vec() + } + /// RGBA samples from [`Self::normalised`] as `fmt`'s channels. + pub(crate) fn to_format(self, rgba: &[f32], fmt: PixelFormat) -> Vec { + let p = [0, 1, 2, 3].map(|i| rgba.get(i).copied().unwrap_or(0.0)); + photocraft_raster::from_rgba(&fmt, p) + } + /// Interleaved Krita pixels as encoded `fmt` bytes. + fn convert(self, src: &[u8], fmt: PixelFormat, out: &mut Vec) { + out.clear(); + let ps = self.pixel_size(); + if fmt == self.native() { + for px in src.chunks_exact(ps) { + match self { + Space::Rgba8 => out.extend([2, 1, 0, 3].map(|i| px.get(i).copied().unwrap_or(0))), + Space::Rgba16 => { + for i in [4, 2, 0, 6] { + let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]); + out.extend(v.to_ne_bytes()); + } + } + Space::Gray16 => { + for i in [0, 2] { + let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]); + out.extend(v.to_ne_bytes()); + } + } + Space::Gray8 | Space::Alpha8 => out.extend_from_slice(px), + } + } + return; + } + let mut enc = vec![0; fmt.bytes_per_pixel()]; + for px in src.chunks_exact(ps) { + let v = photocraft_raster::from_rgba(&fmt, self.rgba(px)); + photocraft_raster::encode_pixel(&fmt, &v, &mut enc); + out.extend_from_slice(&enc); + } + } +} + +/// LZF decompression (the liblzf format) into exactly `expected` bytes. +pub(crate) fn lzf_decompress(src: &[u8], expected: usize) -> Result> { + let bad = || invalid("corrupt LZF tile data"); + let mut out = Vec::with_capacity(expected); + let mut ip = 0; + while ip < src.len() { + let ctrl = usize::from(*src.get(ip).ok_or_else(bad)?); + ip += 1; + if ctrl < 32 { + let len = ctrl + 1; + let lit = src.get(ip..ip + len).ok_or_else(bad)?; + if out.len() + len > expected { + return Err(bad()); + } + out.extend_from_slice(lit); + ip += len; + } else { + let mut len = ctrl >> 5; + if len == 7 { + len += usize::from(*src.get(ip).ok_or_else(bad)?); + ip += 1; + } + len += 2; + let back = ((ctrl & 0x1f) << 8) + usize::from(*src.get(ip).ok_or_else(bad)?) + 1; + ip += 1; + let start = out.len().checked_sub(back).ok_or_else(bad)?; + if out.len() + len > expected { + return Err(bad()); + } + // Back-references may overlap the bytes they produce, so copy one at a time. + for i in start..start + len { + let b = *out.get(i).ok_or_else(bad)?; + out.push(b); + } + } + } + if out.len() != expected { + return Err(invalid(format!("LZF tile data decodes to {} bytes, expected {expected}", out.len()))); + } + Ok(out) +} + +/// A parsed tiled pixel file (`VERSION 2`), borrowing the tile payloads. +pub(crate) struct TileFile<'a> { + pub(crate) width: usize, + pub(crate) height: usize, + pub(crate) pixel_size: usize, + tiles: Vec<(i32, i32, &'a [u8])>, +} + +fn line<'a>(bytes: &'a [u8], pos: &mut usize) -> Result<&'a str> { + let rest = bytes.get(*pos..).ok_or_else(|| invalid("truncated tile file"))?; + let end = rest.iter().take(256).position(|b| *b == b'\n').ok_or_else(|| invalid("truncated or malformed tile file header"))?; + *pos += end + 1; + std::str::from_utf8(rest.get(..end).unwrap_or_default()).map_err(|_| invalid("tile file header is not text")) +} + +fn header_value(bytes: &[u8], pos: &mut usize, key: &str, max: usize) -> Result { + let l = line(bytes, pos)?; + let v = l.strip_prefix(key).map(str::trim).ok_or_else(|| invalid(format!("tile file: expected {key}, found `{l}`")))?; + v.parse::().ok().filter(|v| (1..=max).contains(v)).ok_or_else(|| invalid(format!("tile file: invalid {key} `{v}`"))) +} + +impl<'a> TileFile<'a> { + pub(crate) fn parse(bytes: &'a [u8]) -> Result> { + let mut pos = 0; + let version = line(bytes, &mut pos)?; + if version.trim() != "VERSION 2" { + return Err(IoError::Unsupported(format!("Krita tile file `{}` (only VERSION 2, Krita 2.0 and later)", version.trim()))); + } + let width = header_value(bytes, &mut pos, "TILEWIDTH", 1024)?; + let height = header_value(bytes, &mut pos, "TILEHEIGHT", 1024)?; + let pixel_size = header_value(bytes, &mut pos, "PIXELSIZE", 64)?; + let l = line(bytes, &mut pos)?; + let count = l + .strip_prefix("DATA") + .map(str::trim) + .and_then(|v| v.parse::().ok()) + .ok_or_else(|| invalid(format!("tile file: invalid DATA line `{l}`")))?; + // Every tile needs at least a header line and a flag byte. + if count > bytes.len() / 8 + 1 { + return Err(invalid("tile count exceeds the file size")); + } + let mut tiles = Vec::with_capacity(count); + for _ in 0..count { + let l = line(bytes, &mut pos)?; + let mut f = l.split(','); + let (x, y, method, len) = (f.next(), f.next(), f.next(), f.next()); + let coord = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.unsigned_abs() <= super::MAX_SIDE * 4); + let (Some(x), Some(y), Some("LZF"), Some(len), None) = + (coord(x), coord(y), method.map(str::trim), len.and_then(|v| v.trim().parse::().ok()), f.next()) + else { + return Err(invalid(format!("tile file: malformed tile header `{l}`"))); + }; + let data = bytes.get(pos..pos.checked_add(len).ok_or_else(|| invalid("tile size overflow"))?).ok_or_else(|| invalid("truncated tile data"))?; + pos += len; + tiles.push((x, y, data)); + } + Ok(TileFile { width, height, pixel_size, tiles }) + } + + /// Bytes the decoded tiles take at `bpp` bytes per pixel. + pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result { + self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow")) + } + + /// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`). + pub(crate) fn decode_into(&self, surface: &mut Surface, space: Space, dx: i32, dy: i32, ctl: &Interrupt<'_>) -> Result<()> { + let fmt = surface.format(); + let n = self.width * self.height; + let expected = n * self.pixel_size; + let mut interleaved = vec![0u8; expected]; + let mut out = Vec::new(); + for &(x, y, data) in &self.tiles { + ctl.check().map_err(|_| IoError::Cancelled)?; + let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?; + let planar = match flag { + 1 => lzf_decompress(payload, expected)?, + 0 if payload.len() == expected => payload.to_vec(), + 0 => return Err(invalid("raw tile has the wrong size")), + f => return Err(invalid(format!("unknown tile compression flag {f}"))), + }; + // Byte planes: byte k of pixel i is at k * n + i. + for (k, plane) in planar.chunks_exact(n).enumerate() { + for (i, b) in plane.iter().enumerate() { + if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { + *slot = *b; + } + } + } + space.convert(&interleaved, fmt, &mut out); + let (x0, y0) = + (x.checked_add(dx).ok_or_else(|| invalid("tile offset overflow"))?, y.checked_add(dy).ok_or_else(|| invalid("tile offset overflow"))?); + let rect = Rect::from_xywh(x0, y0, self.width as u32, self.height as u32); + if rect.width() as usize != self.width || rect.height() as usize != self.height || out.len() != n * fmt.bytes_per_pixel() { + return Err(invalid("tile does not fit its position")); + } + surface.write_interleaved(rect, &out); + } + Ok(()) + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6d33c332a0..b80da66c3c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -18,7 +18,8 @@ //! * Paint.NET PDN3 documents open as editable bitmap layers (import only). //! * OpenRaster (`.ora`) documents open and save with their layers, groups, blend modes, //! opacity and visibility; see `ora`. -//! * Krita (`.kra`) documents open as their merged composite; source layers are not imported. +//! * Krita (`.kra`, `.krz`) documents open with their paint layers, groups and transparency +//! masks (import only); see `kra`. //! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively //! with no source save path, what isn't imported listed in the warnings; a file //! whose native data can't be read opens as its embedded preview; see `affinity`. @@ -36,16 +37,19 @@ mod adjust_map; pub mod affinity; pub mod annotations_map; pub mod blocks; +pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; pub mod effects_map; pub mod exr_parts; mod flat; mod gradient_bake; +pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod kra; pub mod linked; mod multichannel_map; mod ora; +pub use ora::losses as ora_losses; pub mod pattern_map; mod pdn; pub mod pixels; @@ -61,6 +65,7 @@ pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; +pub use photocraft_codecs::DpxBits; use photocraft_codecs::{CodecError, EncodeOptions}; use photocraft_doc::Document; use photocraft_psd::{PsdError, PsdFile}; @@ -79,12 +84,12 @@ pub enum IoError { /// Paint.NET document decode failure. #[error("PDN: {0}")] Pdn(String), + /// Krita document read failure. + #[error("Krita: {0}")] + Kra(String), /// OpenRaster read failure. #[error("OpenRaster: {0}")] Ora(String), - /// Krita document decode failure. - #[error("Krita: {0}")] - Kra(String), /// PSD parse/write failure. #[error("PSD: {0}")] Psd(#[from] PsdError), @@ -285,9 +290,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m if ora::is_ora(bytes) || has_extension(name, "ora") { return ora::import(name, bytes, ctl); } - // Krita files are ZIP containers carrying a merged PNG. A recognized raster signature - // still wins over a misleading .kra suffix (as with SVG imports). - if kra::is_kra(bytes) || (has_extension(name, "kra") && photocraft_codecs::detect(bytes).is_none()) { + // Krita files are ZIP containers. A recognized raster signature still wins over a + // misleading .kra suffix (as with SVG imports). + if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) { return kra::import(name, bytes, ctl); } if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") { @@ -379,6 +384,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & if affinity::EXTENSIONS.contains(&ext.as_str()) { return Err(IoError::Unsupported("Affinity export is not implemented; save a new PSD, PNG or .pcraft copy".into())); } + if kra::EXTENSIONS.contains(&ext.as_str()) { + return Err(IoError::Unsupported("Krita files are import-only; save as .ora to open the layers in Krita, or .pcraft to keep everything".into())); + } if ext == "ora" { return ora::export(doc); } diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index b1b7286d25..eaf77e864f 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -84,14 +84,101 @@ pub fn parse_linked_files(data: &[u8]) -> Vec { /// The embedded file for smart-object `uuid` among the document's preserved PSD global blocks. pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option { + find_in_blocks(meta.psd_global_blocks.iter().map(|(_, k, d)| (k, d.as_slice())), uuid) +} + +/// The embedded file for smart-object `uuid` among PSD global blocks given as (key, data). +pub fn find_in_blocks<'a>(blocks: impl IntoIterator, uuid: &str) -> Option { if uuid.is_empty() { return None; } - meta.psd_global_blocks - .iter() - .filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")) - .flat_map(|(_, _, d)| parse_linked_files(d)) - .find(|f| f.uuid == uuid) + blocks.into_iter().filter(|(k, _)| matches!(*k, b"lnk2" | b"lnk3" | b"lnkD")).flat_map(|(_, d)| parse_linked_files(d)).find(|f| f.uuid == uuid) +} + +/// The size of embedded file `uuid` among PSD global blocks given as (key, data), when it is there +/// and readable with a non-empty size (the source size a `PlLd` record leaves out). +pub(crate) fn source_size_in_blocks<'a>( + blocks: impl IntoIterator, + uuid: &str, + ctl: &photocraft_raster::Interrupt, +) -> Option<(f64, f64)> { + let f = find_in_blocks(blocks, uuid)?; + if ctl.cancelled() { + return None; + } + source_size_probe(&f.bytes).filter(|(w, h)| *w > 0.0 && *h > 0.0) +} + +/// Pixel size of an embedded source file from its header: PSD/PSB, TIFF (the default page's +/// directory), PNG (`IHDR`) and JPEG (the frame header); any other image is decoded flat by +/// `photocraft-codecs`. It never imports a document: a layered TIFF can carry PSD layer data +/// whose `lnkD` holds another file, so a full import here would nest without bound (#2465). +pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { + let be32 = |at: usize| bytes.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); + let size = |w: u32, h: u32| (f64::from(w), f64::from(h)); + match bytes { + _ if crate::is_psd(bytes) => be32(18).zip(be32(14)).map(|(w, h)| size(w, h)), + [b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] | [b'I', b'I', 43, 0, ..] | [b'M', b'M', 0, 43, ..] => { + let info = photocraft_codecs::tiff_info(bytes).ok()?; + let page = info.pages.get(info.default_page()?)?; + Some(size(page.width, page.height)) + } + [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => be32(16).zip(be32(20)).map(|(w, h)| size(w, h)), + [0xFF, 0xD8, ..] => jpeg_size(bytes), + _ => photocraft_codecs::decode(bytes).ok().map(|img| { + let (w, h) = img.dimensions(); + size(w, h) + }), + } +} + +/// A JPEG's size from its first start-of-frame marker (ITU-T T.81 B.2.2), walking the marker +/// segments without decoding. Each step moves forward, so a malformed file ends the walk. +fn jpeg_size(bytes: &[u8]) -> Option<(f64, f64)> { + let mut at = 2usize; + loop { + // Markers are 0xFF then a code; extra 0xFF fill bytes may come first. + while bytes.get(at) == Some(&0xFF) && bytes.get(at + 1) == Some(&0xFF) { + at += 1; + } + if *bytes.get(at)? != 0xFF { + return None; + } + let code = *bytes.get(at + 1)?; + match code { + // SOF0..SOF15, except DHT (C4), JPG (C8) and DAC (CC). + 0xC0..=0xCF if !matches!(code, 0xC4 | 0xC8 | 0xCC) => { + let h = bytes.get(at + 5..at + 7)?; + let w = bytes.get(at + 7..at + 9)?; + return Some((f64::from(u16::from_be_bytes([w[0], w[1]])), f64::from(u16::from_be_bytes([h[0], h[1]])))); + } + // Standalone markers have no length. + 0x01 | 0xD0..=0xD7 => at = at.checked_add(2)?, + // Start of scan or end of image before any frame header. + 0xDA | 0xD9 => return None, + _ => { + let len = bytes.get(at + 2..at + 4).map(|b| usize::from(u16::from_be_bytes([b[0], b[1]])))?; + if len < 2 { + return None; + } + at = at.checked_add(2 + len)?; + } + } + } +} + +/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image). +/// Other formats are decoded in full, which `ctl` can cancel (its progress is left alone: this is +/// a step of a larger operation). +pub(crate) fn source_geometry(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result<((f64, f64), f64), String> { + if crate::is_psd(bytes) { + let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); + let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?; + return Ok(((w, h), 72.0)); + } + let cancel = || ctl.cancelled(); + let doc = crate::import_with(name, bytes, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|e| e.to_string())?.document; + Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi))) } /// The four-character file type Photoshop records for an embedded file, from its contents. @@ -188,6 +275,85 @@ pub fn block_uuids(data: &[u8]) -> Vec { split_items(data).into_iter().filter_map(|(u, _)| u).collect() } +/// Copies only records accepted by `keep` (UUID and complete raw record including padding). +/// Unlike [`rebuild_block`], unidentified records and malformed tails are discarded: clipboard +/// copies must not carry unrelated embedded files. Empty UUIDs cannot identify source bytes. +/// Selected identity must be provable without lossy UTF-8 decoding; an ambiguous or truncated +/// selected ID is an error instead. No payload decoding or filesystem access is performed. +/// `None` means the block already contains only selected complete records and can be shared. +pub fn select_items(data: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result>, crate::IoError> { + let error = || crate::IoError::Unsupported("can't copy a truncated selected PSD linked-layer record".into()); + let mut selected = None; + let mut at = 0usize; + while let Some(rest) = data.get(at..).filter(|r| !r.is_empty()) { + let Some((item, raw)) = framed_item(rest) else { + if let Some(item) = rest.get(8..) + && selection_uuid(item, rest, keep)?.is_some_and(|uuid| keep(uuid, rest)) + { + return Err(error()); + } + if selected.is_none() { + let mut prefix = Vec::new(); + append_selected(&mut prefix, data.get(..at).ok_or_else(error)?)?; + selected = Some(prefix); + } + break; + }; + if selection_uuid(item, raw, keep)?.is_some_and(|uuid| keep(uuid, raw)) { + if let Some(out) = &mut selected { + append_selected(out, raw)?; + } + } else if selected.is_none() { + let mut prefix = Vec::new(); + append_selected(&mut prefix, data.get(..at).ok_or_else(error)?)?; + selected = Some(prefix); + } + at = at.checked_add(raw.len()).ok_or_else(error)?; + } + Ok(selected) +} + +// Legacy PSD readers retain lossy IDs for file fidelity. Selection needs exact identity; +// a lossy label is used only to refuse an ambiguous transfer, never to retain a record. +fn selection_uuid<'a>(item: &'a [u8], raw: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result, crate::IoError> { + let Some(n) = item.get(8).map(|n| usize::from(*n)) else { return Ok(None) }; + let Some(bytes) = 9usize.checked_add(n).and_then(|end| item.get(9..end)).filter(|bytes| !bytes.is_empty()) else { return Ok(None) }; + match std::str::from_utf8(bytes) { + Ok(uuid) => Ok(Some(uuid)), + Err(_) if keep(&String::from_utf8_lossy(bytes), raw) => { + Err(crate::IoError::Unsupported("can't verify a PSD linked-layer source ID with invalid UTF-8".into())) + } + Err(_) => Ok(None), + } +} + +/// Whether a complete raw record already occurs in a block, without decoding its file bytes. +pub fn contains_item(data: &[u8], record: &[u8]) -> bool { + let mut remaining = data; + while let Some((_, raw)) = framed_item(remaining) { + if raw == record { + return true; + } + let Some(next) = remaining.get(raw.len()..) else { return false }; + remaining = next; + } + false +} + +fn framed_item(data: &[u8]) -> Option<(&[u8], &[u8])> { + let len = usize::try_from(u64::from_be_bytes(data.get(..8)?.try_into().ok()?)).ok()?; + let end = len.checked_add(8)?; + let item = data.get(8..end)?; + let padded = end.checked_add(3).map(|n| n & !3)?.min(data.len()); + Some((item, data.get(..padded)?)) +} + +fn append_selected(out: &mut Vec, bytes: &[u8]) -> Result<(), crate::IoError> { + out.try_reserve(bytes.len()).map_err(|_| crate::IoError::Unsupported("not enough memory to copy PSD linked-layer records".into()))?; + out.extend_from_slice(bytes); + Ok(()) +} + /// Copies one linked item under a new UUID, preserving its embedded, external or alias payload. pub(crate) fn copy_item(data: &[u8], uuid: &str, new_uuid: &str) -> Option> { let (_, raw) = split_items(data).into_iter().find(|(u, _)| u.as_deref() == Some(uuid))?; @@ -210,6 +376,45 @@ mod tests { use super::*; use std::sync::Arc; + /// #2465: a placed source's size comes from its header, for every format a CS2 file embeds, + /// and never by importing it as a document. + #[test] + fn source_size_probe_reads_headers() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; + let img = Image::new(7, 5, ChannelLayout::Rgb, SampleType::U8).unwrap(); + for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp] { + let bytes = photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap(); + assert_eq!(source_size_probe(&bytes), Some((7.0, 5.0)), "{format:?}"); + } + let mut psd = b"8BPS\0\x01".to_vec(); + psd.extend([0; 6]); + psd.extend(3u16.to_be_bytes()); + psd.extend(40u32.to_be_bytes()); // height + psd.extend(30u32.to_be_bytes()); // width + assert_eq!(source_size_probe(&psd), Some((30.0, 40.0))); + assert_eq!(source_size_probe(b"not an image"), None); + assert_eq!(source_size_probe(&[]), None); + } + + /// Malformed JPEG marker walks end without a size and without panicking or looping. + #[test] + fn jpeg_size_rejects_malformed_marker_walks() { + let sof = [0xFF, 0xC0, 0, 17, 8, 0, 5, 0, 7]; + let ok: Vec = [&[0xFF, 0xD8][..], &[0xFF, 0xE0, 0, 4, 1, 2], &[0xFF, 0xFF, 0xD0], &sof].concat(); + assert_eq!(jpeg_size(&ok), Some((7.0, 5.0)), "APP0, a fill byte and a standalone marker are skipped"); + for bad in [ + vec![0xFF, 0xD8], + vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 1], // length below 2 + vec![0xFF, 0xD8, 0xFF, 0xE0, 0xFF, 0xFF], // length past the end + vec![0xFF, 0xD8, 0x00, 0x00], // not a marker + vec![0xFF, 0xD8, 0xFF, 0xDA, 0, 2], // scan before any frame header + vec![0xFF, 0xD8, 0xFF, 0xC0, 0, 17, 8], // truncated frame header + [&[0xFF, 0xD8][..], &[0xFF; 64]].concat(), + ] { + assert_eq!(jpeg_size(&bad), None, "{bad:?}"); + } + } + #[test] fn round_trips_and_finds_by_uuid() { let a = LinkedFile { uuid: "abc-1".into(), file_name: "a.png".into(), bytes: vec![1, 2, 3] }; @@ -266,4 +471,79 @@ mod tests { assert_eq!(©[17 + "independent-copy".len()..8 + copied_len], &raw[20..8 + original_len]); assert!(copy_item(&raw, "missing", "new").is_none()); } + #[test] + fn selected_records_exclude_other_files_and_unidentified_pieces() { + let a = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![1; 3] }); + let b = encode_linked_file(&LinkedFile { uuid: "b".into(), file_name: "b.psb".into(), bytes: vec![2; 4] }); + assert!(select_items(&a, &|uuid, _| uuid == "a").unwrap().is_none(), "an exclusive block can keep its original Arc"); + let mut data = a.clone(); + data.extend(&b); + data.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]); + assert_eq!(select_items(&data, &|uuid, _| uuid == "a").unwrap(), Some(a.clone())); + assert_eq!(select_items(&data, &|uuid, _| uuid == "b").unwrap(), Some(b)); + assert_eq!(select_items(&data, &|_, _| false).unwrap(), Some(Vec::new())); + // A complete but unidentified item can be skipped without losing a later selected item. + let mut unreadable = 1u64.to_be_bytes().to_vec(); + unreadable.extend([7, 0, 0, 0]); + unreadable.extend(&a); + assert_eq!(select_items(&unreadable, &|uuid, _| uuid == "a").unwrap(), Some(a)); + } + + #[test] + fn selected_external_and_alias_records_keep_unknown_payloads_verbatim() { + for kind in [*b"liFE", *b"liFA", *b"next"] { + let mut raw = encode_linked_file(&LinkedFile { uuid: "selected".into(), file_name: "missing.psb".into(), bytes: vec![7; 5] }); + raw[8..12].copy_from_slice(&kind); + raw[12..16].copy_from_slice(&42u32.to_be_bytes()); // an unknown future version + let mut data = encode_linked_file(&LinkedFile { uuid: "unselected".into(), file_name: "other.psb".into(), bytes: vec![9; 3] }); + data.extend(&raw); + assert_eq!(select_items(&data, &|uuid, _| uuid == "selected").unwrap(), Some(raw)); + } + } + + #[test] + fn truncated_selected_records_fail_instead_of_becoming_unavailable() { + let mut raw = encode_linked_file(&LinkedFile { uuid: "selected".into(), file_name: "missing.psb".into(), bytes: vec![7; 5] }); + raw[..8].copy_from_slice(&u64::MAX.to_be_bytes()); + assert!(select_items(&raw, &|uuid, _| uuid == "selected").unwrap_err().to_string().contains("truncated selected")); + assert_eq!(select_items(&raw, &|uuid, _| uuid == "other").unwrap(), Some(Vec::new())); + } + #[test] + fn existing_records_are_matched_individually_without_matching_other_payloads() { + let a = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![1; 3] }); + let b = encode_linked_file(&LinkedFile { uuid: "b".into(), file_name: "b.psb".into(), bytes: vec![2; 4] }); + let different = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![9; 3] }); + let mut combined = a.clone(); + combined.extend(&b); + assert!(contains_item(&combined, &a)); + assert!(contains_item(&combined, &b)); + assert!(!contains_item(&combined, &different)); + assert_eq!(select_items(&a, &|_, raw| !contains_item(&combined, raw)).unwrap(), Some(Vec::new())); + assert!(select_items(&different, &|_, raw| !contains_item(&combined, raw)).unwrap().is_none()); + } + #[test] + fn empty_uuids_are_unidentified_even_when_requested() { + let raw = encode_linked_file(&LinkedFile { uuid: String::new(), file_name: "unidentified.psb".into(), bytes: vec![7; 5] }); + assert_eq!(select_items(&raw, &|_, _| true).unwrap(), Some(Vec::new())); + let mut truncated = raw; + truncated[..8].copy_from_slice(&u64::MAX.to_be_bytes()); + assert_eq!(select_items(&truncated, &|_, _| true).unwrap(), Some(Vec::new())); + } + #[test] + fn selection_never_uses_lossy_uuid_aliases_as_record_identity() { + let mut a = encode_linked_file(&LinkedFile { uuid: "x".into(), file_name: "a.psb".into(), bytes: vec![1; 3] }); + let mut b = encode_linked_file(&LinkedFile { uuid: "x".into(), file_name: "b.psb".into(), bytes: vec![2; 4] }); + a[17] = 0xff; + b[17] = 0xfe; + let mut data = a; + data.extend(b); + assert_eq!(block_uuids(&data), vec!["\u{fffd}", "\u{fffd}"], "legacy lossy parsing remains unchanged"); + assert!(select_items(&data, &|uuid, _| uuid == "\u{fffd}").unwrap_err().to_string().contains("invalid UTF-8")); + let valid = encode_linked_file(&LinkedFile { uuid: "valid".into(), file_name: "valid.psb".into(), bytes: vec![3; 5] }); + data.extend(&valid); + assert_eq!(select_items(&data, &|uuid, _| uuid == "valid").unwrap(), Some(valid)); + // A literal replacement character can itself be a valid UTF-8 identity. + let valid_unicode = encode_linked_file(&LinkedFile { uuid: "\u{fffd}".into(), file_name: "unicode.psb".into(), bytes: vec![4; 2] }); + assert!(select_items(&valid_unicode, &|uuid, _| uuid == "\u{fffd}").unwrap().is_none()); + } } diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index dba3c96197..42b2ab7a33 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -476,6 +476,8 @@ struct Writer<'a> { next: usize, sample: CSample, warnings: Warnings, + /// Only collect the warnings ([`losses`]): no pixels are rendered or encoded. + dry: bool, } /// Straight RGBA floats as interleaved PNG samples at `sample` depth. @@ -559,6 +561,20 @@ impl Writer<'_> { attrs } + /// What saving a non-group layer's pixels loses: a type, shape, fill or smart object layer + /// becomes pixels, and its pixel mask is applied to them. + fn content_warnings(&mut self, layer: &Layer) { + if !matches!(layer.content, LayerContent::Raster(_)) { + self.warnings.layer(&layer.name, &format!("{} layers are saved to OpenRaster as pixels", layer.content.kind_name().to_lowercase())); + } + if let Some(mask) = layer.mask.as_ref().filter(|m| m.enabled) { + if mask.feather > 0.0 { + self.warnings.layer(&layer.name, "layer mask feathering is not applied in OpenRaster"); + } + self.warnings.layer(&layer.name, "layer masks were applied to the layer pixels"); + } + } + /// The layer's own pixels (straight RGBA floats) and their bounds; its pixel mask applied. fn pixels(&mut self, layer: &Layer) -> (Rect, Vec<[f32; 4]>) { let canvas = self.doc.bounds(); @@ -578,15 +594,10 @@ impl Writer<'_> { solo.mask = None; solo.effects = Default::default(); let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas); - self.warnings.layer(&layer.name, &format!("{} layers are saved to OpenRaster as pixels", layer.content.kind_name().to_lowercase())); (r, photocraft_compose::render_layer(&solo, r).px) } }; if let Some(mask) = layer.mask.as_ref().filter(|m| m.enabled) { - if mask.feather > 0.0 { - self.warnings.layer(&layer.name, "layer mask feathering is not applied in OpenRaster"); - } - self.warnings.layer(&layer.name, "layer masks were applied to the layer pixels"); let mut values = Vec::new(); mask.values_into(rect, &mut values); for (p, m) in buf.iter_mut().zip(values) { @@ -623,6 +634,10 @@ impl Writer<'_> { } _ => { let attrs = self.common(layer); + self.content_warnings(layer); + if self.dry { + continue; + } let (rect, px) = self.pixels(layer); let (rect, px) = if rect.is_empty() { (Rect::from_xywh(0, 0, 1, 1), vec![[0.0; 4]]) } else { (rect, px) }; let src = format!("data/layer{}.png", self.next); @@ -648,10 +663,8 @@ pub(crate) fn export(doc: &Document) -> Result { let mut zip = ZipWriter::new(); // The mimetype must be the first entry, stored, so the type is readable at a fixed offset. zip.add("mimetype", MIMETYPE)?; - let mut w = Writer { doc, zip, xml: String::new(), next: 0, sample, warnings: Warnings::default() }; - if doc.depth == SampleType::F32 { - w.warnings.note("32-bit float layers are saved as 16-bit PNGs"); - } + let mut w = Writer { doc, zip, xml: String::new(), next: 0, sample, warnings: Warnings::default(), dry: false }; + document_warnings(doc, &mut w.warnings); w.xml.push_str(&format!( "\n", doc.size.width, @@ -661,24 +674,48 @@ pub(crate) fn export(doc: &Document) -> Result { )); w.layers(&doc.layers, 1)?; w.xml.push_str("\n"); - let Writer { mut zip, xml, mut warnings, .. } = w; + let Writer { mut zip, xml, warnings, .. } = w; zip.add("stack.xml", xml.as_bytes())?; let merged = photocraft_compose::flatten(doc); zip.add("mergedimage.png", &png(doc.size.width, doc.size.height, &merged.px, sample)?)?; let thumb = photocraft_compose::thumbnail(doc, THUMBNAIL_SIDE); let thumb = Image::from_raw(thumb.width, thumb.height, ChannelLayout::Rgba, CSample::U8, thumb.pixels)?; zip.add("Thumbnails/thumbnail.png", &codecs::encode(&thumb, Format::Png, &codecs::EncodeOptions::default())?)?; + Ok(ExportResult { bytes: zip.finish()?, warnings: warnings.finish() }) +} + +/// What saving `doc` to OpenRaster loses that isn't in a layer. +fn document_warnings(doc: &Document, warnings: &mut Warnings) { + if doc.depth == SampleType::F32 { + warnings.note("32-bit float layers are saved as 16-bit PNGs"); + } if !doc.channels.is_empty() { warnings.note("alpha channels are not saved to OpenRaster"); } if !doc.metadata.text.is_empty() || doc.metadata.xmp.is_some() || doc.metadata.exif.is_some() { warnings.note("metadata (XMP, EXIF, text) is not saved to OpenRaster"); } - if doc.icc_profile.is_some() { + // Readers assume sRGB, so an sRGB document loses nothing by the missing tag. + let srgb = |bytes: &[u8]| photocraft_cms::Profile::parse(bytes).is_ok_and(|p| p.same_colors(photocraft_cms::Builtin::Srgb.profile())); + if doc.icc_profile.as_deref().is_some_and(|b| !srgb(b)) { warnings.note("the colour profile is not saved to OpenRaster; readers assume sRGB"); } if doc.mode == ColorMode::Grayscale { warnings.note("grayscale layers are saved as RGB"); } - Ok(ExportResult { bytes: zip.finish()?, warnings: warnings.finish() }) +} + +/// Everything saving `doc` to OpenRaster would lose (the warnings [`export`] gives), without +/// rendering or encoding anything. Empty when an OpenRaster file holds the whole document, so it +/// can be the document's own file; otherwise a save to OpenRaster is a copy (#3806). +pub fn losses(doc: &Document) -> Vec { + if !matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale) { + return vec!["OpenRaster holds RGB layers; convert the document to RGB or Grayscale first".into()]; + } + let mut w = Writer { doc, zip: ZipWriter::new(), xml: String::new(), next: 0, sample: CSample::U8, warnings: Warnings::default(), dry: true }; + document_warnings(doc, &mut w.warnings); + if let Err(e) = w.layers(&doc.layers, 1) { + w.warnings.note(&e.to_string()); + } + w.warnings.finish() } diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index 625630d08c..319414a94d 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -397,3 +397,53 @@ fn group_opacity_and_fill_are_combined_without_affecting_raster_opacity() { assert!((back.layers[0].opacity - 0.25).abs() < 1e-6); assert_eq!(back.layers[0].children().unwrap()[0].opacity, 1.0); } + +/// One document per thing OpenRaster can't hold, each also checked against the exporter. +fn lossy_documents() -> Vec<(&'static str, Document)> { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let with = |f: &dyn Fn(&mut Document)| { + let mut d = doc(SampleType::U8); + f(&mut d); + d + }; + vec![ + ("adjustment", with(&|d| d.layers.push(Layer::new("Curves", LayerContent::Adjustment(Adjustment::Invert))))), + ("fill", with(&|d| d.layers.push(Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0))))))), + ("mask", { + let mut l = block("Masked", fmt, 0, 0, 20, 20, 9); + l.mask = Some(LayerMask::reveal_all()); + with(&move |d| d.layers.push(l.clone())) + }), + ("clipped", with(&|d| d.layers[1].clipped = true)), + ("fill opacity", with(&|d| d.layers[0].fill_opacity = 0.5)), + ("float", doc(SampleType::F32)), + ("profile", with(&|d| d.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes()))), + ("metadata", with(&|d| d.metadata.xmp = Some("".into()))), + ] +} + +/// The exporter's warnings and [`losses`] are one list, so they can't drift apart. +#[test] +fn losses_are_exactly_what_the_exporter_warns_about() { + for (what, d) in lossy_documents() { + let losses = losses(&d); + assert!(!losses.is_empty(), "{what}: OpenRaster can't hold it"); + assert_eq!(losses, export(&d).unwrap().warnings, "{what}"); + } +} + +/// A document of plain pixel layers and groups loses nothing, so its OpenRaster file can stay its +/// own; an sRGB profile isn't a loss, as readers assume sRGB. +#[test] +fn plain_documents_lose_nothing() { + for depth in [SampleType::U8, SampleType::U16] { + let mut d = doc(depth); + assert_eq!(losses(&d), Vec::::new(), "{depth:?}"); + d.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); + assert_eq!(losses(&d), Vec::::new(), "{depth:?} tagged sRGB"); + assert_eq!(export(&d).unwrap().warnings, Vec::::new()); + } + let mut cmyk = doc(SampleType::U8); + cmyk.mode = ColorMode::Cmyk; + assert_eq!(losses(&cmyk).len(), 1, "a document OpenRaster can't hold at all"); +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 38db080873..4eada2422d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -244,17 +244,6 @@ impl SmartOut { } } -/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image). -fn source_geometry(name: &str, bytes: &[u8]) -> Result<((f64, f64), f64), String> { - if crate::is_psd(bytes) { - let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); - let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?; - return Ok(((w, h), 72.0)); - } - let doc = crate::import(name, bytes).map_err(|e| e.to_string())?.document; - Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi))) -} - /// The source size of a smart object whose file we can't decode: its rendered bounds taken back /// through the inverse transform (the source's top-left is the origin). fn size_from_layer(sm: &photocraft_doc::SmartObject) -> (f64, f64) { @@ -593,11 +582,14 @@ impl Ex { } LayerContent::Smart(sm) => { let parsed = sm.psd_raw.as_deref().and_then(|d| crate::smart_map::parse_sold(d)); - if sm.psd_raw.is_some() && parsed.is_none() { - // Placed-layer data we don't parse (`PlLd` only): kept verbatim. + if sm.psd_raw.is_some() && parsed.is_none() && !self.plld_edited(sm) { + // Placed-layer data that isn't `SoLd` (a CS2 `PlLd` record, or one we can't + // read) and still says what the layer is: kept verbatim. self.smart.prune_ok = false; set_principal(&mut raw, smart_keys(sm.psd_raw.as_deref().map(Vec::as_slice)), sm.psd_raw.as_ref()); } else { + // An edited CS2 `PlLd`-only layer is written like any other smart object + // (`SoLd` and a regenerated `PlLd`), with its current placement (#2465). self.smart_blocks(l, sm, parsed, &mut raw); } } @@ -608,7 +600,7 @@ impl Ex { // pixel layers, so it stays. A vector mask (`vmsk`/`vsms`, `vogk`) stays. LayerContent::Raster(_) => { let masked = l.vector_mask.is_some(); - raw.retain(|(k, _)| !RASTER_STALE_KEYS.contains(&k) && !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && !(k == b"vstk" && masked)); + raw.retain(|(k, _)| !(RASTER_STALE_KEYS.contains(&k) || adjust_map::ADJUSTMENT_KEYS.contains(&k) || k == b"CgEd" || (k == b"vstk" && masked))); } LayerContent::Group(_) => {} } @@ -767,6 +759,36 @@ impl Ex { self.warnings.extend(warnings.into_iter().map(|w| if w.starts_with("layer ") { w } else { format!("layer \"{}\": {w}", l.name) })); } + /// Whether a CS2 placed layer (`PlLd` with no `SoLd`, #2465) no longer matches its record: + /// moved, transformed, warped, filtered or given other contents. `false` for a record that + /// can't be read (or whose warp can't), or whose file isn't embedded: its contents were never + /// available, so it is kept. + fn plld_edited(&self, sm: &photocraft_doc::SmartObject) -> bool { + let Some(data) = sm.psd_raw.as_deref() else { return false }; + let Some((id, corners)) = blocks::parse_plld(data) else { return false }; + // Without its embedded file the contents were never available: keep the record as it + // is, rather than resolve the id (bytes from the file) as a path on disk. + if !self.smart.known.contains(&id) { + return false; + } + let warp = match blocks::parse_plld_warp(data) { + blocks::PlldWarp::Unreadable => return false, + blocks::PlldWarp::None => None, + blocks::PlldWarp::Warp(w) => Some(w), + }; + if sm.source != (photocraft_doc::SmartSource::Linked { path: id.clone() }) + || sm.warp != warp + || !sm.smart_filters.is_empty() + || sm.filter_mask.is_some() + { + return true; + } + // Placed as the importer places it (the same source size, from the same file, and the + // same frame: a custom warp's control-point box, #3677). + let size = crate::linked::source_size_in_blocks(self.smart.globals.iter().map(|(_, k, d)| (k, d.as_slice())), &id, &photocraft_raster::Interrupt::NONE); + blocks::plld_transform(&corners, size, warp.as_ref()) != (sm.transform, sm.perspective) + } + /// Whether an imported placed layer still says what the smart object is: transform, warp, /// filter stack and filter mask as stored. fn placed_unchanged(&self, t: &crate::smart_map::Placed, data: &[u8], sm: &photocraft_doc::SmartObject, stack: &crate::smart_map::FilterStack) -> bool { @@ -798,7 +820,7 @@ impl Ex { let geometry = stored.map(|size| (size, f64::from(self.dpi))).or_else(|| { let meta = photocraft_doc::Metadata { psd_global_blocks: self.smart.globals.clone(), ..Default::default() }; let f = crate::linked::find_linked_file(&meta, path)?; - source_geometry(&f.file_name, &f.bytes).ok() + crate::linked::source_geometry(&f.file_name, &f.bytes, &photocraft_raster::Interrupt::NONE).ok() }); let (size, dpi) = geometry.unwrap_or((size_from_layer(sm), f64::from(self.dpi))); let original = self.smart.linked_contents.entry(path.clone()).or_insert(sm.contents_id); @@ -908,7 +930,8 @@ impl Ex { (format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc)) } else { // Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer. - let (size, dpi) = source_geometry(file_name, bytes).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); + let (size, dpi) = + crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); (file_name.to_string(), bytes.to_vec(), size, dpi, None) }; let uuid = contents_uuid(contents_id, &data); @@ -1673,7 +1696,11 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if let Some(info) = &mut layer_info { // Photoshop pads the layer info to 4 bytes (an even pad misaligns readers after a global // `Lr16`/`Lr32` block, #200). A length that cannot be computed keeps the even default. - if info.pad_to(version, 4).is_err() { + // A global block's length is the exact data length and its padding to 4 follows the block: + // readers that check that length exactly (Unity's PSD Importer) reject it otherwise (#4654). + if matches!(placement, LayerInfoPlacement::GlobalBlock { .. }) { + info.padding = Some(Vec::new()); + } else if info.pad_to(version, 4).is_err() { info.padding = None; } } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index af19a0d76e..1e77cdaa6a 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -59,6 +59,8 @@ pub(crate) struct Ctx<'a> { pub filter_effects: Vec, /// Placed layers naming the same source share editable contents, not their placement. pub smart_contents: std::collections::HashMap, + /// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file). + pub plld_sizes: std::collections::HashMap>, /// Cancellation and progress for background opens (checked per layer record). pub ctl: photocraft_raster::Interrupt<'a>, /// Layer records decoded so far, out of `total` (progress). @@ -105,11 +107,51 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 (soft || pattern).then_some((fill, soft)) } +/// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record. +/// An empty `id` leaves the contents unavailable (the stored pixels stay the rendering). +#[derive(Default)] +struct Placement { + id: String, + transform: photocraft_geom::Affine, + perspective: Option<[f64; 9]>, + warp: Option, +} + impl Ctx<'_> { fn warn(&mut self, s: impl Into) { self.warnings.push(s.into()); } + /// Source id, transform, (Distort/Perspective) projective placement and warp of a placed + /// layer that has only a `PlLd` block, as Photoshop CS2 and older write it (#2465). `PlLd` + /// stores the placed corners but not the source size, which comes from the embedded file. + fn plld_placement(&mut self, data: &[u8], name: &str) -> Placement { + let Some((id, p)) = blocks::parse_plld(data) else { + self.warn(format!("layer \"{name}\": its placed-layer data (PlLd) could not be read; the smart object's contents are unavailable")); + return Placement::default(); + }; + let warp = match blocks::parse_plld_warp(data) { + blocks::PlldWarp::None => None, + blocks::PlldWarp::Warp(w) => Some(w), + blocks::PlldWarp::Unreadable => { + // Re-rendering from the source would unwarp the layer: keep it as saved (its + // warped pixels), as before PlLd was read. + self.warn(format!( + "layer \"{name}\": its smart object warp (PlLd) could not be read; the layer keeps its warped pixels but its contents are unavailable" + )); + return Placement::default(); + } + }; + let (file, ctl) = (self.file, self.ctl); + let size = *self + .plld_sizes + .entry(id.clone()) + .or_insert_with(|| crate::linked::source_size_in_blocks(file.global_blocks.iter().map(|b| (&b.key, b.data.as_slice())), &id, &ctl)); + // Without the source (missing or unreadable), it is taken as placed at 100 %. + let (transform, perspective) = blocks::plld_transform(&p, size, warp.as_ref()); + Placement { id, transform, perspective, warp } + } + fn channel_plane(&mut self, rec: &LayerRecord, id: i16, name: &str) -> Option> { rec.channel(id)?; match rec.decode_channel(id, self.file.header.depth, self.file.header.version) { @@ -267,7 +309,8 @@ impl Ctx<'_> { let fill_key = [b"SoCo", b"GdFl", b"PtFl"].into_iter().find(|k| rec.block(k).is_some()); let vector_key = [b"vsms", b"vmsk"].into_iter().find(|k| rec.block(k).is_some()); let adj_key = ADJUSTMENT_KEYS.into_iter().find(|k| rec.block(k).is_some()); - let smart_key = [b"SoLd", b"PlLd", b"SoLE"].into_iter().find(|k| rec.block(k).is_some()); + // `SoLE` (a linked smart object) before `PlLd`: a `PlLd` alone is Photoshop CS2's record. + let smart_key = [b"SoLd", b"SoLE", b"PlLd"].into_iter().find(|k| rec.block(k).is_some()); let blocks = preserved_blocks(rec); // `psd_raw` shares the Arc of the matching `psd_blocks` entry. let principal = |k: &[u8; 4]| blocks.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.clone()); @@ -321,7 +364,16 @@ impl Ctx<'_> { t.psd_raw = principal(b"TySh"); LayerContent::Text(t) } else if let Some(k) = smart_key { - let (id, transform) = rec.block(k).map(|b| blocks::parse_smart(k, &b.data)).unwrap_or_default(); + let Placement { id, transform, perspective, warp } = match rec.block(k) { + Some(b) if k == b"PlLd" => self.plld_placement(&b.data, &name), + Some(b) => { + let (id, transform) = blocks::parse_smart(k, &b.data); + // Distort / Perspective: the fourth corner (the affine `transform` drops it). + let perspective = blocks::parse_smart_perspective(k, &b.data); + Placement { id, transform, perspective, warp: blocks::parse_placed_warp(k, &b.data) } + } + None => Placement::default(), + }; let contents_id = if id.is_empty() { SmartContentsId::fresh() } else { *self.smart_contents.entry(id.clone()).or_insert_with(SmartContentsId::fresh) }; // Smart filters (`filterFX` in the placed-layer data) and their mask (`FEid`). @@ -340,10 +392,9 @@ impl Ctx<'_> { psd_raw: principal(k), filters_enabled: stack.enabled, filter_mask, - warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)), + warp, stack_mode: None, - // Distort / Perspective: the fourth corner (the affine `transform` drops it). - perspective: rec.block(k).and_then(|b| blocks::parse_smart_perspective(k, &b.data)), + perspective, }) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { // A shape whose vector mask has a density or feather is a fill layer seen through a @@ -650,6 +701,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)), filter_effects: Vec::new(), smart_contents: Default::default(), + plld_sizes: Default::default(), ctl: *ctl, done: 0, total: file.layers().len(), diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 26e62d25fd..008173839b 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -38,7 +38,7 @@ fn limits() -> Limits { fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { let img = codecs::decode_as_with(Format::Jpeg, jpeg, &codecs::DecodeOptions { keep_orientation: true, ..Default::default() })?; let own = img.meta.exif.as_deref().map_or(1, codecs::exif_orientation); - let o = if own != 1 { own } else { codecs::exif_orientation(raw) }; + let o = if own != 1 { own } else { photocraft_raw::orientation(raw).unwrap_or_else(|| codecs::exif_orientation(raw)) }; Ok(img.oriented(o)?) } diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index b6a7741d6e..04fc4bee3a 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, OnceLock}; use photocraft_color::blend::BlendMode; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::effects::GradientStyle; -use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; +use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask}; use photocraft_doc::{Document, Fill, Group, Layer, LayerContent, MAX_GROUP_DEPTH}; use photocraft_geom::{Point, Rect, Size}; use photocraft_raster::Surface; @@ -348,8 +348,11 @@ impl Conv { fn convert_node(&mut self, node: &usvg::Node, out: &mut Vec, depth: usize, rec: usize, parent: tiny_skia::Transform) { match node { usvg::Node::Group(g) => { + // A clip-path of one plain path becomes the group's vector mask (#4266); others + // are rasterised with the group. + let clip = g.clip_path().and_then(|cp| self.clip_mask(g, cp)); let dropped: [(bool, &'static str); 3] = - [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some(), "clip-path"), (g.mask().is_some(), "mask")]; + [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some() && clip.is_none(), "clip-path"), (g.mask().is_some(), "mask")]; let too_deep = self.max_group_depth.is_some_and(|limit| depth >= limit) || depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION; if dropped.iter().any(|d| d.0) || too_deep { for (yes, what) in dropped { @@ -366,7 +369,7 @@ impl Conv { } // A group that only carries a transform (baked into its paths by usvg) or that // usvg inserted for its own bookkeeping adds nothing to the layer tree. - let plain = g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; + let plain = clip.is_none() && g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, g.abs_transform()); if children.is_empty() { return; @@ -376,9 +379,14 @@ impl Conv { return; } let only_id = g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; - if only_id && children.len() == 1 { + // A lone child takes the group's name (and its clip, when it has no vector mask of + // its own). + if only_id && children.len() == 1 && (clip.is_none() || children.iter().all(|c| c.vector_mask.is_none())) { if let Some(mut child) = children.into_iter().next() { - child.name = g.id().to_string(); + if !g.id().is_empty() { + child.name = g.id().to_string(); + } + child.vector_mask = clip.or(child.vector_mask); out.push(child); } return; @@ -386,6 +394,7 @@ impl Conv { let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None })); l.opacity = g.opacity().get(); l.blend = blend(g.blend_mode()); + l.vector_mask = clip; out.push(l); } usvg::Node::Path(p) => out.extend(self.shape_layer(node, p, parent)), @@ -412,6 +421,29 @@ impl Conv { } } + /// The vector mask for group `g` clipped by `cp`, when the clip is one visible path with no + /// clip of its own: what resvg draws for the clip (the group's transform, then the clip's, + /// then the path), in canvas pixels, with the clip rule as its fill rule. + fn clip_mask(&self, g: &usvg::Group, cp: &usvg::ClipPath) -> Option { + if cp.clip_path().is_some() { + return None; + } + let [usvg::Node::Path(p)] = cp.root().children() else { return None }; + if !p.is_visible() { + return None; + } + let ts = g.abs_transform().pre_concat(cp.transform()).post_concat(self.global); + let subpaths = knots(&(*p.data()).clone().transform(ts)?); + if subpaths.is_empty() { + return None; + } + let fill_rule = match p.fill().map(|f| f.rule()) { + Some(usvg::FillRule::EvenOdd) => FillRule::EvenOdd, + _ => FillRule::NonZero, + }; + Some(VectorMask::new(Path { subpaths, fill_rule, inverted: false })) + } + /// A path as a shape layer, or a pixel layer when its paint is a pattern. fn shape_layer(&mut self, node: &usvg::Node, p: &usvg::Path, parent: tiny_skia::Transform) -> Option { if !p.is_visible() { @@ -688,6 +720,54 @@ mod tests { assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings); } + /// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable + /// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of + /// two shapes is still rasterised, with the warning. + #[test] + fn a_single_path_clip_becomes_the_groups_vector_mask() { + let svg = br##" + + + + + "##; + let r = import_svg("t", svg).unwrap(); + assert!(r.warnings.iter().all(|w| !w.contains("clip-path")), "{:?}", r.warnings); + let [group] = r.document.layers.as_slice() else { panic!("one group: {:?}", r.document.layers.len()) }; + let LayerContent::Group(g) = &group.content else { panic!("a group") }; + assert_eq!(g.children.len(), 2); + assert!(g.children.iter().all(|c| matches!(c.content, LayerContent::Shape(_))), "editable shapes"); + let mask = group.vector_mask.as_ref().expect("the clip as a vector mask"); + let xs: Vec = mask.path.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| k.anchor.x)).collect(); + let (lo, hi) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); + assert!((lo - 12.0).abs() < 1e-3 && (hi - 52.0).abs() < 1e-3, "in the group's space: {lo}..{hi}"); + // Pixel for pixel what resvg draws, but for anti-aliasing along the edges. + let ours = photocraft_compose::flatten(&r.document).to_rgba8().pixels; + let tree = parse(svg).unwrap().tree; + let mut pm = tiny_skia::Pixmap::new(64, 64).unwrap(); + resvg::render(&tree, tiny_skia::Transform::default(), &mut pm.as_mut()); + let theirs: Vec = pm + .pixels() + .iter() + .flat_map(|p| { + let c = p.demultiply(); + [c.red(), c.green(), c.blue(), c.alpha()] + }) + .collect(); + let off = ours.chunks(4).zip(theirs.chunks(4)).filter(|(a, b)| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 48)).count(); + assert!(off < 64, "{off} pixels differ"); + // The report's file: PhotoCraft's own icon opens without the clip-path warning. + let icon = import_svg("photocraft.svg", include_bytes!("../../../assets/app-icon/photocraft.svg")).unwrap(); + assert!(icon.warnings.iter().all(|w| !w.contains("clip-path")), "{:?}", icon.warnings); + assert!(icon.document.layers.iter().any(|l| l.vector_mask.is_some()), "its rounded square is a vector mask"); + // Two shapes in the clip: rasterised, and said so. + let two = br##" + + "##; + let r = import_svg("t", two).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; diff --git a/crates/io/src/text_import.rs b/crates/io/src/text_import.rs index 9159f704dd..9272f0b5b7 100644 --- a/crates/io/src/text_import.rs +++ b/crates/io/src/text_import.rs @@ -11,7 +11,7 @@ pub(crate) const MAX_RENDER_PIXELS: u64 = 64 * 1024 * 1024; /// Renders `t` from its model unless its ink would exceed [`MAX_RENDER_PIXELS`]. fn render_bounded(eng: &mut photocraft_text::TextEngine, t: &mut TextLayer, dpi: f32, format: photocraft_color::PixelFormat) { let layout = eng.layout(t, dpi); - let warp = photocraft_text::render::layout_warp(&layout, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&layout, t); let ink = photocraft_text::render::ink_rect_warped(&layout, &t.transform, warp.as_ref()); if u64::from(ink.width()).saturating_mul(u64::from(ink.height())) > MAX_RENDER_PIXELS { t.cache = Some(photocraft_raster::Surface::new(photocraft_color::PixelFormat { alpha: true, ..format })); @@ -36,10 +36,16 @@ pub(crate) fn prepare(doc: &mut Document) { let mut changed = false; for r in &mut t.runs { if let Some(ps) = r.style.postscript_name.clone() - && !eng.fonts.has_family(&r.style.font_family) + && !eng.fonts.faces(&r.style.font_family).iter().any(|f| f.postscript_name.as_deref() == Some(&ps)) { let f = eng.fonts.resolve_postscript(&ps); - if f.exact || eng.fonts.has_family(&f.family) { + // Keep installed families, including legacy names whose faces are + // indexed under a typographic family. An exact face may recover a + // truncated hyphenated family; a heuristic only fills a missing one. + let installed = eng.fonts.has_family(&r.style.font_family); + let exact_recovery = + f.exact && (!installed || f.family.strip_prefix(&r.style.font_family).is_some_and(|suffix| suffix.starts_with('-'))); + if exact_recovery || (!installed && eng.fonts.has_family(&f.family)) { r.style.font_family = f.family; r.style.weight = f.weight; r.style.italic = f.italic; diff --git a/crates/io/tests/cmyk_composite_export.rs b/crates/io/tests/cmyk_composite_export.rs new file mode 100644 index 0000000000..6364305304 --- /dev/null +++ b/crates/io/tests/cmyk_composite_export.rs @@ -0,0 +1,235 @@ +use photocraft_cms::{Builtin, Intent, Profile, Transform}; +use photocraft_codecs::{ChannelLayout, SampleType as CodecSample, decode}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::Size; +use photocraft_io::{ExportOptions, document_to_image, export}; +use photocraft_raster::Surface; + +fn document(depth: SampleType, alpha: f32) -> Document { + let mut doc = Document::new("CMYK composite", Size::new(8, 8), ColorMode::Cmyk, depth); + doc.icc_profile = Some(Builtin::CoatedCmyk.profile().to_bytes()); + let surface = Surface::with_default(doc.pixel_format(), &[0.3, 0.6, 0.1, 0.2, alpha]); + doc.layers.push(Layer::new("Ink", LayerContent::Raster(surface))); + doc.layers.push(Layer::new("Empty", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc +} + +fn tag(bytes: &[u8], id: u16) -> Option { + assert_eq!(&bytes[..2], b"II"); + let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap(); + let count = usize::from(u16::from_le_bytes(bytes[start..start + 2].try_into().unwrap())); + bytes[start + 2..] + .as_chunks::<12>() + .0 + .iter() + .take(count) + .find_map(|entry| (u16::from_le_bytes(entry[..2].try_into().unwrap()) == id).then(|| u16::from_le_bytes(entry[8..10].try_into().unwrap()))) +} + +#[test] +fn tiff_keeps_cmyk_profile_depth_and_straight_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + for alpha in [1.0, 0.5] { + let doc = document(depth, alpha); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), if alpha == 1.0 { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(if alpha == 1.0 { 4 } else { 5 })); + assert_eq!(tag(&result.bytes, 338), if alpha == 1.0 { None } else { Some(2) }); + if alpha < 1.0 { + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - alpha).abs() < 0.004)); + let opaque = document_to_image(&document(depth, 1.0), &mut Vec::new()).unwrap().to_normalized(); + for (px, color) in image.to_normalized().as_chunks::<5>().0.iter().zip(opaque.as_chunks::<4>().0) { + assert!(px[..4].iter().zip(color).all(|(a, b)| (a - b).abs() < 0.004)); + } + } + } + } +} + +#[test] +fn tiff_keeps_true_zero_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + let doc = document(depth, 0.0); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(5)); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| px[4].abs() < f32::EPSILON)); + } +} + +#[test] +fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = document(depth, 0.5); + + let result = export(&doc, "psd", &ExportOptions::default()).unwrap(); + + let back = photocraft_io::import("composite.psd", &result.bytes).unwrap().document; + assert_eq!(back.mode, ColorMode::Cmyk); + assert_eq!(back.depth, depth); + assert_eq!(back.icc_profile, doc.icc_profile); + assert_eq!(back.layer_count(), doc.layer_count()); + } +} + +#[test] +fn jpeg_keeps_four_components_profile_and_zero_ink_matte_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [1.0, 0.5, 0.0] { + let doc = document(depth, alpha); + let mut opts = ExportOptions::default(); + opts.encode.jpeg_quality = 100; + + let result = export(&doc, "jpg", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + let adobe = result.bytes.windows(5).position(|bytes| bytes == b"Adobe").unwrap(); + assert_eq!(result.bytes[adobe + 11], 0); + let mut marker = 2; + while result.bytes[marker + 1] != 192 { + let length = usize::from(u16::from_be_bytes(result.bytes[marker + 2..marker + 4].try_into().unwrap())); + marker += length + 2; + } + assert_eq!(result.bytes[marker + 9], 4); + let expected = document_to_image(&doc, &mut Vec::new()).unwrap(); + for (px, source) in image.to_normalized().as_chunks::<4>().0.iter().zip(expected.to_normalized().chunks_exact(expected.layout().channels())) { + for channel in 0..4 { + let ink = source[channel] * if expected.layout().has_alpha() { source[4] } else { 1.0 }; + assert!((px[channel] - ink).abs() < 0.015, "{px:?} vs {source:?}"); + } + } + if depth != SampleType::U8 { + assert!(result.warnings.iter().any(|warning| warning.contains("bit")), "{:?}", result.warnings); + } + } + } +} + +#[test] +fn composite_uses_distinctive_document_profile_on_every_worker() { + let params = photocraft_cms::synth::CmykParams { + description: "Export synthetic uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }; + let profile = photocraft_cms::synth::cmyk_profile(¶ms); + let mut doc = document(SampleType::F32, 0.5); + doc.size = Size::new(64, 64); + doc.icc_profile = Some(profile.to_bytes()); + let rgb = photocraft_compose::flatten(&doc); + let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true).unwrap(); + let fallback = Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).unwrap(); + let mut expected = [0.0; 4]; + transform.eval_fast(&rgb.px[0][..3], &mut expected); + let mut wrong = [0.0; 4]; + fallback.eval_fast(&rgb.px[0][..3], &mut wrong); + assert!(expected.iter().zip(wrong).any(|(a, b)| (a - b).abs() > 0.02)); + assert!(photocraft_io::pixels::bands(rgb.px.len()).len() > 1); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::CmykA); + for px in image.to_normalized().as_chunks::<5>().0 { + assert!(px[..4].iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.00001), "{px:?} vs {expected:?}"); + } + for extension in ["tiff", "jpg"] { + let encoded = export(&doc, extension, &ExportOptions::default()).unwrap(); + assert_eq!(decode(&encoded.bytes).unwrap().icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + } +} + +#[test] +fn cmyk_composite_rejects_invalid_non_cmyk_and_unusable_profiles() { + let without_tags = |prefix: &[u8]| { + let mut bytes = Builtin::CoatedCmyk.profile().to_bytes().to_vec(); + let count = usize::try_from(u32::from_be_bytes(bytes[128..132].try_into().unwrap())).unwrap(); + for entry in bytes[132..].as_chunks_mut::<12>().0.iter_mut().take(count) { + if &entry[..3] == prefix { + entry[..4].copy_from_slice(b"test"); + } + } + std::sync::Arc::new(bytes) + }; + for bytes in [std::sync::Arc::new(vec![1, 2, 3]), Builtin::Srgb.profile().to_bytes(), without_tags(b"B2A"), without_tags(b"A2B")] { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = Some(bytes); + + for extension in ["tiff", "jpg"] { + assert!(export(&doc, extension, &ExportOptions::default()).is_err(), "accepted invalid profile for {extension}"); + } + // RGB-only targets never write CMYK, so they still export (through the built-in profile). + assert!(export(&doc, "png", &ExportOptions::default()).is_ok(), "rejected invalid profile for png"); + } +} + +#[test] +fn untagged_composite_embeds_coated_cmyk() { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = None; + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.unwrap(), Builtin::CoatedCmyk.profile().to_bytes().as_slice()); +} + +#[test] +fn native_samples_are_preserved_without_composite_roundtrip() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = document(depth, 0.5); + doc.layers.pop(); + let LayerContent::Raster(surface) = &doc.layers[0].content else { panic!() }; + let expected = surface.to_interleaved(doc.bounds()); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.data(), expected); + } +} + +#[test] +fn rgb_only_export_uses_direct_composite_not_reseparated_cmyk() { + let doc = document(SampleType::U16, 0.5); + let rgb = photocraft_compose::flatten(&doc); + let expected = + photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.px.into_iter().flatten().collect::>()).unwrap(); + + let result = export(&doc, "png", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.data(), expected.data()); + assert_eq!(Profile::parse(image.icc.as_ref().unwrap()).unwrap().color_space, photocraft_cms::ColorSpace::Rgb); +} + +#[test] +fn layered_tiff_keeps_its_existing_cmyk_psd_composite_path() { + let doc = document(SampleType::U16, 0.5); + let opts = ExportOptions { tiff_layers: true, ..Default::default() }; + + let result = export(&doc, "tiff", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert!(image.meta.photoshop_layers.is_some()); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - 0.5).abs() < 0.0001)); +} diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/common/mod.rs index f175badb35..28c1a01906 100644 --- a/crates/io/tests/common/mod.rs +++ b/crates/io/tests/common/mod.rs @@ -364,7 +364,12 @@ pub fn strict_block_errors(bytes: &[u8]) -> Vec { if let Some(li) = &file.layer_info { let body = li.unpadded_len(v).unwrap_or(1); let pad = li.padding.as_ref().map_or(body % 2, |p| p.len() as u64); - if (body + pad) % 4 != 0 { + // In a global `Lr16`/`Lr32` block the padding to 4 follows the block, outside its length. + let after = match &file.layer_info_placement { + photocraft_psd::LayerInfoPlacement::GlobalBlock { padding, .. } => padding.as_ref().map_or((4 - (body + pad) % 4) % 4, |p| p.len() as u64), + photocraft_psd::LayerInfoPlacement::Section => 0, + }; + if (body + pad + after) % 4 != 0 { errs.push(format!("layer info: {body} bytes + {pad} padding is not a multiple of 4")); } } diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index d45956dba8..776f27f89d 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 133, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 145, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index 48d6944c72..35a5e5ad46 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -53,6 +53,9 @@ codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0); codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0); codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); +codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); +codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); +codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; @@ -111,6 +114,25 @@ fn fits_header_cards_round_trip_as_text() { } } +/// DPX header fields travel as the document's text metadata and come back on a DPX export at the +/// chosen bit depth; the transfer characteristic is reported when the frame is opened (#1049). +#[test] +fn dpx_header_fields_and_encoding_notes_round_trip_through_the_document() { + let mut d = single(ColorMode::Rgb, SampleType::U16, false); + d.metadata.text = vec![("DPX:Project".into(), "Reel 7".into()), ("DPX:Transfer".into(), "3".into()), ("DPX:TimeCode".into(), "01:00:00:12".into())]; + let mut opts = ExportOptions::default(); + opts.encode.dpx_bits = Some(DpxBits::Bits10); + let r = export(&d, "a.dpx", &opts).unwrap(); + assert!(r.bytes.starts_with(b"SDPX")); + assert_eq!(r.bytes[803], 10, "bits per sample"); + let back = import("a.dpx", &r.bytes).unwrap(); + for kv in &d.metadata.text { + assert!(back.document.metadata.text.contains(kv), "{kv:?}: {:?}", back.document.metadata.text); + } + assert!(back.warnings.iter().any(|w| w.contains("transfer characteristic: logarithmic (3)")), "{:?}", back.warnings); + assert_eq!(back.document.depth, SampleType::U16); +} + /// EXR stores linear light: a linear document round-trips exactly; an sRGB (untagged) one is /// linearised on export and comes back tagged linear sRGB with the same colours. #[test] diff --git a/crates/io/tests/krita_corpus.rs b/crates/io/tests/krita_corpus.rs index cafbe2c718..b0b3a01d63 100644 --- a/crates/io/tests/krita_corpus.rs +++ b/crates/io/tests/krita_corpus.rs @@ -1,4 +1,4 @@ -//! Krita-authored oracles: OpenRaster (`.ora`) files whose archives carry +//! Krita-authored oracles: OpenRaster (`.ora`) and Krita (`.kra`) files whose archives carry //! Krita's own rendering of the layer stack (`mergedimage.png`). The files are generated by //! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus and are //! not bundled. Run with @@ -47,6 +47,10 @@ const KNOWN: &[(&str, f32, &str)] = &[ ("blend-lighter_color.ora", 0.02, "Krita weighs the channels differently when it picks the lighter colour; near-ties pick the other pixel"), ("blend-hard_mix_photoshop.ora", 0.02, "a channel sum of exactly 1 goes up in Photoshop's Hard Mix (ours) and down in Krita's"), ("depth-rgba-u16.ora", 1.0, "Krita blends this linear-light 16-bit document in linear light, but its ORA export stores sRGB 8-bit layers"), + ("blend-darker_color.kra", 0.02, "as for the .ora"), + ("blend-lighter_color.kra", 0.02, "as for the .ora"), + ("blend-hard_mix_photoshop.kra", 0.02, "as for the .ora"), + ("nodes-vector-unsupported.kra", 0.2, "vector layers are left out of .kra imports (reported); Krita's merged image has the shape"), ]; fn run(ext: &str, tolerance: f32) { @@ -85,3 +89,9 @@ fn run(ext: &str, tolerance: f32) { fn krita_ora_files_match_their_merged_image() { run("ora", 3.0 / 255.0); } + +#[test] +#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"] +fn krita_kra_files_match_their_merged_image() { + run("kra", 3.0 / 255.0); +} diff --git a/crates/io/tests/psd_font_identity.rs b/crates/io/tests/psd_font_identity.rs new file mode 100644 index 0000000000..39a51c147d --- /dev/null +++ b/crates/io/tests/psd_font_identity.rs @@ -0,0 +1,126 @@ +//! #3833: hyphens inside an installed family must survive PSD save/reopen and editing. +//! The fixtures rename the OFL bundled fonts in memory; no proprietary fonts are needed. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{ + Document, Layer, LayerContent, TextLayer, + text::{CharStyle, TextRun}, +}; +use photocraft_geom::Size; +use photocraft_text::fonts::{INTER_REGULAR, JETBRAINS_MONO_REGULAR}; + +fn renamed_font(source: &[u8], family: &str, ps: &str) -> Vec { + font_with_names(source, &[(1, family), (2, "Regular"), (6, ps), (16, family), (17, "Regular")]) +} + +fn font_with_names(source: &[u8], names: &[(u16, &str)]) -> Vec { + let mut bytes = source.to_vec(); + let count = u16::from_be_bytes(bytes[4..6].try_into().unwrap()) as usize; + let record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..*r + 4] == b"name").unwrap(); + let mut table = Vec::new(); + let mut strings = Vec::new(); + for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] { + table.extend(value.to_be_bytes()); + } + for &(id, value) in names { + let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] { + table.extend(n.to_be_bytes()); + } + strings.extend(encoded); + } + table.extend(strings); + let offset = bytes.len() as u32; + bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes()); + bytes[record + 12..record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes()); + bytes.extend(table); + bytes +} + +fn roundtrip(family: &str, ps: Option<&str>, install_prefix: bool) { + let actual_ps = ps.map(str::to_string).unwrap_or_else(|| format!("{family}-Book").replace(' ', "")); + { + let mut engine = photocraft_text::shared().lock().unwrap(); + engine.fonts.register_font_data(renamed_font(&JETBRAINS_MONO_REGULAR, family, &actual_ps)); + if install_prefix { + let prefix = family.split_once('-').unwrap().0; + engine.fonts.register_font_data(renamed_font(&INTER_REGULAR, prefix, &format!("{prefix}-Regular"))); + } + } + roundtrip_registered_fonts(family, ps); +} + +fn roundtrip_registered_fonts(family: &str, ps: Option<&str>) { + let mut doc = Document::new("fonts", Size::new(256, 64), ColorMode::Rgb, SampleType::U8); + let mut original = TextLayer { + text: "Wide WWW iii".into(), + runs: vec![TextRun { + len: 12, + style: CharStyle { font_family: family.into(), postscript_name: ps.map(str::to_string), size_pt: 24.0, ..Default::default() }, + }], + ..Default::default() + }; + original.sync_summary(); + photocraft_text::shared().lock().unwrap().render_layer(&mut original, doc.resolution_dpi, doc.pixel_format()); + doc.layers.push(Layer::new("type", LayerContent::Text(original.clone()))); + let bytes = photocraft_io::export(&doc, "fonts.psd", &Default::default()).unwrap().bytes; + let mut reopened = photocraft_io::import("fonts.psd", &bytes).unwrap().document; + let LayerContent::Text(text) = &mut reopened.layers[0].content else { panic!("expected editable text") }; + assert!(text.cache.is_some(), "opening retains the original appearance"); + let before = original.cache.as_ref().unwrap(); + let opened = text.cache.as_ref().unwrap(); + assert_eq!(opened.read_region(before.content_bounds()), before.read_region(before.content_bounds()), "PSD pixels retain the original font before editing"); + assert_eq!(text.font_family, family, "the Character panel must show the installed family"); + assert_eq!(text.char_runs()[0].style.font_family, family); + assert_eq!(text.char_runs()[0].style.weight, original.char_runs()[0].style.weight); + assert_eq!(text.char_runs()[0].style.italic, original.char_runs()[0].style.italic); + if let Some(ps) = ps { + assert_eq!(text.char_runs()[0].style.postscript_name.as_deref(), Some(ps)); + } + // Editing drops the PSD pixels and regenerates them from the reopened model. + text.text.push('!'); + text.runs[0].len += 1; + text.cache = None; + text.psd_raw = None; + original.text.push('!'); + original.runs[0].len += 1; + let mut engine = photocraft_text::shared().lock().unwrap(); + let expected = engine.layout(&original, doc.resolution_dpi); + let actual = engine.layout(text, doc.resolution_dpi); + assert!((expected.lines[0].x1 - actual.lines[0].x1).abs() < 0.01, "editing must keep the same font outlines"); + engine.render_layer(text, doc.resolution_dpi, doc.pixel_format()); + engine.render_layer(&mut original, doc.resolution_dpi, doc.pixel_format()); + let edited = text.cache.as_ref().unwrap(); + let expected_pixels = original.cache.as_ref().unwrap(); + assert_eq!(edited.content_bounds(), expected_pixels.content_bounds()); + assert_eq!(edited.read_region(expected_pixels.content_bounds()), expected_pixels.read_region(expected_pixels.content_bounds())); +} + +#[test] +fn family_only_hyphenated_font_survives_psd_reopen_and_edit() { + roundtrip("FOT-NewRodin Pro", None, false); +} + +#[test] +fn exact_postscript_identity_wins_over_installed_prefix_family() { + roundtrip("Prefix-Hyphenated Pro", Some("Prefix-Book"), true); +} + +#[test] +fn installed_family_survives_a_face_indexed_under_its_typographic_family() { + const FAMILY: &str = "Indexed Alias"; + const PS: &str = "IndexedAlias-Regular"; + { + let mut engine = photocraft_text::shared().lock().unwrap(); + // The requested family exists, but its exact PostScript face is listed under the + // typographic family, as with system fonts that separate legacy and typographic names. + engine.fonts.register_font_data(renamed_font(&INTER_REGULAR, FAMILY, "IndexedAlias-Other")); + engine.fonts.register_font_data(font_with_names(&JETBRAINS_MONO_REGULAR, &[(1, FAMILY), (2, "Regular"), (6, PS), (16, "Indexed"), (17, "Regular")])); + assert!(engine.fonts.has_family(FAMILY)); + assert!(engine.fonts.faces(FAMILY).iter().all(|face| face.postscript_name.as_deref() != Some(PS))); + let exact = engine.fonts.resolve_postscript(PS); + assert!(exact.exact); + assert_eq!(exact.family, "Indexed"); + } + roundtrip_registered_fonts(FAMILY, Some(PS)); +} diff --git a/crates/io/tests/psd_layer_block_length.rs b/crates/io/tests/psd_layer_block_length.rs new file mode 100644 index 0000000000..acb4a6b15c --- /dev/null +++ b/crates/io/tests/psd_layer_block_length.rs @@ -0,0 +1,61 @@ +//! The global `Lr16` / `Lr32` block of a 16/32-bit export declares the exact length of the layer +//! data, and its padding to 4 bytes follows outside that length (#4654). +//! +//! Photoshop writes these blocks this way. Readers that check the block length exactly (Unity's +//! PSD Importer, "Incorrect length for InfoLayers") reject a block whose length counts the padding. + +mod common; + +use common::Features; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_io::*; +use photocraft_psd::PsdFile; + +fn be(b: &[u8], at: usize, long: bool) -> usize { + if long { u64::from_be_bytes(b[at..at + 8].try_into().unwrap()) as usize } else { u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as usize } +} + +/// Walks the raw file to the first global block and returns (key, declared length, the bytes +/// after the data up to the next block or the end of the section). +fn first_global_block(bytes: &[u8]) -> ([u8; 4], usize, Vec) { + let psb = be(bytes, 4, false) >> 16 == 2; + let mut p = 26; + p += 4 + be(bytes, p, false); // color mode data + p += 4 + be(bytes, p, false); // image resources + let lw = if psb { 8 } else { 4 }; + let end = p + lw + be(bytes, p, psb); + p += lw; + p += lw + be(bytes, p, psb); // classic layer info + p += 4 + be(bytes, p, false); // global layer mask + let key: [u8; 4] = bytes[p + 4..p + 8].try_into().unwrap(); + let len = be(bytes, p + 8, psb); + let data_end = p + 8 + if psb { 8 } else { 4 } + len; + let next = (data_end..end).find(|&q| bytes.get(q..q + 4).is_some_and(|s| s == b"8BIM" || s == b"8B64")).unwrap_or(end); + (key, len, bytes[data_end..next].to_vec()) +} + +#[test] +fn lr16_lr32_length_excludes_padding() { + let mut unaligned = 0; + for (depth, key) in [(SampleType::U16, *b"Lr16"), (SampleType::F32, *b"Lr32")] { + for mode in [ColorMode::Rgb, ColorMode::Grayscale] { + for features in [Features::PIXELS, Features::ALL] { + let doc = common::gen_doc(mode, depth, features); + for path in ["x.psd", "x.psb"] { + let name = format!("{depth:?} {mode:?} {path}"); + let bytes = export(&doc, path, &ExportOptions::default()).expect("export").bytes; + let file = PsdFile::from_bytes(&bytes).expect("reparse"); + let body = file.layer_info.as_ref().expect("layers").unpadded_len(file.header.version).unwrap() as usize; + let (k, len, after) = first_global_block(&bytes); + assert_eq!(k, key, "{name}: first global block"); + assert_eq!(len, body, "{name}: declared length vs layer data"); + assert_eq!(after, vec![0; (4 - body % 4) % 4], "{name}: padding after the block"); + unaligned += usize::from(!body.is_multiple_of(4)); + } + } + } + } + // Only a body that is not already a multiple of 4 tells padding inside the length apart from + // padding after it. + assert!(unaligned > 0, "no case exercises the padding"); +} diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/psd_type_bounds.rs index 95e7ddfa13..8fabe8be24 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/psd_type_bounds.rs @@ -261,7 +261,7 @@ fn imported_type_layers_keep_their_full_bounds() { // Model: size in points at the document resolution, transform and shape as written. assert!((t.runs[0].style.size_pt - c.size_pt).abs() < 0.01, "{}: size {} pt, want {}", c.name, t.runs[0].style.size_pt, c.size_pt); assert_eq!(t.transform, c.transform, "{}", c.name); - match (c.box_bounds, t.shape) { + match (c.box_bounds, t.shape.clone()) { (None, TextShape::Point) => {} (Some(b), TextShape::Box { x, y, width, height }) => { assert_eq!([x, y, x + width, y + height].map(f64::from), b, "{}", c.name) diff --git a/crates/io/tests/psd_vertical_type.rs b/crates/io/tests/psd_vertical_type.rs index e69f725da7..93cd1a8858 100644 --- a/crates/io/tests/psd_vertical_type.rs +++ b/crates/io/tests/psd_vertical_type.rs @@ -34,7 +34,7 @@ fn text(doc: &Document) -> &TextLayer { #[test] fn vertical_orientation_round_trips_through_psd() { for shape in [TextShape::Point, TextShape::Box { x: -200.0, y: 0.0, width: 220.0, height: 300.0 }] { - let doc = doc_with(shape); + let doc = doc_with(shape.clone()); let ink = text(&doc).cache.as_ref().unwrap().content_bounds(); assert!(ink.height() > ink.width(), "{shape:?}: {ink:?}"); let out = photocraft_io::export(&doc, "v.psd", &Default::default()).unwrap(); diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index 7a11f4fb03..d9e31a87c1 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -94,6 +94,18 @@ fn unsupported_raw_without_preview_is_a_clear_error() { } } +#[test] +fn cr3_opens_its_upright_camera_jpeg_with_explicit_preview_warning() { + let img = Image::from_u8(32, 20, ChannelLayout::Rgb, vec![180; 32 * 20 * 3]).unwrap(); + let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let raw = photocraft_raw::testgen::cr3_preview(&jpeg, 6); + let r = import("shot.cr3", &raw).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (20, 32)); + assert_eq!(r.document.depth, SampleType::U8); + assert!(r.warnings[0].contains("8-bit, not the raw sensor data")); + assert!(r.warnings[0].contains("CR3")); +} + #[test] fn truncated_raws_do_not_panic() { let b = nef_with_preview(); diff --git a/crates/io/tests/smart_contents_identity.rs b/crates/io/tests/smart_contents_identity.rs index 632547b0d4..5a0714b3d8 100644 --- a/crates/io/tests/smart_contents_identity.rs +++ b/crates/io/tests/smart_contents_identity.rs @@ -199,3 +199,153 @@ fn independent_unavailable_link_retains_separate_preserved_record() { let (again, _) = round_trip(&doc, "psd"); assert_eq!(after.to_bytes().unwrap(), again.to_bytes().unwrap()); } + +/// A `PlLd`-only placed layer (Photoshop CS2, #2465) whose embedded file is missing still gets its +/// source id and placement (at an assumed 100 % scale); an unreadable `PlLd` is reported. +#[test] +fn plld_only_layers_without_their_file_or_with_a_broken_record() { + let (mut file, _) = round_trip(&fixture(), "psd"); + file.global_blocks.retain(|b| !matches!(&b.key, b"lnk2" | b"lnk3" | b"lnkD")); + let rotated = Affine { m: [0.0, 1.0, -1.0, 0.0, 6.0, 1.0] }; + let spec = + smart_map::PlacedSpec { idnt: "missing", placed: "", transform: rotated, perspective: None, size: (3.0, 2.0), dpi: 72.0, warp: None, filter_fx: None }; + let mut record = file.layers()[0].clone(); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let mut broken = record.clone(); + record.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec, &mut Vec::new()))); + broken.blocks.push(TaggedBlock::new(*b"PlLd", b"plcL\0\0\0\x03\x05ab\0\0".to_vec())); + *file.layers_mut() = vec![record, broken]; + let (doc, warnings) = photocraft_io::psd_to_document(&file); + let LayerContent::Smart(a) = &doc.layers[0].content else { panic!("PlLd layer is not a smart object") }; + assert_eq!(a.source, SmartSource::Linked { path: "missing".into() }); + for (x, y) in a.transform.m.iter().zip(rotated.m) { + assert!((x - y).abs() < 1e-12, "{:?} vs {rotated:?}", a.transform); + } + let LayerContent::Smart(b) = &doc.layers[1].content else { panic!("broken PlLd layer is not a smart object") }; + assert_eq!(b.source, SmartSource::Linked { path: String::new() }); + assert_eq!(warnings.iter().filter(|w| w.contains("(PlLd) could not be read")).count(), 1, "{warnings:?}"); +} + +/// Photoshop CS2 can warp a smart object and keeps the warp in `PlLd`, after the corners. A +/// `PlLd`-only layer imports with that warp, so re-rendering it from its now-available source +/// (Free Transform, Image Size, mode changes) keeps it warped. A warp that can't be read leaves +/// the contents unavailable, so the warped pixels stay the rendering (#2465). +#[test] +fn plld_only_layers_keep_their_warp() { + use photocraft_geom::warp::{Warp, WarpStyle}; + let (mut file, _) = round_trip(&fixture(), "psd"); + let bulge = Warp::preset(WarpStyle::Bulge, 35.0, [0.0, 0.0, 2.0, 2.0]); + let spec = |warp| smart_map::PlacedSpec { + idnt: "missing", + placed: "", + transform: Affine { m: [2.0, 0.0, 0.0, 2.0, 1.0, 0.0] }, + perspective: None, + size: (2.0, 2.0), + dpi: 72.0, + warp, + filter_fx: None, + }; + let mut record = file.layers()[0].clone(); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let (mut warped, mut flat, mut broken) = (record.clone(), record.clone(), record); + warped.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec(Some(&bulge)), &mut Vec::new()))); + flat.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec(None), &mut Vec::new()))); + // The warp descriptor cut short: a warp is stored but can't be read. + let mut cut = smart_map::plld_bytes(&spec(Some(&bulge)), &mut Vec::new()); + cut.truncate(cut.len() - 40); + broken.blocks.push(TaggedBlock::new(*b"PlLd", cut)); + *file.layers_mut() = vec![warped, flat, broken]; + let (doc, warnings) = photocraft_io::psd_to_document(&file); + let sm = |i: usize| match &doc.layers[i].content { + LayerContent::Smart(s) => s, + _ => panic!("layer {i} is not a smart object"), + }; + assert_eq!(sm(0).warp, Some(bulge)); + assert_eq!(sm(0).source, SmartSource::Linked { path: "missing".into() }); + assert_eq!((sm(1).warp.as_ref(), &sm(1).source), (None, &SmartSource::Linked { path: "missing".into() })); + assert_eq!(sm(2).source, SmartSource::Linked { path: String::new() }, "unreadable warp: contents unavailable"); + assert!(sm(2).cache.is_some()); + assert_eq!(warnings.iter().filter(|w| w.contains("warp (PlLd) could not be read")).count(), 1, "{warnings:?}"); +} + +/// A linked smart object with both `SoLE` and `PlLd` is read from `SoLE`: `PlLd` alone is +/// Photoshop CS2's record (#2465). +#[test] +fn sole_wins_over_plld() { + let (mut file, _) = round_trip(&fixture(), "psd"); + let spec = smart_map::PlacedSpec { + idnt: "plld-only", + placed: "", + transform: Affine::IDENTITY, + perspective: None, + size: (2.0, 2.0), + dpi: 72.0, + warp: None, + filter_fx: None, + }; + let mut record = file.layers()[0].clone(); + let sold = record.block(b"SoLd").expect("the fixture has SoLd").data.clone(); + let (id, _) = photocraft_io::blocks::parse_smart(b"SoLd", &sold); + assert!(!id.is_empty() && id != "plld-only"); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + record.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec, &mut Vec::new()))); + record.blocks.push(TaggedBlock::new(*b"SoLE", sold.clone())); + *file.layers_mut() = vec![record]; + let (doc, _) = photocraft_io::psd_to_document(&file); + let LayerContent::Smart(sm) = &doc.layers[0].content else { panic!("not a smart object") }; + assert_eq!(sm.source, SmartSource::Linked { path: id }); + assert_eq!(sm.psd_raw.as_deref(), Some(&sold)); +} + +/// A CS2 `PlLd`-only layer with a custom warp whose control points spill past its source frames +/// that control-point box with its corners, as `SoLd`'s `Trnf` does (#3677): it imports with the +/// same placement as the equivalent `SoLd` layer. Unedited, it saves its `PlLd` record verbatim +/// (no `SoLd` added) and reopens in the same place (#2465). +#[test] +fn plld_only_spilled_custom_warp_places_like_sold_and_round_trips_verbatim() { + use photocraft_geom::warp::{BezierMesh, Warp}; + let (mut file, _) = round_trip(&fixture(), "psd"); + let mut record = file.layers()[0].clone(); + let (id, _) = photocraft_io::blocks::parse_smart(b"SoLd", &record.block(b"SoLd").expect("the fixture has SoLd").data); + assert!(!id.is_empty()); + // The embedded source is 2 × 2; the mesh reaches from (-1, -0.5) to (3, 2.5). + let bounds = [0.0, 0.0, 2.0, 2.0]; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[0] = [-1.0, -0.5]; + mesh.points[15] = [3.0, 2.5]; + let warp = Warp::custom(mesh, bounds); + let t = Affine { m: [1.5, 0.0, 0.0, 1.5, 4.0, 1.0] }; + let spec = + smart_map::PlacedSpec { idnt: &id, placed: "p", transform: t, perspective: None, size: (2.0, 2.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let (mut with_sold, mut cs2) = (record.clone(), record); + with_sold.blocks.push(TaggedBlock::new(*b"SoLd", smart_map::sold_bytes(None, &spec, &mut Vec::new()))); + let plld = smart_map::plld_bytes(&spec, &mut Vec::new()); + cs2.blocks.push(TaggedBlock::new(*b"PlLd", plld.clone())); + *file.layers_mut() = vec![with_sold, cs2]; + + let (doc, warnings) = photocraft_io::psd_to_document(&file); + assert!(!warnings.iter().any(|w| w.contains("PlLd")), "{warnings:?}"); + let sm = |d: &Document, i: usize| match &d.layers[i].content { + LayerContent::Smart(s) => s.clone(), + _ => panic!("layer {i} is not a smart object"), + }; + let close = |a: Affine, b: Affine| a.m.iter().zip(b.m).all(|(x, y)| (x - y).abs() < 1e-9); + let (a, b) = (sm(&doc, 0), sm(&doc, 1)); + assert!(close(a.transform, t), "SoLd: {:?} vs {t:?}", a.transform); + assert!(close(b.transform, a.transform), "PlLd {:?} vs SoLd {:?}", b.transform, a.transform); + assert_eq!((&b.source, &b.warp, b.perspective), (&a.source, &a.warp, a.perspective)); + assert_eq!(b.warp.as_ref(), Some(&warp)); + assert_eq!(b.source, SmartSource::Linked { path: id.clone() }); + + // Unedited: the CS2 record is written back as it was, with no SoLd, and reopens in place. + let out = export(&doc, "psd", &ExportOptions::default()).unwrap(); + let saved = PsdFile::from_bytes(&out.bytes).unwrap(); + let rec = &saved.layers()[1]; + assert_eq!(rec.block(b"PlLd").map(|b| &b.data), Some(&plld)); + assert!(rec.block(b"SoLd").is_none(), "an unedited PlLd-only layer gained a SoLd"); + let again = import("psd", &out.bytes).unwrap().document; + let c = sm(&again, 1); + assert!(close(c.transform, t), "{:?} vs {t:?}", c.transform); + assert_eq!(c.warp.as_ref(), Some(&warp)); +} diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index adbdf08a03..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -148,10 +148,11 @@ fn gradients_are_approximated_with_a_warning() { #[test] fn clip_paths_masks_filters_patterns_and_images_are_rasterised() { + // A clip of one path becomes a vector mask (#4266); one of two shapes is still rasterised. let r = open(&format!( "\ \ - \ + \ \ \ \ diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 14b3df3964..ec0ce0907b 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -71,36 +71,41 @@ pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEra let mut stroke = stroke.clone(); stroke.brush.opacity = 1.0; stroke.brush.erase = false; + // The pixels before the stroke, taken at the first dab. Sampling and matching read these, so a + // dab landing on pixels earlier dabs already erased still samples and matches (#1280). + let mut orig: Option = None; apply_dab_stroke(target, &stroke, selection, false, 0, |work: &mut Region, fp| { let r = fp.rect.intersect(&work.rect); if r.is_empty() { return; } + let orig = &*orig.get_or_insert_with(|| work.clone()); let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); - // The hotspot's colour (None where it is already transparent or off the working area). - let hotspot = |work: &Region| -> Option> { - if !work.rect.contains(cx, cy) { + // The hotspot's colour (None where it was transparent before the stroke or is off the + // working area). + let hotspot = || -> Option> { + if !orig.rect.contains(cx, cy) { return None; } - let p = work.px(cx, cy); + let p = orig.px(cx, cy); (p[a] > 0.0).then(|| p[..nc].to_vec()) }; let sampled = match bs.sampling { - Sampling::Continuous => hotspot(work), - Sampling::Once => once.get_or_insert_with(|| hotspot(work)).clone(), + Sampling::Continuous => hotspot(), + Sampling::Once => once.get_or_insert_with(hotspot).clone(), Sampling::BackgroundSwatch => Some(swatch.clone()), }; let Some(sampled) = sampled else { return }; let (w, h) = (r.width() as usize, r.height() as usize); - // Per-pixel match weight. Erasing only lowers alpha, so the working copy still holds the - // pre-stroke colours. + // Per-pixel match weight from the pre-stroke colours: the contiguous fill passes through + // pixels this stroke already erased. let mut weight = vec![0.0f32; w * h]; for y in r.y0..r.y1 { for x in r.x0..r.x1 { if fp.at(x, y) <= 0.0 { continue; } - let p = work.px(x, y); + let p = orig.px(x, y); if p[a] <= 0.0 { continue; } @@ -246,6 +251,27 @@ mod tests { assert_eq!(t, s); } + #[test] + fn one_pass_clears_the_whole_stroke_when_dabs_overlap_erased_pixels() { + // #1280: later dabs land their hotspot on pixels earlier dabs already erased. + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 60, 30), &from_rgba(&fmt, GREEN)); + let mut st = stroke(&[(10.0, 15.0), (50.0, 15.0)]); + st.brush.size = 20.0; + for sampling in [Sampling::Continuous, Sampling::Once] { + let mut t = s.clone(); + let bs = BgEraseSettings { sampling, limits: Limits::Contiguous, ..Default::default() }; + apply_background_eraser(&mut t, &st, &bs, None); + // Everything within radius − 1 of the line. + for y in 6..24 { + for x in 10..51 { + assert_eq!(alpha(&t, x, y), 0.0, "{sampling:?} ({x}, {y}) left behind"); + } + } + } + } + #[test] fn selection_limits_and_degenerate_inputs() { let fmt = PixelFormat::RGBA8; diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 51d8026f31..0dd4d21d65 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -486,6 +486,11 @@ pub struct BrushSettings { pub pressure_opacity: bool, /// Straight RGBA colour (display RGB). Commands fill it from the foreground colour. pub color: [f32; 4], + /// The inks `color` was picked as (its sRGB colour, the CMYK values), for a CMYK target: the + /// stroke paints exactly these, since an sRGB round trip does not give them back (#4344). + /// Only valid while `color` is that sRGB colour; ignored with Color Dynamics. + #[serde(skip)] + pub color_cmyk: Option<([f32; 3], [f32; 4])>, /// Background colour for Color Dynamics' foreground/background jitter. pub background: [f32; 4], /// Erase instead of paint. @@ -545,6 +550,7 @@ impl Default for BrushSettings { pressure_size: true, pressure_opacity: false, color: [0.0, 0.0, 0.0, 1.0], + color_cmyk: None, background: [1.0, 1.0, 1.0, 1.0], erase: false, mode: BlendMode::Normal, @@ -668,4 +674,55 @@ pub struct BrushPreset { /// folders of Photoshop's Brushes panel; e.g. an imported `.abr` file's own folders. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub folder: Vec, + /// What picking the preset brings besides the tip and its sections: the New Brush Preset + /// dialog's boxes. + #[serde(default, skip_serializing_if = "PresetCaptures::is_default")] + pub captures: PresetCaptures, +} + +/// The New Brush Preset dialog's boxes, kept with the preset. The default is a preset made +/// without the dialog (a built-in, an import, an older save): picking it brings its size and tool +/// settings, as it always did, and leaves the foreground colour alone. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PresetCaptures { + /// Capture Brush Size in Preset. Off: picking the preset keeps the tool's size. + pub size: bool, + /// Include Tool Settings: the options bar's mode, opacity, flow and pressure buttons. Off: + /// picking the preset keeps the tool's. + pub tool_settings: bool, + /// Include Color: picking the preset sets the foreground colour to the brush's `color`. + pub color: bool, +} + +impl Default for PresetCaptures { + fn default() -> Self { + Self { size: true, tool_settings: true, color: false } + } +} + +impl PresetCaptures { + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +impl BrushPreset { + /// The tool's brush once this preset is picked while `current` is the tool's brush: the + /// preset's brush ([`BrushSettings::picked_over`]), with the size and tool settings it didn't + /// capture left as they were. + pub fn picked_over(&self, current: &BrushSettings) -> BrushSettings { + let mut b = self.brush.clone().picked_over(current); + if !self.captures.size { + b.size = current.size; + } + if !self.captures.tool_settings { + b.mode = current.mode; + b.opacity = current.opacity; + b.flow = current.flow; + b.pressure_size = current.pressure_size; + b.pressure_opacity = current.pressure_opacity; + } + b + } } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 371363b02a..284b7ae3b9 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -80,10 +80,12 @@ fn hardness_profile_is_monotonic() { for h in [0.0f32, 0.5, 1.0] { let b = BrushSettings { hardness: h, ..brush() }; let r = raster(&b, &Dab::round(Point::new(50.0, 50.0), 20.0, 1.0)); - let prof: Vec = (0..22).map(|i| at(&r, 50 + i, 50)).collect(); + // A soft tip paints past its 20 px radius, as Photoshop's does, out to its reach. + let n = crate::tip_reach(20.0, h).ceil() as i32 + 1; + let prof: Vec = (0..n).map(|i| at(&r, 50 + i, 50)).collect(); assert!(prof.windows(2).all(|w| w[1] <= w[0] + 1e-6), "h={h} {prof:?}"); assert!(prof[0] > 0.99); - assert_eq!(prof[21], 0.0); + assert!(*prof.last().unwrap() < 0.002, "h={h} {prof:?}"); let mid = at(&r, 60, 50); if h == 1.0 { assert!(mid > 0.99); @@ -103,7 +105,7 @@ fn hard_edge_is_sharp() { let band = prof.iter().filter(|&&a| (0.01..0.99).contains(&a)).count(); assert!(band <= 3, "hardness 1 rim is {band} px wide: {prof:?}"); assert!(prof[17] > 0.99, "translucent inside the rim: {prof:?}"); - assert_eq!(*prof.last().unwrap(), 0.0); + assert!(*prof.last().unwrap() < 0.01, "{prof:?}"); // A soft tip spreads the same crossing over many pixels instead. let soft = BrushSettings { hardness: 0.0, ..brush() }; let rs = raster(&soft, &Dab::round(Point::new(50.0, 50.0), 20.0, 1.0)); @@ -1283,7 +1285,9 @@ fn huge_scatter_is_capped_and_renders_at_every_depth() { let ctx = BrushContext::new(&b); for d in &capped.0 { let r = ctx.dab_rect(d, false); - assert!(!r.is_empty() && r.width() <= 24 && r.height() <= 24, "{r:?}"); + // A 20 px soft tip reaches about 1.6 radii (see `tip_reach`). + let side = 2 * (crate::tip_reach(10.0, b.hardness).ceil() as u32 + 1) + 2; + assert!(!r.is_empty() && r.width() <= side && r.height() <= side, "{r:?} vs {side}"); } for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); @@ -1367,14 +1371,32 @@ fn a_tiny_dabs_coverage_does_not_depend_on_where_it_lands_in_a_pixel() { } #[test] -fn the_tip_falls_off_from_the_hard_core_to_zero_at_the_edge() { +fn the_tip_matches_photoshops_measured_dabs() { use crate::tip_falloff; + // Photoshop 27.11, single 40 px dabs (R = 20): mean alpha over rings 0.1 R wide starting at + // the given fraction of the radius (area-weighted, as measured). + let measured: [(f32, [(f32, f32); 4]); 3] = [ + (0.0, [(0.2, 0.861), (0.5, 0.509), (0.9, 0.129), (1.2, 0.027)]), + (0.5, [(0.6, 0.97), (0.8, 0.561), (1.0, 0.135), (1.2, 0.012)]), + (1.0, [(0.8, 1.0), (0.9, 0.821), (1.2, 0.0), (1.3, 0.0)]), + ]; + for (h, rings) in measured { + for (lo, want) in rings { + let (mut sum, mut area) = (0.0, 0.0); + for k in 0..64 { + let r = (lo + 0.1 * (k as f32 + 0.5) / 64.0) * 20.0; + sum += tip_falloff(r, 20.0, h) * r; + area += r; + } + let got = sum / area; + assert!((got - want).abs() < 0.05, "hardness {h} ring at {lo} R: {got} vs {want}"); + } + } assert_eq!(tip_falloff(0.0, 10.0, 0.0), 1.0); - assert_eq!(tip_falloff(10.0, 10.0, 0.0), 0.0); assert_eq!(tip_falloff(4.0, 10.0, 0.5), 1.0, "inside the hard core"); - let v: Vec = (0..=20).map(|i| tip_falloff(i as f32 * 0.5, 10.0, 0.0)).collect(); + let v: Vec = (0..=40).map(|i| tip_falloff(i as f32 * 0.5, 10.0, 0.0)).collect(); assert!(v.windows(2).all(|w| w[1] <= w[0]), "{v:?}"); - assert!((tip_falloff(5.0, 10.0, 0.0) - 0.75f32.powi(4)).abs() < 1e-6); + assert_eq!(tip_falloff(crate::tip_reach(10.0, 0.0) * 4.0, 10.0, 0.0), 0.0, "far past the reach"); } #[test] @@ -1385,7 +1407,8 @@ fn half_coverage_radius_is_the_tips_50_percent_contour() { let d = half_coverage_radius(10.5, h); assert!((tip_falloff(d, 10.5, h) - 0.5).abs() < 1e-3, "hardness {h}: ring at {d}"); } - assert_eq!(half_coverage_radius(10.5, 1.0), 10.5); + // A hard tip's ring is its edge. + assert!((half_coverage_radius(10.5, 1.0) - 10.5).abs() < 0.5); assert!(half_coverage_radius(10.5, f32::NAN).is_finite()); } diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index c5d065249f..4dc07b6604 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -33,8 +33,8 @@ pub mod symmetry; pub mod tile; pub use brush::{ - BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MAX_BRUSH_SIZE, MAX_SCATTER, MaskMode, Pattern, PatternStyle, Pose, Scattering, - SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer, + BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MAX_BRUSH_SIZE, MAX_SCATTER, MaskMode, Pattern, PatternStyle, Pose, PresetCaptures, + Scattering, SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer, }; pub use mixer::MixerSettings; pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke}; @@ -138,42 +138,78 @@ pub fn dabs(stroke: &Stroke) -> Vec { out } -/// Opacity of a round tip at distance `d` from its centre, without edge anti-aliasing: 1 in the -/// hard core (`hardness` of the radius), then a Gaussian-like fall to 0 at the radius: -/// `(1 - t²)⁴`, which tracks `exp(-4.6 t²)` closely but is exact (no `exp`, the same on every -/// platform), reaches exactly 0 at the edge and leaves it flat. A soft tip keeps most of its area -/// faint, so a stroke of overlapping dabs (Flow builds up) stays about as soft as one dab, as in -/// Photoshop; the old smoothstep kept so much near the centre that strokes came out hard. +/// Photoshop's round tip, measured (Photoshop 27.11, single dabs at Spacing 1000 %, hardness 0–100 +/// at 40 px and checked at 100, 9 and 3 px): a solid core of radius `r0 · R`, then a Gaussian +/// shoulder `exp(-((d − r0) / s)²)` of width `s · R`, both by hardness (0, 0.1, … 1). A soft tip +/// reaches well past its nominal radius (hardness 0 is a Gaussian of σ ≈ 0.47 R: 8 % at 1.05 R). +const TIP_SHAPE: [(f32, f32); 11] = + [(0.00, 0.664), (0.14, 0.58), (0.28, 0.50), (0.40, 0.44), (0.52, 0.36), (0.63, 0.29), (0.70, 0.24), (0.78, 0.19), (0.84, 0.15), (0.89, 0.11), (1.00, 0.0)]; + +/// Photoshop's anti-aliased edge: the shoulder never narrows below this many pixels, and the core +/// stays this far inside the radius (a hardness-100 tip is a disc with a ~1 px soft edge at any size). +const TIP_EDGE_PX: f32 = 1.2; + +/// The core radius and shoulder width (both in pixels) of a round tip of `radius` and `hardness`. +#[inline] +pub fn tip_shape(radius: f32, hardness: f32) -> (f32, f32) { + let h = if hardness.is_nan() { 0.0 } else { hardness.clamp(0.0, 1.0) } * 10.0; + let i = (h.floor() as usize).min(9); + let t = h - i as f32; + let (a, b) = (TIP_SHAPE[i], TIP_SHAPE[i + 1]); + let (r0, s) = (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t); + let radius = if radius.is_finite() { radius.max(0.0) } else { 0.0 }; + // A tip of a few pixels keeps a narrower edge, so a small hard dab stays solid in the middle. + // Never sharper than a quarter pixel, the small-tip supersampling step. + let edge = TIP_EDGE_PX.min(0.3 * radius).max(0.25); + ((r0 * radius).min(radius - edge).max(0.0), (s * radius).max(edge).max(1e-3)) +} + +/// `exp(-u)` as `(1 − u/64)⁶⁴` by six squarings: within 1/255 of `exp` where it matters, exact +/// arithmetic (the same on every platform) and exactly 0 from `u = 64`. +#[inline] +fn soft_exp(u: f32) -> f32 { + let mut g = (1.0 - u / 64.0).clamp(0.0, 1.0); + for _ in 0..6 { + g *= g; + } + g +} + +/// Opacity of a round tip at distance `d` from its centre, edge included (see [`tip_shape`]). #[inline] pub fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { - let inner = radius * hardness.clamp(0.0, 1.0); - if d <= inner || radius - inner < 1e-3 { + let (r0, s) = tip_shape(radius, hardness); + if d <= r0 { return 1.0; } - let t = ((d - inner) / (radius - inner)).clamp(0.0, 1.0); - let s = 1.0 - t * t; - let s2 = s * s; - s2 * s2 + let x = (d - r0) / s; + soft_exp(x * x) +} + +/// How far from its centre a round tip still paints (where [`tip_falloff`] drops below ~0.1 %). +#[inline] +pub fn tip_reach(radius: f32, hardness: f32) -> f32 { + let (r0, s) = tip_shape(radius, hardness); + r0 + 2.6 * s } /// Distance from the centre where [`tip_falloff`] drops to 50 %: the contour Preferences › -/// Cursors › Normal Brush Tip outlines. It inverts `(1 - t²)⁴ = ½`, so `t = √(1 - ½^¼) ≈ 0.399` -/// of the way from the hard core (`hardness · radius`) to the edge. +/// Cursors › Normal Brush Tip outlines. `(1 − u/64)⁶⁴ = ½` at `u = 64 (1 − ½^(1/64))`. #[inline] pub fn half_coverage_radius(radius: f32, hardness: f32) -> f32 { - let h = if hardness.is_nan() { 0.0 } else { hardness.clamp(0.0, 1.0) }; - let t = (1.0 - 0.5f32.sqrt().sqrt()).sqrt(); - radius * (h + t * (1.0 - h)) + let (r0, s) = tip_shape(radius, hardness); + let u = 64.0 * (1.0 - 0.5f32.powf(1.0 / 64.0)); + r0 + s * u.sqrt() } -/// Coverage of a round dab at distance `d` from its centre (anti-aliased edge, hardness falloff). +/// Coverage of a round dab at distance `d` from its centre. #[inline] pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 { - if d >= radius + 0.5 { + // NaN distances paint nothing. + if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 { return 0.0; } - let edge = ((radius + 0.5 - d).clamp(0.0, 1.0)).min(1.0); - edge * tip_falloff(d, radius, hardness) + tip_falloff(d, radius, hardness) } /// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface). @@ -229,7 +265,9 @@ mod tests { assert_eq!(dab_coverage(20.0, 10.0, 1.0), 0.0); let soft_mid = dab_coverage(5.0, 10.0, 0.0); assert!(soft_mid > 0.2 && soft_mid < 0.8); - assert!(dab_coverage(9.9, 10.0, 1.0) > 0.5); + // A hard tip is opaque to just inside its radius and gone 1 px outside it. + assert!(dab_coverage(8.5, 10.0, 1.0) > 0.99 && dab_coverage(9.5, 10.0, 1.0) > 0.5); + assert!(dab_coverage(11.0, 10.0, 1.0) < 0.05); } #[test] diff --git a/crates/paint/src/presets.rs b/crates/paint/src/presets.rs index cbba537e1c..f711d0eb51 100644 --- a/crates/paint/src/presets.rs +++ b/crates/paint/src/presets.rs @@ -11,7 +11,7 @@ use crate::procedural::{bristle_tip, chalk_tip, charcoal_tip, grass_tip, leaf_ti pub const GROUPS: [&str; 4] = ["General", "Dry Media", "Wet Media", "Special Effects"]; fn preset(group: &str, name: &str, brush: BrushSettings) -> BrushPreset { - BrushPreset { name: name.to_string(), brush, builtin: true, group: group.to_string(), folder: Vec::new() } + BrushPreset { name: name.to_string(), brush, builtin: true, group: group.to_string(), folder: Vec::new(), captures: Default::default() } } fn pressure() -> Dynamic { diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 688232c93e..89c0abebd6 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use photocraft_color::{BlendMode, PixelFormat, SampleType}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -19,7 +19,7 @@ use crate::dynamics::DabGenerator; use crate::retouch::{Footprint, alpha_index, over_native}; use crate::rng::hash2; use crate::tile::{Mips, PatternImage}; -use crate::{Dab, StrokePoint, dab_coverage, tip_falloff}; +use crate::{Dab, StrokePoint, dab_coverage, tip_falloff, tip_reach}; /// Salt of the 8-bit dither's per-pixel noise. const DITHER_SALT: u64 = 0x0000_6469_7468_6572; @@ -213,7 +213,16 @@ impl BrushContext { /// Bounding rectangle of a dab (rotated sampled tips need the corner reach). pub fn dab_rect(&self, d: &Dab, dual: bool) -> Rect { let sampled = if dual { self.dual_tip.is_some() } else { self.tip.is_some() }; - let reach = if sampled { d.radius * std::f32::consts::SQRT_2 } else { d.radius }; + let hardness = if dual { self.brush.dual_brush.hardness } else { self.brush.hardness }; + // A computed soft tip paints past its nominal radius (see `tip_shape`). + let aliased = self.brush.aliased && !dual; + let reach = if sampled { + d.radius * std::f32::consts::SQRT_2 + } else if aliased { + d.radius + } else { + crate::tip_reach(d.radius, hardness).max(d.radius) + }; rect_around(d, reach) } @@ -251,7 +260,8 @@ impl BrushContext { let r = d.radius; let ro = d.roundness.max(0.5 / r).min(1.0); let rm = (r * ro).max(0.5); - let reach2 = (rm + 0.5) * (rm + 0.5); + let reach = if aliased || mips.is_some() { rm } else { crate::tip_reach(rm, hardness).max(rm) }; + let reach2 = (reach + 0.5) * (reach + 0.5); // Sampled-tip mapping. let (level, inv_scale, tw, th) = match mips { Some(m) => { @@ -275,7 +285,7 @@ impl BrushContext { // sampling only the pixel centre made a tiny dab's total coverage swing with its sub-pixel // position, and thin lines beaded. let small = mips.is_none() && !aliased && rm < SMALL_TIP_RADIUS; - let small_reach2 = (rm + 0.75) * (rm + 0.75); + let small_reach2 = (reach + 0.75) * (reach + 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_some() || small || (aliased && b.square) || noise || tex_tip || wet) @@ -319,12 +329,9 @@ impl BrushContext { for sx in SUBPIXEL { let (su, sv) = to_tip(dx + sx, dy + sy); let sd = ((su * ro).powi(2) + sv * sv).sqrt(); - // Each sample anti-aliases over its own quarter pixel, so the - // covered area changes smoothly with the dab's position. - let inside = ((rm - sd) * 4.0 + 0.5).clamp(0.0, 1.0); - if inside > 0.0 { - sum += inside * tip_falloff(sd.min(rm), rm, hardness); - } + // Each sample takes the tip's own edge, so the covered area + // changes smoothly with the dab's position. + sum += tip_falloff(sd, rm, hardness); } } (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) @@ -966,6 +973,12 @@ impl StrokeRenderer { let opacity = b.opacity.clamp(0.0, 1.0); let mut src = [0.0f32; 8]; from_rgba_into(&fmt, b.color, &mut src); + // A colour picked as CMYK paints its inks as they are in a CMYK target (#4344). + if let (ColorMode::Cmyk, Some((rgb, inks))) = (fmt.mode, b.color_cmyk) + && rgb == [b.color[0], b.color[1], b.color[2]] + { + src[..4].copy_from_slice(&inks); + } if let Some(a) = a_idx { src[a] = b.color[3]; } @@ -1076,8 +1089,8 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: let ro = d.roundness.max(0.5 / d.radius).min(1.0); let rm = (d.radius * ro).max(0.5); let w = rect.width() as usize; - let reach2 = (rm + 0.5) * (rm + 0.5); - let inner = rm * hardness.clamp(0.0, 1.0); + let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) }; + let reach2 = (reach + 0.5) * (reach + 0.5); for (yy, row) in out.chunks_exact_mut(w).enumerate() { let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy; if aliased { @@ -1086,25 +1099,12 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: let d2 = (dx * ro).powi(2) + dy * dy; *dst = if d2 < reach2 && d2.sqrt() <= rm { d.alpha } else { 0.0 }; } - } else if rm - inner < 1e-3 { - for (xx, dst) in row.iter_mut().enumerate() { - let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; - let d2 = (dx * ro).powi(2) + dy * dy; - let edge = ((rm + 0.5 - d2.sqrt()).clamp(0.0, 1.0)).min(1.0); - *dst = if d2 >= reach2 || edge <= 0.0 { 0.0 } else { edge * d.alpha }; - } } else { for (xx, dst) in row.iter_mut().enumerate() { let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; let d2 = (dx * ro).powi(2) + dy * dy; - let dd = d2.sqrt(); - let edge = ((rm + 0.5 - dd).clamp(0.0, 1.0)).min(1.0); - let t = ((dd - inner) / (rm - inner)).clamp(0.0, 1.0); - let s = 1.0 - t * t; - let s2 = s * s; - let falloff = if dd <= inner { 1.0 } else { s2 * s2 }; - let val = edge * falloff; - *dst = if d2 >= reach2 || val <= 0.0 { 0.0 } else { val * d.alpha }; + let val = if d2 >= reach2 { 0.0 } else { tip_falloff(d2.sqrt(), rm, hardness) }; + *dst = if val <= 0.0 { 0.0 } else { val * d.alpha }; } } } diff --git a/crates/paint/src/render_reference.rs b/crates/paint/src/render_reference.rs index 3811e7c998..10172e6fea 100644 --- a/crates/paint/src/render_reference.rs +++ b/crates/paint/src/render_reference.rs @@ -30,7 +30,8 @@ impl BrushContext { let r = d.radius; let ro = d.roundness.max(0.5 / r).min(1.0); let rm = (r * ro).max(0.5); - let reach2 = (rm + 0.5) * (rm + 0.5); + let reach = if aliased || mips.is_some() { rm } else { crate::tip_reach(rm, hardness).max(rm) }; + let reach2 = (reach + 0.5) * (reach + 0.5); // Sampled-tip mapping. let (level, inv_scale, tw, th) = match mips { Some(m) => { @@ -54,7 +55,7 @@ impl BrushContext { // sampling only the pixel centre made a tiny dab's total coverage swing with its sub-pixel // position, and thin lines beaded. let small = mips.is_none() && !aliased && rm < SMALL_TIP_RADIUS; - let small_reach2 = (rm + 0.75) * (rm + 0.75); + let small_reach2 = (reach + 0.75) * (reach + 0.75); for yy in 0..h { let y = rect.y0 + yy as i32; let dy = y as f32 + 0.5 - cy; @@ -80,12 +81,7 @@ impl BrushContext { for sx in SUBPIXEL { let (su, sv) = to_tip(dx + sx, dy + sy); let sd = ((su * ro).powi(2) + sv * sv).sqrt(); - // Each sample anti-aliases over its own quarter pixel, so the - // covered area changes smoothly with the dab's position. - let inside = ((rm - sd) * 4.0 + 0.5).clamp(0.0, 1.0); - if inside > 0.0 { - sum += inside * tip_falloff(sd.min(rm), rm, hardness); - } + sum += tip_falloff(sd, rm, hardness); } } (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 0129c31638..9ef837c0c0 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -102,7 +102,8 @@ pub fn dab_rect(d: &Dab) -> Rect { /// Rasterize one dab's coverage. pub fn footprint(d: &Dab, index: usize, hardness: f32) -> Footprint { - let rect = dab_rect(d); + // A soft tip paints past its nominal radius. + let rect = crate::render::rect_around(d, crate::tip_reach(d.radius, hardness).max(d.radius)); let (cx, cy) = (d.center.x as f32, d.center.y as f32); let mut cov = Vec::with_capacity(rect.width() as usize * rect.height() as usize); for y in rect.y0..rect.y1 { @@ -554,7 +555,7 @@ mod tests { } #[test] - fn footprint_stays_inside_the_full_size_ring() { + fn footprint_stays_inside_the_tips_reach() { let radius = 10.5f32; for hardness in [1.0, 0.5, 0.0] { let dab = Dab::round(Point::new(32.0, 32.0), radius, 1.0); @@ -569,8 +570,9 @@ mod tests { } painted += 1; farthest = farthest.max(dist); + let reach = f64::from(crate::tip_reach(radius, hardness)); assert!( - dist < radius as f64 + 0.5, + dist < reach.max(radius as f64) + 0.5, "hardness {hardness}: pixel ({x}, {y}) at {dist} px from \ the centre is painted past the Full Size ring of \ {radius} px" diff --git a/crates/psd/src/shc.rs b/crates/psd/src/shc.rs index dfe4855719..135fd33e62 100644 --- a/crates/psd/src/shc.rs +++ b/crates/psd/src/shc.rs @@ -50,14 +50,17 @@ fn read_name(r: &mut Reader<'_>) -> Result { r.check_count(n, 2)?; let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("contour name length"))?; let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); + // The last unit read, not the last one kept: past the cap the counted null is read but not kept. + let mut last = None; for _ in 0..n { let u = r.u16()?; + last = Some(u); if units.len() < MAX_NAME_UNITS { units.push(u); } } - // A count that leaves out the terminator: the null follows (a point count is never 0). - if units.last() != Some(&0) && r.peek_rest().get(..2) == Some(&[0, 0]) { + // A count that leaves out the terminator: the null follows. + if last != Some(0) && r.peek_rest().get(..2) == Some(&[0, 0]) { r.u16()?; } while units.last() == Some(&0) { @@ -128,7 +131,8 @@ pub fn write(contours: &[ShcContour]) -> Result> { for c in contours { let n = u16::try_from(c.points.len()).ok().filter(|n| usize::from(*n) <= MAX_POINTS).ok_or(PsdError::LimitExceeded("too many points in a contour"))?; out.put_u32(2); - let units: Vec = c.name.encode_utf16().take(MAX_NAME_UNITS).collect(); + // Capped on a character boundary, so a surrogate pair is never split. + let units = crate::io::utf16_units(&c.name, MAX_NAME_UNITS); out.put_u32(units.len() as u32 + 1); for u in units { out.put_u16(u); @@ -195,4 +199,53 @@ mod tests { let many = ShcContour { name: String::new(), points: vec![ShcPoint { input: 0.0, output: 0.0, continuous: true }; MAX_POINTS + 1] }; assert!(write(&[many]).is_err()); } + + fn pts(n: usize) -> Vec { + (0..n).map(|i| ShcPoint { input: (i * 10) as f32, output: (i * 20) as f32, continuous: i % 2 == 0 }).collect() + } + + #[test] + fn shc_name_cap_never_splits_a_surrogate_pair() { + let name = format!("{}\u{1F600}", "a".repeat(MAX_NAME_UNITS - 1)); + let c = ShcContour { name, points: pts(2) }; + let back = parse(&write(&[c]).unwrap()).unwrap(); + assert_eq!(back[0].name, "a".repeat(MAX_NAME_UNITS - 1)); + } + + #[test] + fn shc_names_at_and_over_the_cap_keep_the_stream_in_sync() { + for len in [MAX_NAME_UNITS, MAX_NAME_UNITS + 1, MAX_NAME_UNITS * 3] { + for first_points in [0, 3] { + let long = ShcContour { name: "b".repeat(len), points: pts(first_points) }; + let next = ShcContour { name: "Next".into(), points: pts(4) }; + let back = parse(&write(&[long, next.clone()]).unwrap()).unwrap_or_else(|e| panic!("len {len}, points {first_points}: {e}")); + assert_eq!(back.len(), 2); + assert_eq!(back[0].name, "b".repeat(len.min(MAX_NAME_UNITS))); + assert_eq!(back[0].points.len(), first_points); + assert_eq!(back[1], next, "len {len}, points {first_points}"); + } + } + } + + #[test] + fn shc_name_count_without_null_at_the_cap_still_reads() { + // A hostile/foreign file: 1024 units counted, no null counted, null follows. + let mut v = b"8BFS".to_vec(); + v.put_u16(1); + v.put_u32(1); + v.put_u32(1); + v.put_u32(MAX_NAME_UNITS as u32); + for _ in 0..MAX_NAME_UNITS { + v.put_u16(u16::from(b'c')); + } + v.put_u16(0); + v.put_u16(1); + v.put_u16(7); + v.put_u16(9); + v.put_u32(0); + v.put_u32(255); + let c = parse(&v).unwrap(); + assert_eq!(c[0].name, "c".repeat(MAX_NAME_UNITS)); + assert_eq!((c[0].points[0].input, c[0].points[0].output), (9.0, 7.0)); + } } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index dbb6c0a107..194ecad12f 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -81,6 +81,27 @@ impl Tile { pub fn bytes_mut(&mut self) -> &mut [u8] { &mut self.data } + + /// Tile-local half-open bounds `(x0, y0, x1, y1)` of the pixels that differ from the encoded + /// pixel `dp` (one pixel of the tile's format), or `None` when every pixel equals it (or `dp` + /// is empty). + pub fn content_bounds(&self, dp: &[u8]) -> Option<(u16, u16, u16, u16)> { + let bpp = dp.len(); + if bpp == 0 { + return None; + } + let ts = TILE_SIZE as usize; + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); + for (y, row) in self.data.chunks_exact(ts * bpp).enumerate() { + let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; + let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); + x0 = x0.min(first); + x1 = x1.max(last + 1); + y0 = y0.min(y); + y1 = y + 1; + } + (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) + } } #[derive(Clone, Debug)] @@ -90,6 +111,49 @@ pub struct Surface { tiles: BTreeMap>, } +/// Tiles of one pixel value met while moving a surface ([`Surface::translated`]): each scanned +/// once, and one tile kept per value so the targets share it. +#[derive(Default)] +struct UniformTiles { + /// Per source tile (by address): the index of its value's tile in `canonical`, or `None`. + of: std::collections::HashMap<*const Tile, Option>, + canonical: Vec>, +} + +impl UniformTiles { + /// The tile to share for a target reading window `r` of `s`, when every tile under it holds + /// one and the same pixel value (`r` must lie on existing tiles). + fn window(&mut self, s: &Surface, r: Rect) -> Option> { + let bpp = s.format.bytes_per_pixel(); + let mut found = None; + for c in r.tiles() { + let t = s.tiles.get(&c)?; + let k = self.value_of(t, bpp)?; + if found.is_some_and(|f| f != k) { + return None; + } + found = Some(k); + } + self.canonical.get(found?).cloned() + } + + fn value_of(&mut self, t: &Arc, bpp: usize) -> Option { + if let Some(k) = self.of.get(&Arc::as_ptr(t)) { + return *k; + } + let mut px = t.data.chunks_exact(bpp); + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.data.get(..bpp) == Some(p0)) { + Some(k) => k, + None => { + self.canonical.push(t.clone()); + self.canonical.len() - 1 + } + }); + self.of.insert(Arc::as_ptr(t), k); + k + } +} + impl PartialEq for Surface { fn eq(&self, other: &Self) -> bool { self.format == other.format && self.default_pixel == other.default_pixel && self.tiles == other.tiles @@ -222,7 +286,6 @@ impl Surface { /// Exact bounds of pixels that differ from the default pixel. pub fn content_bounds(&self) -> Rect { - let bpp = self.format.bytes_per_pixel(); if self.tiles.is_empty() { return Rect::EMPTY; } @@ -235,25 +298,14 @@ impl Surface { } let mut order: Vec<(&TileCoord, &Arc)> = self.tiles.iter().collect(); order.sort_by_key(|(c, _)| (c.tx - gx0).min(gx1 - c.tx).min(c.ty - gy0).min(gy1 - c.ty)); - let dp = &*self.default_pixel; - let ts = TILE_SIZE as usize; let mut out = Rect::EMPTY; for (c, t) in order { let origin = c.rect(); if !out.is_empty() && out.contains_rect(&origin) { continue; } - let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in t.data.chunks_exact(ts * bpp).enumerate() { - let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; - let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); - x0 = x0.min(first); - x1 = x1.max(last + 1); - y0 = y0.min(y); - y1 = y + 1; - } - if x0 != usize::MAX { - out = out.union(&Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32).translate(origin.x0, origin.y0)); + if let Some((x0, y0, x1, y1)) = t.content_bounds(&self.default_pixel) { + out = out.union(&Rect::new(x0.into(), y0.into(), x1.into(), y1.into()).translate(origin.x0, origin.y0)); } } out @@ -693,7 +745,9 @@ impl Surface { } /// The surface moved by whole pixels: its encoded samples copied tile to tile (no float - /// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves. + /// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves, and + /// a target tile read entirely from tiles of one single pixel value shares one such tile (the + /// inside of a solid fill stays one copy-on-write tile however often it moves). /// `content` must hold every pixel that isn't the default one (`content_bounds`, say): only /// tiles it reaches are kept. pub fn translated(&self, dx: i32, dy: i32, content: Rect) -> Surface { @@ -707,10 +761,24 @@ impl Surface { let bpp = self.format.bytes_per_pixel(); let moved = content.translate(dx, dy); let targets: std::collections::BTreeSet = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect(); + let mut uniform = UniformTiles::default(); + let zero = vec![0u8; bpp]; for dc in targets { - let mut tile = Tile::filled(&self.format, &self.default_pixel); let dr = dc.rect(); let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); + // Every pixel of the target comes from a source tile (none from the default pixel). + // `sr` must be the exact preimage: an offset of `i32::MIN` saturates its negation. + let covered = sr.width() == TILE_SIZE.unsigned_abs() + && sr.height() == TILE_SIZE.unsigned_abs() + && sr.translate(dx, dy) == dr + && content.contains_rect(&sr) + && sr.tiles().all(|sc| self.tiles.contains_key(&sc)); + if covered && let Some(shared) = uniform.window(self, sr) { + out.tiles.insert(dc, shared); + continue; + } + // A covered target is overwritten whole, so skip filling it with the default pixel. + let mut tile = Tile::filled(&self.format, if covered { &zero } else { &self.default_pixel }); for sc in sr.intersect(&content).tiles() { let Some(st) = self.tiles.get(&sc) else { continue }; let part = sc.rect().intersect(&sr); @@ -745,7 +813,16 @@ impl Surface { encode_pixel(&format, &dp, &mut self.default_pixel); let coords: Vec = self.tiles.keys().copied().collect(); let px_count = (TILE_SIZE * TILE_SIZE) as usize; + // A tile shared by several coordinates (a filled background) is mapped once and stays + // shared (#4531). Keys are the original tiles, all alive until their coordinates are + // reached, so a freed address can't be mistaken for another original. + let mut done: std::collections::HashMap<*const Tile, Arc> = std::collections::HashMap::new(); for c in coords { + let Some(key) = self.tiles.get(&c).map(Arc::as_ptr) else { continue }; + if let Some(mapped) = done.get(&key) { + self.tiles.insert(c, mapped.clone()); + continue; + } let tile = self.tile_mut(c); for i in 0..px_count { for k in 0..color { @@ -754,6 +831,9 @@ impl Surface { write_sample(&mut tile.data, format.sample, at, f(v)); } } + if let Some(mapped) = self.tiles.get(&c) { + done.insert(key, mapped.clone()); + } } } @@ -762,7 +842,13 @@ impl Surface { let mut out = Surface::with_default(to, &convert_pixel(&self.format, &to, &self.default_pixel())); let from_n = self.channels(); let mut src = vec![0.0f32; from_n]; + // Each distinct tile converts once; coordinates sharing it share the result (#4531). + let mut done: std::collections::HashMap<*const Tile, Arc> = std::collections::HashMap::new(); for (c, t) in &self.tiles { + if let Some(converted) = done.get(&Arc::as_ptr(t)) { + out.tiles.insert(*c, converted.clone()); + continue; + } let dst = out.tile_mut(*c); let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { @@ -774,6 +860,9 @@ impl Surface { write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); } } + if let Some(converted) = out.tiles.get(c) { + done.insert(Arc::as_ptr(t), converted.clone()); + } } out } @@ -785,7 +874,12 @@ impl Surface { let mut src = Vec::new(); src.try_reserve_exact(from_n).map_err(|_| AllocationError::NotEnoughMemory { bytes: from_n * std::mem::size_of::() })?; src.resize(from_n, 0.0f32); + let mut done: std::collections::HashMap<*const Tile, Arc> = std::collections::HashMap::new(); for (c, t) in &self.tiles { + if let Some(converted) = done.get(&Arc::as_ptr(t)) { + out.tiles.insert(*c, converted.clone()); + continue; + } let dst = out.try_tile_mut(*c)?; let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { @@ -797,6 +891,9 @@ impl Surface { write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); } } + if let Some(converted) = out.tiles.get(c) { + done.insert(Arc::as_ptr(t), converted.clone()); + } } Ok(out) } @@ -916,6 +1013,43 @@ pub fn is_hdr(sample: SampleType) -> bool { #[cfg(test)] mod tests { + /// Distinct tile allocations in `s`. + fn distinct_tiles(s: &Surface) -> usize { + let mut seen: Vec<*const Tile> = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect(); + seen.sort(); + seen.dedup(); + seen.len() + } + + /// Converting or tone-mapping a surface keeps a shared tile shared (#4531) and gives the + /// same pixels as converting each pixel: a filled background is one tile across the grid, + /// plus some distinct tiles. + #[test] + fn conversion_keeps_shared_tiles_shared() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 2048, 1024), &[0.9, 0.5, 0.2, 1.0]); + s.fill_rect(Rect::new(10, 10, 20, 20), &[0.1, 0.2, 0.3, 0.5]); + let shared_before = distinct_tiles(&s); + assert!(shared_before <= 2 && s.tile_count() == 32, "{shared_before} distinct of {}", s.tile_count()); + let r = Rect::new(0, 0, 2048, 1024); + for to in [PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for out in [s.convert(to), s.try_convert(to).expect("memory")] { + assert_eq!(distinct_tiles(&out), shared_before, "{to:?}: still shared"); + assert_eq!(out.tile_count(), 32); + let (a, b) = (out.read_region(r), s.read_region(r)); + for (pa, pb) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0) { + let want = convert_pixel(&s.format(), &to, pb); + assert!(pa.iter().zip(&want).all(|(x, y)| (x - y).abs() < 1e-6), "{to:?}: {pa:?} vs {want:?}"); + } + } + } + let mut m = s.clone(); + m.map_color(|v| v * 0.5); + assert_eq!(distinct_tiles(&m), shared_before, "map_color keeps it shared"); + assert!((m.pixel(1000, 500)[0] - s.pixel(1000, 500)[0] * 0.5).abs() < 1.0 / 255.0); + assert_eq!(s.pixel(1000, 500), vec![s.pixel(1000, 500)[0], s.pixel(1000, 500)[1], s.pixel(1000, 500)[2], 1.0], "the source is untouched"); + } + use super::*; use photocraft_color::PixelFormat; use proptest::prelude::*; @@ -1187,6 +1321,18 @@ mod tests { } } + #[test] + fn tile_content_bounds_scans_against_the_given_pixel() { + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_pixel(3, 4, &[1.0]); + s.write_pixel(200, 9, &[1.0]); + let t = s.tile(TileCoord::new(0, 0)).expect("written tile"); + assert_eq!(t.content_bounds(&[0]), Some((3, 4, 201, 10))); + assert_eq!(t.content_bounds(&[255]).map(|b| (b.0, b.1)), Some((0, 0))); + // A malformed pixel is no content, not a panic. + assert_eq!(t.content_bounds(&[]), None); + } + #[test] fn translated_matches_reading_the_moved_pixels() { // Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default. @@ -1210,6 +1356,69 @@ mod tests { padded.fill_rect(Rect::new(2000, 2000, 2100, 2100), &s.default_pixel()); assert_eq!(padded.translated(5, 5, s.content_bounds()).tile_count(), s.translated(5, 5, s.content_bounds()).tile_count()); } + // A solid fill moved off the tile grid keeps sharing its one tile inside, at every depth + // and over a non-zero default pixel, and reads exactly like the copied pixels. + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8] { + let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + let r = Rect::new(-300, -10, 1300, 1030); + s.fill_rect(r, &vec![0.75; fmt.channels()]); + let fill = s.tile(TileCoord::new(1, 1)).cloned().expect("an inner fill tile"); + for (dx, dy) in [(10, 7), (-1, 0), (0, 300), (-257, 513)] { + let t = s.translated(dx, dy, s.content_bounds()); + let moved = r.translate(dx, dy); + assert_eq!(t.read_region(moved.inflate(3)), s.read_region(moved.inflate(3).translate(-dx, -dy)), "{fmt:?} by ({dx}, {dy})"); + // Targets read only from the fill's whole tiles (x -256..1280, y 0..1024). + let whole = Rect::new(-256, 0, 1280, 1024).translate(dx, dy); + let inner: Vec<_> = t.tiles().filter(|(c, _)| whole.contains_rect(&c.rect())).collect(); + assert!(!inner.is_empty() && inner.iter().all(|(_, a)| Arc::ptr_eq(a, &fill)), "{fmt:?} by ({dx}, {dy}): inner tiles copied"); + } + // Nudged back and forth, the inside keeps sharing one tile (copies of uniform edge + // tiles count as that value too) and the pixels stay exact. + let mut t = s.clone(); + for k in 0..8 { + let d = if k % 2 == 0 { 10 } else { -10 }; + t = t.translated(d, d / 2, t.content_bounds()); + } + // (Targets whose last window read uniform tiles only: one tile in from the fill's whole + // tiles on the sides the last move came from.) + let inner: Vec<_> = t.tiles().filter(|(c, _)| Rect::new(-256, 0, 1024, 768).contains_rect(&c.rect())).map(|(_, a)| a).collect(); + assert!(inner.len() >= 4 && inner.iter().all(|a| Arc::ptr_eq(a, inner[0])), "{fmt:?}: repeated moves copied the inside"); + assert_eq!(t.read_region(r.inflate(3)), s.read_region(r.inflate(3)), "{fmt:?}: back where it started"); + } + // Two solid fills of different values side by side: windows across the seam are + // copied (each tile is uniform, but not with the same value), the rest shared. + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 1024, 768), &vec![0.2; fmt.channels()]); + s.fill_rect(Rect::new(1024, 0, 2048, 768), &vec![0.9; fmt.channels()]); + let t = s.translated(10, 7, s.content_bounds()); + let all = Rect::new(-20, -20, 2100, 800); + assert_eq!(t.read_region(all), s.read_region(all.translate(-10, -7)), "{fmt:?}: two fills"); + let seam = t.tile(TileCoord::new(4, 1)).expect("seam tile"); + let (left, right) = (t.tile(TileCoord::new(2, 1)).expect("left"), t.tile(TileCoord::new(6, 1)).expect("right")); + assert!(!Arc::ptr_eq(seam, left) && !Arc::ptr_eq(seam, right) && !Arc::ptr_eq(left, right), "{fmt:?}: seam aliased"); + } + // An offset of i32::MIN saturates its negation, so the source window isn't the exact + // preimage: no shortcut may apply (a zero pre-fill would leak into the output). + let mut s = Surface::with_default(PixelFormat::GRAY8, &[0.5]); + s.fill_rect(Rect::new(i32::MAX - 1024, 0, i32::MAX - 512, 512), &[0.25]); + let (fill, default) = (s.sample_channel(i32::MAX - 600, 10, 0), s.default_pixel()[0]); + for (dx, dy) in [(i32::MIN, 1), (1, i32::MIN), (i32::MIN, 0)] { + let t = s.translated(dx, dy, s.content_bounds()); + for (c, _) in t.tiles() { + assert!(t.read_region(c.rect()).iter().all(|v| *v == fill || *v == default), "({dx}, {dy}): tile {c:?} holds a pre-fill"); + } + } + // One tile shared but not uniform: each target is copied, not aliased. + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_pixel(3, 4, &[1.0]); + let odd = s.tile(TileCoord::new(0, 0)).cloned().expect("written tile"); + let grid: Vec<_> = (0..9).map(|i| (TileCoord::new(i % 3, i / 3), odd.clone())).collect(); + s.put_tiles(grid); + let t = s.translated(10, 7, s.content_bounds()); + let all = Rect::new(-20, -20, 800, 800); + assert_eq!(t.read_region(all), s.read_region(all.translate(-10, -7))); + assert!(t.tiles().all(|(_, a)| !Arc::ptr_eq(a, &odd))); // Offsets at the limits don't panic. let mut s = Surface::new(PixelFormat::GRAY8); s.write_pixel(5, 5, &[1.0]); diff --git a/crates/raw/README.md b/crates/raw/README.md index 461a0ed0b6..232ef83b3b 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -75,7 +75,7 @@ camera colour tables were copied. | Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 4 and older, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | | Fujifilm RAF, uncompressed (X-Trans I–V, GFX and Bayer bodies since about 2010) | Decoded: 16-bit containers (either byte order), 12-bit LSB-first packing, 14-bit packing in 32-bit words; X-Trans 6×6 or Bayer 2×2 layout, black, as-shot WB, crop, RawExposureBias, orientation from the preview's EXIF | | Fujifilm compressed RAF, early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported (no public description of the compression); preview fallback | -| CR3 | Recognised, unsupported (preview fallback where a preview is found) | +| CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | | X-Trans and other periodic non-Bayer CFAs (up to 16×16) | Demosaiced by one pattern-agnostic edge-directed method, whatever `Demosaic` is chosen | ## Development pipeline @@ -91,6 +91,11 @@ camera colour tables were copied. 16 bits. 5. Orientation. +Development validates public sensor/crop/CFA metadata before allocation. The working-memory +limit checks peak live development buffers instead of summing disjoint phases. AHD limits +simultaneous scratch bands only when required; normal load balancing and pixels are preserved. +The default 2 GiB limit is unchanged and excludes caller-owned sensor/document storage. + Files without colour calibration (CR2, NEF, ARW, RW2, ORF, RAF…) have no camera matrix. Every one of them carries a JPEG the camera rendered from the same sensor data, so `fit_look` estimates the camera's rendering per file: both the sensor data (white-balanced as `develop` does) and the @@ -110,6 +115,10 @@ The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 sensor plane; it removes the border without changing levels, WB or calibration. Measured image areas preserve an explicit file crop. +For a slightly tighter camera JPEG, fitting can align the reference proxy using a bounded +90–100% centred search on training cells. It requires a strong correlation improvement and +retains the held-out colour gates; the developed crop and camera calibration are unchanged. + Without a usable fit (no preview, an unrelated, monochrome or differently shaped one, or a fit that does not clearly help) the documented neutral fallback stays: the white-balanced camera channels are treated as linear sRGB primaries, with no tone curve (plausible but flat, greyish colours; converting diff --git a/crates/raw/src/cr3.rs b/crates/raw/src/cr3.rs new file mode 100644 index 0000000000..bb2119e7d4 --- /dev/null +++ b/crates/raw/src/cr3.rs @@ -0,0 +1,202 @@ +//! Bounded CR3 container reading, independently implemented from ISO BMFF and +//! Laurent Clévy's public format description (canon_cr3/readme.md). This extracts +//! camera JPEGs and orientation, not CRX sensor data. Never scan compressed data +//! for JPEG signatures: offsets must come from a validated sample table or PRVW. + +use crate::preview::{Preview, candidate}; +use crate::tiff::{Tiff, tag}; + +const META_UUID: &[u8] = &[0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; +const PREVIEW_UUID: &[u8] = &[0xea, 0xf4, 0x2b, 0x5e, 0x1c, 0x98, 0x4b, 0x88, 0xb9, 0xfb, 0xb7, 0xdc, 0x40, 0x6e, 0x4d, 0x16]; +const MAX_BOXES: usize = 8192; +const MAX_DEPTH: usize = 12; + +fn u32_at(b: &[u8], p: usize) -> Option { + Some(u32::from_be_bytes(b.get(p..p.checked_add(4)?)?.try_into().ok()?)) +} + +fn u64_at(b: &[u8], p: usize) -> Option { + Some(u64::from_be_bytes(b.get(p..p.checked_add(8)?)?.try_into().ok()?)) +} + +struct BoxRef<'a> { + kind: &'a [u8], + body: &'a [u8], +} + +/// Walks only complete sibling boxes, including extended lengths and a final +/// zero-length box. Invalid lengths end this container; siblings never escape it. +fn boxes<'a>(mut b: &'a [u8], budget: &mut usize, mut f: impl FnMut(BoxRef<'a>, &mut usize)) { + while b.len() >= 8 && *budget > 0 { + *budget -= 1; + let Some(n) = u32_at(b, 0) else { + return; + }; + let (size, header) = match n { + 0 => (b.len(), 8), + 1 => { + let Some(size) = u64_at(b, 8).and_then(|n| usize::try_from(n).ok()) else { + return; + }; + (size, 16) + } + n => (n as usize, 8), + }; + if size < header { + return; + } + let Some(body) = b.get(header..size) else { + return; + }; + let Some(kind) = b.get(4..8) else { + return; + }; + f(BoxRef { kind, body }, budget); + let Some(rest) = b.get(size..) else { + return; + }; + b = rest; + } +} + +#[derive(Default)] +struct Track<'a> { + jpeg: bool, + sizes: Option<&'a [u8]>, + offsets: Option<(&'a [u8], bool)>, + mapping: bool, +} + +/// Accept only a single JPEG sample description, with CRAW's fixed sample-entry +/// header followed by an explicit JPEG codec box. +fn jpeg_description(b: &[u8], budget: &mut usize) -> bool { + if u32_at(b, 0) != Some(0) || u32_at(b, 4) != Some(1) { + return false; + } + let mut jpeg = false; + if let Some(entries) = b.get(8..) { + boxes(entries, budget, |entry, budget| { + if entry.kind == b"CRAW" + && let Some(children) = entry.body.get(82..) + { + boxes(children, budget, |codec, _| { + jpeg |= codec.kind == b"JPEG"; + }); + } + }); + } + jpeg +} + +fn track_boxes<'a>(b: &'a [u8], depth: usize, budget: &mut usize, t: &mut Track<'a>) { + if depth > MAX_DEPTH { + return; + } + boxes(b, budget, |item, budget| match item.kind { + b"mdia" | b"minf" | b"stbl" => track_boxes(item.body, depth + 1, budget, t), + b"stsd" => t.jpeg = jpeg_description(item.body, budget), + b"stsz" => t.sizes = Some(item.body), + b"co64" => t.offsets = Some((item.body, true)), + b"stco" => t.offsets = Some((item.body, false)), + // One entry, starting at chunk 1, one sample per chunk, description 1. + b"stsc" => t.mapping = item.body.len() == 20 && (0..5).all(|i| u32_at(item.body, i * 4) == Some(if i == 0 { 0 } else { 1 })), + _ => {} + }); +} + +fn track_preview<'a>(raw: &'a [u8], track: &'a [u8], budget: &mut usize, media: &[(usize, usize)]) -> Option> { + let mut t = Track::default(); + track_boxes(track, 0, budget, &mut t); + if !t.jpeg || !t.mapping { + return None; + } + let sizes = t.sizes?; + let (offsets, wide) = t.offsets?; + // Burst sequences are deliberately not paired by ordinal index: their + // sample-to-chunk mapping and picture selection need a separate implementation. + if u32_at(sizes, 0) != Some(0) || u32_at(sizes, 8) != Some(1) || u32_at(offsets, 0) != Some(0) || u32_at(offsets, 4) != Some(1) { + return None; + } + let fixed = u32_at(sizes, 4)?; + let len = if fixed == 0 { u32_at(sizes, 12)? } else { fixed }; + let off = if wide { u64_at(offsets, 8)? } else { u64::from(u32_at(offsets, 8)?) }; + let off = usize::try_from(off).ok()?; + let end = off.checked_add(len as usize)?; + if !media.iter().any(|(start, stop)| off >= *start && end <= *stop) { + return None; + } + candidate(raw, off, len as usize) +} + +#[derive(Default)] +struct Found<'a> { + previews: Vec>, + orientation: Option, + media: Vec<(usize, usize)>, +} + +fn walk<'a>(raw: &'a [u8], b: &'a [u8], depth: usize, budget: &mut usize, found: &mut Found<'a>) { + if depth > MAX_DEPTH { + return; + } + boxes(b, budget, |item, budget| match item.kind { + b"moov" => walk(raw, item.body, depth + 1, budget, found), + b"trak" => { + if found.previews.len() < 128 { + found.previews.extend(track_preview(raw, item.body, budget, &found.media)); + } + } + b"uuid" => { + if item.body.get(..16) == Some(META_UUID) + && let Some(children) = item.body.get(16..) + { + walk(raw, children, depth + 1, budget, found); + } else if item.body.get(..16) == Some(PREVIEW_UUID) + && let Some(children) = item.body.get(24..) + { + walk(raw, children, depth + 1, budget, found); + } + } + b"PRVW" => { + if found.previews.len() < 128 + && let Some(len) = u32_at(item.body, 12) + && let Some(p) = candidate(item.body, 16, len as usize) + { + found.previews.push(p); + } + } + b"CMT1" => { + if let Some(tiff) = Tiff::new(item.body) + && let Some(ifd) = tiff.ifd_at(tiff.first_ifd, 0) + && let Some(o) = tiff.tag_uint(&ifd, tag::ORIENTATION).filter(|o| (1..=8).contains(o)) + { + found.orientation = Some(o as u16); + } + } + _ => {} + }); +} + +fn read(raw: &[u8]) -> Found<'_> { + let mut found = Found::default(); + if raw.get(4..12) == Some(b"ftypcrx ") { + let mut budget = MAX_BOXES; + boxes(raw, &mut budget, |item, _| { + if item.kind == b"mdat" && found.media.len() < 128 { + let start = (item.body.as_ptr() as usize).saturating_sub(raw.as_ptr() as usize); + if let Some(end) = start.checked_add(item.body.len()) { + found.media.push((start, end)); + } + } + }); + walk(raw, raw, 0, &mut budget, &mut found); + } + found +} + +pub(crate) fn previews(raw: &[u8]) -> Vec> { + read(raw).previews +} +pub(crate) fn orientation(raw: &[u8]) -> Option { + read(raw).orientation +} diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index 2689061b32..8b87f14a69 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -187,14 +187,14 @@ fn kernels(phase: [u8; 4], method: Demosaic, stride: usize) -> [[Vec<(isize, f32 } /// Demosaics a padded CFA plane into interleaved RGB (`w × h × 3`). -pub(crate) fn demosaic(p: &Padded, phase: [u8; 4], method: Demosaic, to_xyz: &Mat3) -> Vec { +pub(crate) fn demosaic(p: &Padded, phase: [u8; 4], method: Demosaic, to_xyz: &Mat3, ahd_jobs: usize) -> Vec { let (w, h, s) = (p.w, p.h, p.stride); let mut out = vec![0.0f32; w * h * 3]; if w == 0 || h == 0 || p.data.len() < s * (h + 2 * PAD) { return out; } if method == Demosaic::Ahd { - ahd(p, phase, to_xyz, &mut out); + ahd(p, phase, to_xyz, &mut out, ahd_jobs); return out; } let ks = kernels(phase, method, s); @@ -460,7 +460,19 @@ const AHD_BAND: usize = 32; /// Extra band rows above and below the output rows. const M: usize = 3; -fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32]) { +/// Maximum scratch allocation of one AHD band: two green, RGB and Lab float +/// planes and two byte homogeneity planes, including their border rows. +pub(crate) fn ahd_scratch(w: u64, h: u64) -> u64 { + w.saturating_add(2 * PAD as u64).saturating_mul(h.min(AHD_BAND as u64).saturating_add(2 * M as u64)).saturating_mul(58) +} + +pub(crate) fn ahd_bands(h: usize) -> usize { + h.div_ceil(AHD_BAND) +} + +pub(crate) const AHD_TABLE_BYTES: u64 = (LABF_STEPS as u64 + 2) * 4; + +fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32], jobs: usize) { let (w, s) = (p.w, p.stride); let d = &p.data; // Colour at padded coordinates (PAD is even, so the phase is unchanged). @@ -468,7 +480,7 @@ fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32]) { let white = [0.9642f32, 1.0, 0.8249]; let m: [[f32; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| to_xyz[i][j] as f32 / white[i])); let labf = LabF::new(); - par::chunks_mut(out, AHD_BAND * w * 3, |band, chunk| { + par::chunks_mut_limited(out, AHD_BAND * w * 3, jobs, |band, chunk| { let rows = chunk.len() / (w * 3); let n = rows + 2 * M; // Band row b ↔ padded row py(b); padded columns are used as is. @@ -577,13 +589,24 @@ mod tests { use super::*; use crate::color::IDENTITY; + #[test] + fn ahd_band_concurrency_does_not_change_pixels() { + let (w, h) = (53, 103); + let cfa: Vec = (0..w * h).map(|i| ((i * 97 + i / w * 31) % 1009) as f32 / 1009.0).collect(); + let p = Padded::from_plane(&cfa, w, h); + let one = demosaic(&p, [0, 1, 1, 2], Demosaic::Ahd, &IDENTITY, 1); + for jobs in [2, 3, 4, 128] { + assert_eq!(one, demosaic(&p, [0, 1, 1, 2], Demosaic::Ahd, &IDENTITY, jobs)); + } + } + /// Mosaics an RGB image with the given phase. fn mosaic(rgb: &[[f32; 3]], w: usize, h: usize, phase: [u8; 4]) -> Vec { (0..w * h).map(|i| rgb[i][phase[((i / w) & 1) * 2 + ((i % w) & 1)] as usize]).collect() } fn run(cfa: &[f32], w: usize, h: usize, phase: [u8; 4], m: Demosaic) -> Vec { - demosaic(&Padded::from_plane(cfa, w, h), phase, m, &IDENTITY) + demosaic(&Padded::from_plane(cfa, w, h), phase, m, &IDENTITY, par::workers()) } fn psnr(a: &[f32], b: &[[f32; 3]], w: usize, h: usize, border: usize) -> f64 { @@ -671,7 +694,7 @@ mod tests { let cfa: Vec = (0..w * h).map(|i| ((i * 7919) % 1000) as f32 / 1000.0).collect(); for phase in [[0, 1, 1, 2], [1, 2, 0, 1]] { let p = Padded::from_plane(&cfa, w, h); - let fast = demosaic(&p, phase, Demosaic::Bilinear, &IDENTITY); + let fast = demosaic(&p, phase, Demosaic::Bilinear, &IDENTITY, par::workers()); let ks = kernels(phase, Demosaic::Bilinear, p.stride); for y in 0..h { for x in 0..w { diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index ebe8656a39..e3ab70b0c7 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -195,16 +195,31 @@ pub(crate) fn resolve_neutral(s: &Sensor, opts: &DevelopOptions, sc: &Scale, war /// Develops sensor data. pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { let c = s.crop; - if c.is_empty() || c.x + c.width > s.width || c.y + c.height > s.height || s.data.len() < s.width * s.height * s.samples { + let samples = s.width.checked_mul(s.height).and_then(|n| n.checked_mul(s.samples)); + if c.is_empty() + || c.x.checked_add(c.width).is_none_or(|x| x > s.width) + || c.y.checked_add(c.height).is_none_or(|y| y > s.height) + || samples.is_none_or(|n| s.data.len() < n) + { return Err(RawError::malformed("crop lies outside the sensor data")); } + // LinearRaw can be monochrome (one sample) or RGB (three); CFA data needs one. if s.samples != 1 && s.samples != 3 { return Err(RawError::unsupported(format!("{} samples per pixel", s.samples))); } - // Working buffers: one f32 plane plus f32 RGB plus u16 RGB per pixel, and - // a green plane for non-Bayer patterns. + if let Some(cfa) = &s.cfa + && (s.samples != 1 + || cfa.width == 0 + || cfa.height == 0 + || cfa.width > 16 + || cfa.height > 16 + || cfa.colors.len() != cfa.width * cfa.height + || cfa.colors.iter().any(|c| *c > 2)) + { + return Err(RawError::malformed("invalid sensor CFA pattern")); + } let bayer = s.cfa.as_ref().is_none_or(|cfa| cfa.is_bayer()); - opts.limits.check(c.width as u64, c.height as u64, if bayer { 4 + 12 + 6 } else { 4 + 4 + 12 + 6 })?; + let ahd_jobs = working_memory(c.width, c.height, s.cfa.is_some(), bayer, opts, par::workers())?; let mut warnings = s.warnings.clone(); let sc = Scale::new(s); @@ -304,7 +319,7 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { if bayer { let p = cfa.phase(c.x, c.y); pattern = Some(names(&p)); - rgb = demosaic(&plane, p, opts.demosaic, &to_xyz); + rgb = demosaic(&plane, p, opts.demosaic, &to_xyz, ahd_jobs); } else { let pat = Pattern::of(cfa, c.x, c.y); pattern = Some(pat.colors.chunks(pat.w.max(1)).map(names).collect::>().join("/")); @@ -400,6 +415,36 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { }) } +/// Checks the peak of the two development phases. The CFA and non-Bayer green +/// planes are freed before the u16 output is allocated; AHD scratch is bounded +/// by reducing simultaneous bands when needed. Sensor storage belongs to the +/// caller and is checked separately by the decoder. +fn working_memory(w: usize, h: usize, cfa: bool, bayer: bool, opts: &DevelopOptions, workers: usize) -> Result { + let limits = &opts.limits; + limits.check(w as u64, h as u64, 18)?; + let pixels = (w as u64).saturating_mul(h as u64); + let output = pixels.saturating_mul(18).saturating_add((GAMMA_STEPS as u64 + 2) * 4); + let mut peak = output; + let mut jobs = workers.max(1); + if cfa { + let plane = (w as u64).saturating_add(2 * PAD as u64).saturating_mul((h as u64).saturating_add(2 * PAD as u64)).saturating_mul(4); + let mut mosaic = pixels.saturating_mul(if bayer { 12 } else { 16 }).saturating_add(plane); + if bayer && opts.demosaic == Demosaic::Ahd { + mosaic = mosaic.saturating_add(crate::demosaic::AHD_TABLE_BYTES); + let scratch = crate::demosaic::ahd_scratch(w as u64, h as u64); + let available = limits.max_alloc.saturating_sub(mosaic) / scratch.max(1); + jobs = jobs.min(crate::demosaic::ahd_bands(h)).min(usize::try_from(available).unwrap_or(usize::MAX)); + // At least one band must fit, even when no parallelism is available. + mosaic = mosaic.saturating_add(scratch.saturating_mul(jobs.max(1) as u64)); + } + peak = peak.max(mosaic); + } + if peak > limits.max_alloc || usize::try_from(peak).is_err() { + return Err(RawError::LimitExceeded(format!("{peak} bytes of RAW working memory exceed {}", limits.max_alloc))); + } + Ok(jobs.max(1)) +} + /// Gain-map factors (DNG OpcodeList2 GainMap) for data row `y`, columns /// `x0..x0 + w`, sample `k`; `None` when no map touches the row. fn gain_row(s: &Sensor, y: usize, x0: usize, w: usize, k: usize) -> Option> { @@ -544,6 +589,34 @@ pub(crate) fn orient(src: Vec, w: usize, h: usize, o: u16) -> (Vec, us mod tests { use super::*; + #[test] + fn hundred_megapixel_bayer_fits_without_raising_default_limits() { + let mut opts = DevelopOptions::default(); + for method in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] { + opts.demosaic = method; + for workers in [1, 10, 32, 128] { + let jobs = working_memory(11648, 8736, true, true, &opts, workers).unwrap(); + assert!(jobs <= workers); + if method == Demosaic::Ahd && workers >= 32 { + assert!(jobs < workers); + } + } + } + opts.limits.max_alloc = 1 << 30; + assert!(working_memory(11648, 8736, true, true, &opts, 1).is_err()); + } + + #[test] + fn working_memory_accounts_for_padding_and_at_least_one_ahd_band() { + let mut opts = DevelopOptions { demosaic: Demosaic::Ahd, ..Default::default() }; + opts.limits.max_alloc = 100 * 100 * 22; + assert!(working_memory(100, 100, true, true, &opts, 1).is_err()); + opts.demosaic = Demosaic::Mhc; + assert!(working_memory(100, 100, true, true, &opts, 1).is_ok()); + opts.limits.max_alloc = 100 * 100 * 18; + assert!(working_memory(100, 100, false, true, &opts, 1).is_err()); + } + #[test] fn gamma_table_matches_powf() { let lut = GammaLut::new(); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index dc1805d478..f8304b3cfa 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -46,6 +46,7 @@ mod cameras; mod color; mod cr2; +mod cr3; mod demosaic; mod develop; mod dng; @@ -60,6 +61,7 @@ mod par; mod preview; mod raf; mod rw2; +mod saturation; mod sensor; mod sony; mod tiff; @@ -84,6 +86,17 @@ use crate::tiff::{Tiff, tag}; /// Name of the output colour space of [`develop`]. pub const OUTPUT_SPACE: &str = "ProPhoto RGB (ROMM primaries, D50, gamma 1.8)"; +/// The raw container's orientation, when recorded (CR3 metadata or TIFF IFD0). +/// Useful for camera JPEGs that carry no orientation of their own. +pub fn orientation(bytes: &[u8]) -> Option { + if bytes.get(4..12) == Some(b"ftypcrx ") { + return cr3::orientation(bytes); + } + let tiff = Tiff::new(bytes)?; + let ifd = tiff.ifd_at(tiff.first_ifd, 0)?; + tiff.tag_uint(&ifd, tag::ORIENTATION).filter(|o| (1..=8).contains(o)).map(|o| o as u16) +} + /// Camera raw container families. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RawFormat { @@ -230,7 +243,7 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { _ => { let t = Tiff::new(bytes).ok_or(RawError::NotRaw)?; match format { - RawFormat::Dng => dng::decode(&t, limits), + RawFormat::Dng => dng::decode(&t, limits).map(lift_clipped), RawFormat::Cr2 => cr2::decode(&t, limits), RawFormat::Rw2 => rw2::decode(&t, limits), RawFormat::Orf => orf::decode(&t, limits), @@ -240,6 +253,13 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { } } +/// DNG writers can state a white level the sensor never reaches; samples stuck at the real saturation +/// point are raised to it so they clip together (see `saturation.rs`). +fn lift_clipped(mut s: Sensor) -> Sensor { + s.lift_clipped_samples(); + s +} + /// Decodes a lossless JPEG (T.81 process 14) stream into `(width, height, /// components, samples)`; at most `max_samples` samples are allocated. /// Exposed for fuzzing and tools. diff --git a/crates/raw/src/look.rs b/crates/raw/src/look.rs index 98d2c38026..77a5d38089 100644 --- a/crates/raw/src/look.rs +++ b/crates/raw/src/look.rs @@ -26,6 +26,10 @@ //! axis, the camera's rendering of white, still follows the JPEG). Cells with a //! clipped sensor sample, clipped or near-black JPEG pixels are left out of the //! fit; cells on strong edges get less weight. +//! A bounded centered alignment (90–100% of the raw frame's dimensions) can +//! account for a camera JPEG's tighter framing. It changes only the fit proxy, +//! requires correlation ≥ 0.9 and an improvement ≥ 0.05, and preserves the +//! developed crop. Alignment uses training cells; held-out cells stay out of it. //! //! The reference must show the same picture (lightness correlation ≥ 0.7 with //! the raw) in colour (≥ 2 % of the cells with chroma above 4). One cell in @@ -194,10 +198,97 @@ struct Cell { /// Fits a [`CameraLook`] so that developing `s` with `opts` (white balance, /// baseline exposure) matches the camera's `reference` rendering of it. pub fn fit_look(s: &Sensor, opts: &DevelopOptions, reference: &Reference) -> Result { - let (gw, cells) = grid(s, opts, reference)?; + let (gw, mut cells) = grid(s, opts, reference)?; + align_preview(&mut cells, gw, s.baseline_exposure); fit_cells(&cells, gw, s.baseline_exposure) } +/// Camera JPEGs can show a slightly tighter frame (lens correction, for example). +/// Align only the fit proxy, never the developed crop. Search a bounded centered +/// crop of at most 10%, and require a strong, substantially improved match. +fn align_preview(cells: &mut [Cell], w: usize, exposure: f64) { + if w == 0 || cells.len() < w || !cells.len().is_multiple_of(w) { + return; + } + let h = cells.len() / w; + let gain = 2f64.powf(if exposure.is_finite() { exposure.clamp(-10.0, 10.0) } else { 0.0 }); + let fallback = color::scale(&color::mul(&color::xyz_d50_to_prophoto(), &color::srgb_to_xyz_d50()), gain); + let step = (w / 64).max(1); + let score = |scale: f64| { + let mut pairs = Vec::new(); + for y in (0..h).step_by(step) { + for x in (0..w).step_by(step) { + if (x + 2 * y) % 3 == 0 { + continue; + } + let Some(c) = cells.get(y * w + x) else { continue }; + let Some((cam, clipped)) = aligned_camera(cells, w, h, x, y, scale) else { continue }; + if clipped || c.ref_clipped >= 0.01 || c.lum <= 0.002 || c.edge_weight <= 0.05 || cam.iter().any(|v| !v.is_finite() || *v <= 1e-4) { + continue; + } + let lum = color::apply(&PRO_TO_XYZ_Y_ROW, color::apply(&fallback, cam))[0]; + pairs.push((lum.max(0.0).sqrt(), c.lum.sqrt())); + } + } + if pairs.len() < 100 { f64::NAN } else { correlation(&pairs) } + }; + let original = score(1.0); + if !original.is_finite() { + return; + } + let (mut best, mut scale) = (original, 1.0); + for i in 0..5 { + let s = 0.9 + 0.02 * i as f64; + let r = score(s); + if r > best { + (best, scale) = (r, s); + } + } + if scale < 1.0 { + let center = scale; + for i in -7..=7 { + let s = (center + 0.0025 * f64::from(i)).clamp(0.9, 1.0); + let r = score(s); + if r > best { + (best, scale) = (r, s); + } + } + } + if best < 0.9 || best < original + 0.05 || scale >= 1.0 { + return; + } + let aligned: Vec<_> = (0..cells.len()).map(|i| aligned_camera(cells, w, h, i % w, i / w, scale)).collect(); + for (c, aligned) in cells.iter_mut().zip(aligned) { + if let Some((cam, clipped)) = aligned { + c.cam = cam; + c.cam_clipped = clipped; + } + } +} + +/// Bilinear interpolation on cell centers, retaining clipping information from +/// every participating cell. Coordinates remain inside the bounded fit grid. +fn aligned_camera(cells: &[Cell], w: usize, h: usize, x: usize, y: usize, scale: f64) -> Option<([f64; 3], bool)> { + let coord = |i: usize, n: usize| ((i as f64 + 0.5 - n as f64 / 2.0) * scale + n as f64 / 2.0 - 0.5).clamp(0.0, n.saturating_sub(1) as f64); + let (sx, sy) = (coord(x, w), coord(y, h)); + let (x0, y0) = (sx.floor() as usize, sy.floor() as usize); + let (fx, fy) = (sx - x0 as f64, sy - y0 as f64); + let mut cam = [0.0; 3]; + let mut clipped = false; + for (dx, dy, weight) in [(0, 0, (1.0 - fx) * (1.0 - fy)), (1, 0, fx * (1.0 - fy)), (0, 1, (1.0 - fx) * fy), (1, 1, fx * fy)] { + if weight <= 0.0 { + continue; + } + let index = y0.saturating_add(dy).min(h.saturating_sub(1)).checked_mul(w)?.checked_add(x0.saturating_add(dx).min(w.saturating_sub(1)))?; + let c = cells.get(index)?; + clipped |= c.cam_clipped; + for (out, v) in cam.iter_mut().zip(c.cam) { + *out += weight * v; + } + } + Some((cam, clipped)) +} + /// The upright fit grid: (width in cells, cells row-major). fn grid(s: &Sensor, opts: &DevelopOptions, r: &Reference) -> Result<(usize, Vec), LookRejected> { let c = s.crop; @@ -817,6 +908,28 @@ mod tests { assert_ne!(d.rgb, plain.rgb); } + #[test] + fn tighter_camera_preview_aligns_for_color_without_changing_the_raw_crop() { + let (w, h) = (480, 320); + let (s, rgb) = shot(w, h, &true_matrix()); + let (x0, y0, cw, ch) = (24, 16, 432, 288); + let mut tight = Vec::with_capacity(cw * ch * 3); + for y in y0..y0 + ch { + tight.extend_from_slice(&rgb[(y * w + x0) * 3..(y * w + x0 + cw) * 3]); + } + let reference = Reference { width: cw as u32, height: ch as u32, rgb: &tight }; + let (gw, plain) = grid(&s, &wb_opts(), &reference).unwrap(); + let before = fit_cells(&plain, gw, s.baseline_exposure); + let look = fit_look(&s, &wb_opts(), &reference).unwrap(); + assert!(look.delta_e < 3.0, "{look:?}"); + if let Ok(before) = before { + assert!(look.delta_e < before.delta_e * 0.5, "aligned {look:?}, before {before:?}"); + } + let d = crate::develop_sensor(&s, &DevelopOptions { camera_look: Some(look), ..wb_opts() }).unwrap(); + assert_eq!((d.width as usize, d.height as usize), (w, h)); + assert_eq!(s.crop, crate::Rect::new(0, 0, w, h)); + } + #[test] fn follows_the_raw_orientation() { let (w, h) = (480, 320); diff --git a/crates/raw/src/par.rs b/crates/raw/src/par.rs index a8e2c9f949..6d23fd70c4 100644 --- a/crates/raw/src/par.rs +++ b/crates/raw/src/par.rs @@ -12,6 +12,35 @@ pub(crate) fn chunks_mut(buf: &mut [T], chunk: usize, f: impl Fn(usize, buf.chunks_mut(chunk).enumerate().for_each(|(i, c)| f(i, c)); } +/// Native worker count (the browser builds run sequentially). +pub(crate) fn workers() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + rayon::current_num_threads() + } + #[cfg(target_arch = "wasm32")] + { + 1 + } +} + +/// Processes normal-sized chunks with at most `jobs` live scratch buffers. +pub(crate) fn chunks_mut_limited(buf: &mut [T], chunk: usize, jobs: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { + let chunk = chunk.max(1); + // Each worker holds one band's scratch at a time. When the normal worker + // count fits, preserve Rayon's fine-grained load balancing. + if jobs >= workers() { + chunks_mut(buf, chunk, f); + return; + } + let per_group = buf.len().div_ceil(chunk).div_ceil(jobs.max(1)).max(1); + chunks_mut(buf, chunk.saturating_mul(per_group), |group, part| { + for (local, c) in part.chunks_mut(chunk).enumerate() { + f(group * per_group + local, c); + } + }); +} + /// Maps `f` over `0..n`, keeping order. pub(crate) fn map(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index ec0c041fb5..d5dd013310 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -41,13 +41,13 @@ fn dct_jpeg_size(b: &[u8]) -> Option<(u32, u32)> { None } -fn candidate(data: &[u8], offset: usize, len: usize) -> Option> { +pub(crate) fn candidate(data: &[u8], offset: usize, len: usize) -> Option> { let jpeg = data.get(offset..offset.checked_add(len)?)?; let (width, height) = dct_jpeg_size(jpeg)?; Some(Preview { jpeg, width, height }) } -/// The largest embedded JPEG preview of a raw file (TIFF-based raws and RAF). +/// The largest embedded JPEG preview of a raw file (TIFF-based raws, RAF and CR3). pub fn embedded_preview(bytes: &[u8]) -> Option> { embedded_previews(bytes).into_iter().max_by_key(|p| u64::from(p.width) * u64::from(p.height)) } @@ -56,7 +56,9 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { /// full-size and a smaller rendering of the same picture). pub fn embedded_previews(bytes: &[u8]) -> Vec> { let mut found: Vec = Vec::new(); - if bytes.starts_with(crate::raf::MAGIC) { + if bytes.get(4..12) == Some(b"ftypcrx ") { + found = crate::cr3::previews(bytes); + } else if bytes.starts_with(crate::raf::MAGIC) { // RAF header: big-endian JPEG offset and length at bytes 84 and 88. if let Some((off, len)) = crate::raf::preview_range(bytes) { found.extend(candidate(bytes, off, len)); diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs new file mode 100644 index 0000000000..a36692a3f6 --- /dev/null +++ b/crates/raw/src/saturation.rs @@ -0,0 +1,265 @@ +//! Sensors that clip below the stated white level. +//! +//! A DNG's `WhiteLevel` is what the writer says the samples top out at, not always where the sensor +//! really saturates: a camera app can state 4095 for 12-bit data that piles up at 3567 (85 % of the +//! range above black, storytold/photocraft#4348, as storytold/lightcraft#548). Development clips each +//! channel at 1 after white balance, so samples stuck 15 % below the white level are not clipped +//! together: red and blue (multiplied by more than green) reach 1, green stays at 0.85, and blown +//! highlights turn magenta. +//! +//! [`Sensor::lift_clipped_samples`] finds the real saturation point of each colour from the samples +//! (a pile-up of identical values near the top of the range) and raises the samples at or above it to +//! the stated white level. Unclipped samples keep their values, and a file whose data reaches its white +//! level, or has no pile-up, is left alone. Same detector as storytold/lightcraft#586. + +use crate::sensor::Sensor; + +/// A saturation plateau must hold at least this share of its colour's samples (and 100 of them), as +/// many must lie below it (a scene, not a flat field)... +const MIN_SHARE: f64 = 1e-4; +/// ...be at least this many times as populated as the average value just below it... +const MIN_SPIKE: f64 = 16.0; +/// ...and have next to nothing above it (a real clip point is the top of the data; a popular value in +/// the middle of sparse or curve-coded data has plenty of samples above): at most this share of the +/// plateau lies more than `ABOVE_MARGIN` of the range over it (noise spilling past the plateau). +const MAX_ABOVE: f64 = 0.02; +const ABOVE_MARGIN: f32 = 0.01; +/// Plateaus within this share of the range from the white level are the white level (no lift needed). +const NEAR_WHITE: f32 = 0.03; +/// How far below the plateau the "average value just below it" reaches. +const NEIGHBOURHOOD: usize = 64; + +impl Sensor { + /// The value each colour's samples saturate at, when that is clearly below the stated white level + /// (see the module docs). Only CFA data without a linearization table is examined: with a table the + /// stored values are not on the white level's scale. + pub(crate) fn saturation_points(&self) -> [Option; 3] { + let none = [None; 3]; + let Some(cfa) = &self.cfa else { return none }; + if self.samples != 1 || self.linearization.is_some() || self.width.checked_mul(self.height) != Some(self.data.len()) { + return none; + } + let white = self.white[0]; + let black = if self.black.values.is_empty() { 0.0 } else { self.black.values.iter().sum::() / self.black.values.len() as f32 }; + // a hostile file can state any levels: NaN, negative or inverted ones are left alone + if !(black.is_finite() && white.is_finite() && black >= 0.0 && white > black && white <= 65535.0) { + return none; + } + let a = self.active; + let fits = |start: usize, len: usize, max: usize| start.checked_add(len).is_some_and(|end| end <= max); + if !fits(a.x, a.width, self.width) || !fits(a.y, a.height, self.height) { + return none; + } + let lo = (black + 0.5 * (white - black)).ceil() as usize; + let hi = white as usize; + if lo + NEIGHBOURHOOD >= hi { + return none; + } + let mut hist = vec![[0u32; 3]; 65536]; + let mut total = [0u64; 3]; + for y in a.y..a.y + a.height { + let row = &self.data[y * self.width + a.x..][..a.width]; + for (x, &v) in (a.x..).zip(row) { + let c = usize::from(cfa.color(x, y)).min(2); + total[c] += 1; + hist[usize::from(v)][c] += 1; + } + } + let mut out = none; + for c in 0..3 { + // the most populated value of the upper half of the range + let Some((v, n)) = (lo..hi).map(|v| (v, hist[v][c])).max_by_key(|&(v, n)| (n, v)) else { continue }; + let min_n = (total[c] as f64 * MIN_SHARE).max(100.0); + if f64::from(n) < min_n || v as f32 >= white - NEAR_WHITE * (white - black) { + continue; + } + // a colour whose samples all sit on one value is a flat field, not a scene that clipped + let darker: u64 = hist[..v].iter().map(|h| u64::from(h[c])).sum(); + if (darker as f64) < min_n { + continue; + } + let below: u64 = (v.saturating_sub(NEIGHBOURHOOD)..v).map(|u| u64::from(hist[u][c])).sum(); + let from = (v + ((ABOVE_MARGIN * (white - black)) as usize).max(1) + 1).min(hist.len()); + let above: u64 = hist[from..].iter().map(|h| u64::from(h[c])).sum(); + if f64::from(n) >= MIN_SPIKE * (below as f64 / NEIGHBOURHOOD as f64).max(1.0) && above as f64 <= MAX_ABOVE * f64::from(n) { + out[c] = u16::try_from(v).ok(); + } + } + out + } + + /// Raises samples at or above their colour's real saturation point ([`Self::saturation_points`]) to + /// the stated white level, so development clips them together. Returns how many samples changed + /// (0: the data is untouched). + pub(crate) fn lift_clipped_samples(&mut self) -> usize { + let points = self.saturation_points(); + if points.iter().all(Option::is_none) { + return 0; + } + let Some(cfa) = self.cfa.clone() else { return 0 }; + let (a, w) = (self.active, self.width); + // the white level itself, rounded up: (white - black) / (white - black) is exactly 1 + let white = self.white[0].ceil() as u16; + let mut changed = 0; + for y in a.y..a.y + a.height { + let row = &mut self.data[y * w + a.x..][..a.width]; + for (x, v) in (a.x..).zip(row.iter_mut()) { + if let Some(p) = points[usize::from(cfa.color(x, y)).min(2)] + && *v >= p + && *v < white + { + *v = white; + changed += 1; + } + } + } + changed + } +} + +#[cfg(test)] +mod tests { + use crate::*; + + /// A 64 x 64 RGGB mosaic with a gradient, black 528, white `white`, whose samples above `clip` (when + /// given) are stuck at `clip` like a sensor that saturates early. + fn sensor_with(clip: Option, white: f32) -> Sensor { + let data = (0..64 * 64) + .map(|i| { + let v = 528 + ((i % 64) * 60 + (i / 64) * 7) as u16 % 4000; + clip.map_or(v, |c| v.min(c)) + }) + .collect(); + Sensor { + format: RawFormat::Dng, + make: None, + model: None, + width: 64, + height: 64, + samples: 1, + data, + cfa: Cfa::bayer("RGGB"), + linearization: None, + black: BlackLevels::uniform(528.0), + white: [white; 3], + active: Rect::new(0, 0, 64, 64), + crop: Rect::new(0, 0, 64, 64), + color: ColorInfo::default(), + camera_wb: None, + orientation: 1, + baseline_exposure: 0.0, + gain_maps: Vec::new(), + tone_curve: Vec::new(), + black_point: 0.0, + warnings: Vec::new(), + } + } + + /// The gradient of `sensor_with`, wrapped so it never gets near the top of the range. + fn without_top(s: &mut Sensor) { + for v in s.data.iter_mut() { + *v = 528 + (*v - 528) % 3000; + } + } + + #[test] + fn a_pile_up_below_the_white_level_is_found_and_lifted() { + let mut s = sensor_with(Some(3567), 4095.0); + assert_eq!(s.saturation_points(), [Some(3567); 3]); + let before = s.data.clone(); + assert!(s.lift_clipped_samples() > 100); + for (x, y) in before.iter().zip(&s.data) { + // plateau samples go to the white level, everything else is untouched + assert_eq!(*y, if *x >= 3567 { 4095 } else { *x }); + } + assert_eq!(s.lift_clipped_samples(), 0, "idempotent"); + } + + #[test] + fn data_that_reaches_its_white_level_is_left_alone() { + let mut s = sensor_with(Some(4095), 4095.0); + let before = s.data.clone(); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + assert_eq!(s.data, before); + } + + #[test] + fn data_without_a_pile_up_is_left_alone() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + let before = s.data.clone(); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + assert_eq!(s.data, before); + } + + /// Sparse or curve-coded data has popular values with plenty of samples above them: not a clip point. + #[test] + fn a_popular_value_with_data_above_it_is_not_a_clip_point() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + for (i, v) in s.data.iter_mut().enumerate() { + // a third of the samples sit on one code in the upper half, a few hundred are brighter + if i % 3 == 0 { + *v = 3000; + } else if i % 11 == 0 { + *v = 3000 + (i % 700) as u16 + 1; + } + } + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + /// A uniform patch (a flat-field test frame) has nothing darker: not a scene that clipped. + #[test] + fn a_flat_field_is_not_a_clip_point() { + let mut s = sensor_with(None, 4095.0); + s.data.fill(3000); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + #[test] + fn a_few_stuck_samples_are_not_a_plateau() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + for v in s.data.iter_mut().take(20) { + *v = 3800; + } + assert_eq!(s.saturation_points(), [None; 3]); + } + + /// With a linearization table the stored codes are not on the white level's scale. + #[test] + fn linearized_data_is_left_alone() { + let mut s = sensor_with(Some(3567), 4095.0); + s.linearization = Some((0..4096).collect()); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + #[test] + fn hostile_levels_are_left_alone() { + for black in [-5000.0, f32::NAN] { + let mut s = sensor_with(Some(40), 4095.0); + s.black = BlackLevels::uniform(black); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + for white in [f32::NAN, f32::INFINITY, 1e9, 100.0] { + let mut s = sensor_with(Some(3567), 4095.0); + s.white = [white; 3]; + assert_eq!(s.saturation_points(), [None; 3], "{white}"); + } + // an active area that runs past the data (or overflows when added up) is refused + let mut s = sensor_with(Some(3567), 4095.0); + s.active = Rect::new(usize::MAX, 0, 2, 64); + assert_eq!(s.saturation_points(), [None; 3]); + s.active = Rect::new(0, 0, 64, 65); + assert_eq!(s.lift_clipped_samples(), 0); + let mut s = sensor_with(Some(3567), 4095.0); + s.data.pop(); + assert_eq!(s.lift_clipped_samples(), 0); + } +} diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index e8c65ebf59..585191b799 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -63,14 +63,22 @@ impl Cfa { /// Colour at data coordinates. #[inline] pub fn color(&self, x: usize, y: usize) -> u8 { - let cx = (x + self.width - self.origin_x % self.width) % self.width; - let cy = (y + self.height - self.origin_y % self.height) % self.height; - self.colors.get(cy * self.width + cx).copied().unwrap_or(1) + fn relative(v: usize, origin: usize, period: usize) -> Option { + if period == 0 { + return None; + } + let (v, origin) = (v % period, origin % period); + Some(if v >= origin { v - origin } else { period - (origin - v) }) + } + let Some((cx, cy)) = relative(x, self.origin_x, self.width).zip(relative(y, self.origin_y, self.height)) else { + return 1; + }; + cy.checked_mul(self.width).and_then(|i| i.checked_add(cx)).and_then(|i| self.colors.get(i)).copied().unwrap_or(1) } /// The 2×2 colours seen from data coordinates (`x0`, `y0`), row-major. pub fn phase(&self, x0: usize, y0: usize) -> [u8; 4] { - [self.color(x0, y0), self.color(x0 + 1, y0), self.color(x0, y0 + 1), self.color(x0 + 1, y0 + 1)] + [self.color(x0, y0), self.color(x0.saturating_add(1), y0), self.color(x0, y0.saturating_add(1)), self.color(x0.saturating_add(1), y0.saturating_add(1))] } /// `true` for a Bayer pattern: a 2×2 period holding one red, one blue and two @@ -79,10 +87,11 @@ impl Cfa { if self.width == 0 || self.height == 0 || self.width > 16 || self.height > 16 { return false; } - let p = self.phase(self.origin_x, self.origin_y); - let periodic = (0..self.height).all(|y| (0..self.width).all(|x| self.color(self.origin_x + x, self.origin_y + y) == p[(y % 2) * 2 + x % 2])); - let greens_on_diagonal = - (p[0] == 1 && p[3] == 1 && p[1] != 1 && p[2] != 1 && p[1] + p[2] == 2) || (p[1] == 1 && p[2] == 1 && p[0] != 1 && p[3] != 1 && p[0] + p[3] == 2); + let (ox, oy) = (self.origin_x % self.width, self.origin_y % self.height); + let p = self.phase(ox, oy); + let periodic = (0..self.height).all(|y| (0..self.width).all(|x| self.color(ox + x, oy + y) == p[(y % 2) * 2 + x % 2])); + let greens_on_diagonal = (p[0] == 1 && p[3] == 1 && p[1] != 1 && p[2] != 1 && u16::from(p[1]) + u16::from(p[2]) == 2) + || (p[1] == 1 && p[2] == 1 && p[0] != 1 && p[3] != 1 && u16::from(p[0]) + u16::from(p[3]) == 2); periodic && greens_on_diagonal } } @@ -110,7 +119,8 @@ impl BlackLevels { pub fn at(&self, ax: usize, ay: usize, s: usize, spp: usize) -> f32 { let r = ay % self.rows.max(1); let c = ax % self.cols.max(1); - let base = self.values.get((r * self.cols.max(1) + c) * spp + s).or_else(|| self.values.first()).copied().unwrap_or(0.0); + let index = r.checked_mul(self.cols.max(1)).and_then(|i| i.checked_add(c)).and_then(|i| i.checked_mul(spp)).and_then(|i| i.checked_add(s)); + let base = index.and_then(|i| self.values.get(i)).or_else(|| self.values.first()).copied().unwrap_or(0.0); base + self.delta_h.get(ax).copied().unwrap_or(0.0) + self.delta_v.get(ay).copied().unwrap_or(0.0) } diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index fdacc19828..823cd97ae6 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -7,6 +7,41 @@ // ---------------------------------------------------------------- lossless JPEG +/// A CR3 container holding one JPEG track and CMT1 orientation. It deliberately +/// contains no CRX data: used to test explicit preview fallback and BMFF bounds. +pub fn cr3_preview(jpeg: &[u8], orientation: u16) -> Vec { + fn bx(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut b = ((data.len() + 8) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(data); + b + } + fn moov(jpeg_len: usize, offset: u64, orientation: u16) -> Vec { + let mut tiff = TiffBuilder::default(); + let ifd = tiff.ifd(vec![(274, Val::Short(vec![orientation]))]); + tiff.chain = vec![ifd]; + let mut meta = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; + meta.extend(bx(b"CMT1", &tiff.build())); + let mut entry = vec![0; 82]; + entry.extend(bx(b"JPEG", &[0; 4])); + let mut description = vec![0, 0, 0, 0, 0, 0, 0, 1]; + description.extend(bx(b"CRAW", &entry)); + let mut sizes = vec![0; 4]; + sizes.extend_from_slice(&(jpeg_len as u32).to_be_bytes()); + sizes.extend_from_slice(&1u32.to_be_bytes()); + let mut offsets = vec![0, 0, 0, 0, 0, 0, 0, 1]; + offsets.extend_from_slice(&offset.to_be_bytes()); + let mapping: Vec = [0u32, 1, 1, 1, 1].iter().flat_map(|n| n.to_be_bytes()).collect(); + let table = [bx(b"stsd", &description), bx(b"stsz", &sizes), bx(b"co64", &offsets), bx(b"stsc", &mapping)].concat(); + let track = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &table)))); + bx(b"moov", &[bx(b"uuid", &meta), track].concat()) + } + let ftyp = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let header = moov(jpeg.len(), 0, orientation); + let offset = ftyp.len() + header.len() + 8; + [ftyp, moov(jpeg.len(), offset as u64, orientation), bx(b"mdat", jpeg)].concat() +} + /// Huffman code lengths assigned to categories in decreasing frequency /// order. Kraft sum < 1 and no code is all ones. const LENGTHS: [u8; 17] = [2, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 8, 9, 10, 11, 12, 13]; diff --git a/crates/raw/tests/cr3.rs b/crates/raw/tests/cr3.rs new file mode 100644 index 0000000000..6907172460 --- /dev/null +++ b/crates/raw/tests/cr3.rs @@ -0,0 +1,119 @@ +//! CR3 preview extraction never mistakes encoded sensor data for a JPEG. +use photocraft_raw::testgen::cr3_preview; +use photocraft_raw::{RawFormat, embedded_preview, embedded_previews, identify, orientation}; + +fn jpeg() -> Vec { + vec![0xff, 0xd8, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 32, 1, 1, 0x11, 0, 0xff, 0xd9] +} + +fn position(b: &[u8], marker: &[u8]) -> usize { + b.windows(marker.len()).position(|w| w == marker).unwrap() +} + +#[test] +fn full_size_jpeg_and_orientation_are_read_from_their_boxes() { + let j = jpeg(); + let b = cr3_preview(&j, 6); + assert_eq!(identify(&b), Some(RawFormat::Cr3)); + let p = embedded_preview(&b).unwrap(); + assert_eq!((p.width, p.height), (32, 20)); + assert_eq!(p.jpeg, j); + assert_eq!(embedded_previews(&b).len(), 1); + assert_eq!(orientation(&b), Some(6)); +} + +#[test] +fn variable_sizes_and_32_bit_chunk_offsets_work() { + let mut b = cr3_preview(&jpeg(), 1); + let at = position(&b, b"stsz") + 8; + // Add a single variable-size entry and repair ancestor lengths/offset. + let len = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()); + b[at..at + 4].fill(0); + b.splice(at + 8..at + 8, len.to_be_bytes()); + for marker in [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"stsz"] { + let at = position(&b, marker) - 4; + let n = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()); + b[at..at + 4].copy_from_slice(&(n + 4).to_be_bytes()); + } + let at = position(&b, b"co64"); + let off = u64::from_be_bytes(b[at + 12..at + 20].try_into().unwrap()) + 4; + b[at..at + 4].copy_from_slice(b"stco"); + b[at + 12..at + 16].copy_from_slice(&(off as u32).to_be_bytes()); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); +} + +#[test] +fn broken_tables_and_raw_codec_tracks_are_not_used() { + let good = cr3_preview(&jpeg(), 1); + for marker in [b"stsz", b"co64", b"stsc", b"JPEG"] { + let mut b = good.clone(); + let at = position(&b, marker); + b[at..at + 4].copy_from_slice(b"xxxx"); + assert!(embedded_preview(&b).is_none()); + } + let mut b = good.clone(); + let at = position(&b, b"co64") + 12; + b[at..at + 8].copy_from_slice(&u64::MAX.to_be_bytes()); + assert!(embedded_preview(&b).is_none()); + let mut b = good; + let at = position(&b, b"stsz") + 12; + b[at..at + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(embedded_preview(&b).is_none()); +} + +#[test] +fn truncations_and_corrupted_box_lengths_never_panic() { + let b = cr3_preview(&jpeg(), 8); + for n in 0..b.len() { + let _ = embedded_previews(&b[..n]); + let _ = orientation(&b[..n]); + } + for marker in [b"ftyp", b"moov", b"trak", b"mdat"] { + for len in [0u32, 1, 2, 7, u32::MAX] { + let mut corrupt = b.clone(); + let at = position(&b, marker) - 4; + corrupt[at..at + 4].copy_from_slice(&len.to_be_bytes()); + let _ = embedded_previews(&corrupt); + } + } +} + +#[test] +fn extended_mdat_length_is_supported() { + let mut b = cr3_preview(&jpeg(), 1); + let at = position(&b, b"mdat") - 4; + let size = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as u64 + 8; + b[at..at + 4].copy_from_slice(&1u32.to_be_bytes()); + b.splice(at + 8..at + 8, size.to_be_bytes()); + let off = position(&b, b"co64") + 12; + let n = u64::from_be_bytes(b[off..off + 8].try_into().unwrap()) + 8; + b[off..off + 8].copy_from_slice(&n.to_be_bytes()); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); +} + +#[test] +fn prvw_is_used_when_no_jpeg_track_exists() { + fn bx(kind: &[u8; 4], body: &[u8]) -> Vec { + [(body.len() as u32 + 8).to_be_bytes().as_slice(), kind, body].concat() + } + let ftyp = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut body = vec![0; 12]; + body.extend_from_slice(&(jpeg().len() as u32).to_be_bytes()); + body.extend(jpeg()); + let mut uuid = vec![0xea, 0xf4, 0x2b, 0x5e, 0x1c, 0x98, 0x4b, 0x88, 0xb9, 0xfb, 0xb7, 0xdc, 0x40, 0x6e, 0x4d, 0x16]; + uuid.extend([0; 8]); + uuid.extend(bx(b"PRVW", &body)); + let b = [ftyp, bx(b"uuid", &uuid)].concat(); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); + assert_eq!(orientation(&b), None); +} + +#[test] +fn deeply_nested_containers_are_bounded() { + let mut b = vec![0; 8]; + for _ in 0..1000 { + b = [((b.len() + 8) as u32).to_be_bytes().as_slice(), b"moov", &b].concat(); + } + let ftyp = [24u32.to_be_bytes().as_slice(), b"ftypcrx \0\0\0\x01crx isom"].concat(); + assert!(embedded_previews(&[ftyp, b].concat()).is_empty()); +} diff --git a/crates/raw/tests/development_limits.rs b/crates/raw/tests/development_limits.rs new file mode 100644 index 0000000000..4e5e1636d3 --- /dev/null +++ b/crates/raw/tests/development_limits.rs @@ -0,0 +1,91 @@ +//! Development limits and hostile public sensor metadata. +use photocraft_raw::testgen::DngSpec; +use photocraft_raw::*; + +fn small_sensor() -> Sensor { + decode(&DngSpec::cfa(64, 48, vec![1000; 64 * 48]).build(), &Limits::default()).unwrap() +} + +#[test] +fn checks_peak_live_buffers_instead_of_summing_disjoint_phases() { + let sensor = decode(&DngSpec::cfa(128, 96, vec![1000; 128 * 96]).build(), &Limits::default()).unwrap(); + let mut opts = DevelopOptions { demosaic: Demosaic::Mhc, ..Default::default() }; + opts.limits.max_alloc = 128 * 96 * 18 + (4096 + 2) * 4; + let expected = develop_sensor(&sensor, &DevelopOptions { demosaic: Demosaic::Mhc, ..Default::default() }).unwrap(); + assert_eq!(develop_sensor(&sensor, &opts).unwrap().rgb, expected.rgb); + opts.limits.max_alloc -= 1; + assert!(matches!(develop_sensor(&sensor, &opts), Err(RawError::LimitExceeded(_)))); +} + +#[test] +fn monochrome_and_rgb_linear_raw_preserve_output_and_memory_limits() { + let (w, h) = (32, 24); + for samples in [1, 3] { + let data = (0..w * h).flat_map(|i| (0..samples).map(move |c| (i % w * 1000 + c * 5000) as u16)).collect(); + let spec = DngSpec { samples, monochrome: samples == 1, ..DngSpec::cfa(w, h, data) }; + let sensor = decode(&spec.build(), &Limits::default()).unwrap(); + assert!(sensor.cfa.is_none()); + assert_eq!(sensor.samples, samples); + let expected = develop_sensor(&sensor, &DevelopOptions::default()).unwrap(); + let mut opts = DevelopOptions::default(); + opts.limits.max_alloc = 32 * 1024; + let got = develop_sensor(&sensor, &opts).unwrap(); + assert_eq!(got.rgb, expected.rgb); + assert_eq!((got.width, got.height), (w as u32, h as u32)); + if samples == 1 { + assert!(got.rgb.as_chunks::<3>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2])); + } + // Even the encoded RGB output alone cannot fit this budget. + opts.limits.max_alloc = (expected.rgb.len() * 2 - 1) as u64; + assert!(matches!(develop_sensor(&sensor, &opts), Err(RawError::LimitExceeded(_)))); + } +} + +#[test] +fn invalid_public_sensor_metadata_returns_errors() { + let sensor = small_sensor(); + let mut cases = Vec::new(); + let mut s = sensor.clone(); + s.crop.x = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.width = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.samples = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().width = 0; + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().colors.clear(); + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().colors[0] = 255; + cases.push(s); + let mut s = sensor.clone(); + s.cfa = None; + s.samples = 2; + s.data.resize(64 * 48 * 2, 1000); + cases.push(s); + let mut s = sensor.clone(); + s.samples = 3; + s.data.resize(64 * 48 * 3, 1000); + cases.push(s); + for s in cases { + assert!(develop_sensor(&s, &DevelopOptions::default()).is_err()); + } +} + +#[test] +fn extreme_cfa_and_black_origins_do_not_overflow() { + let mut sensor = small_sensor(); + let cfa = sensor.cfa.as_mut().unwrap(); + cfa.origin_x = usize::MAX; + cfa.origin_y = usize::MAX; + assert!(cfa.is_bayer()); + sensor.black.rows = usize::MAX; + sensor.black.cols = usize::MAX; + assert!(develop_sensor(&sensor, &DevelopOptions::default()).is_ok()); + assert_eq!(Cfa { width: 0, height: 0, colors: vec![], origin_x: 0, origin_y: 0 }.color(1, 2), 1); +} diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 9dfab5dfd7..e13fb920dd 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -425,6 +425,31 @@ fn highlights_clip_to_white_and_exposure_applies() { assert!((lin(up) / lin(base) - 2.0).abs() < 0.02, "{} vs {}", lin(up), lin(base)); } +/// storytold/photocraft#4348 (as storytold/lightcraft#548): a 12-bit DNG that states WhiteLevel 4095 +/// but whose sensor saturates at 3567 in every colour. Under the camera's white balance (red x2.46, +/// blue x1.59) the stuck red and blue samples clip at 1 while green stays at 0.85, so a blown wall +/// rendered magenta. The wall must come out neutral, and the unclipped ramp beside it unchanged. +#[test] +fn highlights_that_clip_below_the_stated_white_level_stay_neutral() { + let (w, h) = (64, 32); + let data: Vec = (0..w * h).map(|i| if i % w < w / 2 { 528 + ((i % w) * 90 + (i / w) * 3) as u16 } else { 3567 }).collect(); + let mut spec = DngSpec::cfa(w, h, data.clone()); + spec.bits = 12; + spec.black = vec![528]; + spec.white = 4095; + spec.as_shot_neutral = Some([1.0 / 2.46, 1.0, 1.0 / 1.59]); + let b = spec.build(); + let d = develop(&b, &DevelopOptions::default()).unwrap(); + let px = &d.rgb[(16 * w + 48) * 3..(16 * w + 48) * 3 + 3]; + let lin = |v: u16| (f64::from(v) / 65535.0).powf(1.8); + let tint = (lin(px[0]) / lin(px[1]) - 1.0).abs().max((lin(px[2]) / lin(px[1]) - 1.0).abs()); + assert!(tint < 0.01, "the clipped wall is tinted: {px:?}"); + let s = sensor(&b); + for (i, (&got, &was)) in s.data.iter().zip(&data).enumerate() { + assert_eq!(got, if was == 3567 { 4095 } else { was }, "sample {i}"); + } +} + #[test] fn gain_map_opcode_is_applied() { // Uniform 0.25 grey; a gain map that doubles everything over the whole image. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 21fc1a92a4..d1df6bc20e 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -59,7 +59,7 @@ fn inflate_font(deflated: &[u8]) -> Vec { /// Families tried (if installed) after the requested one, for missing glyphs. The Southeast Asian /// and CJK families ([`THAI_FAMILIES`], [`KHMER_FAMILIES`], [`LAO_FAMILIES`], [`MYANMAR_FAMILIES`], -/// [`crate::cjk::families`]) go between these two lists, in the UI locale's script order. +/// [`INDIC_FAMILIES`], [`crate::cjk::families`]) go between these two lists, in the UI locale's script order. const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"]; /// Thai-capable families: Windows (Leelawadee UI since Windows 8; Tahoma and Leelawadee on /// older systems), macOS (Thonburi, Sukhumvit Set) and Linux (Noto). Text in a font without Thai @@ -76,6 +76,38 @@ pub const LAO_FAMILIES: &[&str] = &["Lao Sangam MN", "Lao MN", "Lao UI", "Noto S /// Myanmar/Burmese-capable families: macOS (Myanmar Sangam MN, Myanmar MN), Windows (Myanmar Text) /// and Linux (Noto). pub const MYANMAR_FAMILIES: &[&str] = &["Myanmar Sangam MN", "Myanmar MN", "Myanmar Text", "Noto Sans Myanmar"]; +/// Indic-script families for document text. Windows has `Nirmala UI` for all nine scripts (from +/// Windows 8); macOS has the Kohinoor and Sangam MN families; Linux has Noto Sans. Indic text in a +/// font without these glyphs had no candidate to fall back to and drew .notdef boxes (#218). +pub const DEVANAGARI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Noto Sans Devanagari UI"]; +/// Bengali-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const BENGALI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Bangla", "Bangla Sangam MN", "Noto Sans Bengali", "Noto Sans Bengali UI"]; +/// Gurmukhi-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const GURMUKHI_FAMILIES: &[&str] = &["Nirmala UI", "Gurmukhi Sangam MN", "Noto Sans Gurmukhi", "Noto Sans Gurmukhi UI"]; +/// Gujarati-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const GUJARATI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Gujarati", "Gujarati Sangam MN", "Noto Sans Gujarati", "Noto Sans Gujarati UI"]; +/// Tamil-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const TAMIL_FAMILIES: &[&str] = &["Nirmala UI", "Tamil Sangam MN", "Noto Sans Tamil", "Noto Sans Tamil UI"]; +/// Telugu-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const TELUGU_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Telugu", "Telugu Sangam MN", "Noto Sans Telugu", "Noto Sans Telugu UI"]; +/// Kannada-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const KANNADA_FAMILIES: &[&str] = &["Nirmala UI", "Kannada Sangam MN", "Noto Sans Kannada", "Noto Sans Kannada UI"]; +/// Malayalam-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const MALAYALAM_FAMILIES: &[&str] = &["Nirmala UI", "Malayalam Sangam MN", "Noto Sans Malayalam", "Noto Sans Malayalam UI"]; +/// Sinhala-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const SINHALA_FAMILIES: &[&str] = &["Nirmala UI", "Sinhala Sangam MN", "Noto Sans Sinhala", "Noto Sans Sinhala UI"]; +/// All Indic-script families, in the order they join the fallback candidates. +pub const INDIC_FAMILIES: &[&[&str]] = &[ + DEVANAGARI_FAMILIES, + BENGALI_FAMILIES, + GURMUKHI_FAMILIES, + GUJARATI_FAMILIES, + TAMIL_FAMILIES, + TELUGU_FAMILIES, + KANNADA_FAMILIES, + MALAYALAM_FAMILIES, + SINHALA_FAMILIES, +]; /// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get /// the locale's forms instead of Arial Unicode's. const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"]; @@ -87,6 +119,14 @@ pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static s v.extend_from_slice(KHMER_FAMILIES); v.extend_from_slice(LAO_FAMILIES); v.extend_from_slice(MYANMAR_FAMILIES); + for fams in INDIC_FAMILIES { + for f in *fams { + // Nirmala UI covers all nine scripts and is listed by each; probe it once. + if !v.contains(f) { + v.push(f); + } + } + } for s in order { // craft-fonts' Japanese fonts (if built in) go ahead of the installed Japanese fonts, in // the Japanese slot of the locale order, so shared Han keeps the locale's forms. @@ -109,6 +149,16 @@ pub fn generation() -> u64 { GENERATION.load(Ordering::Relaxed) } +/// Bumped only by the [`FontDb`] of [`crate::shared`], so caches of the shared engine's fonts +/// aren't invalidated by unrelated databases (a document's own engine, a test's throwaway one). +static SHARED_GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Changes whenever font data is registered in the shared engine's [`FontDb`] ([`crate::shared`]). +/// Font menus key their caches on this, not [`generation`]. +pub fn shared_generation() -> u64 { + SHARED_GENERATION.load(Ordering::Relaxed) +} + /// One face in the database (for font menus). #[derive(Clone, Debug, PartialEq)] pub struct FaceInfo { @@ -145,6 +195,8 @@ pub struct FontDb { missing_glyph_protection: bool, face_cache: HashMap>, face_aliases: HashMap<(String, usize), String>, + /// This is the shared engine's database: registrations also move [`shared_generation`]. + shared: bool, } impl Default for FontDb { @@ -165,6 +217,7 @@ impl FontDb { missing_glyph_protection: true, face_cache: HashMap::new(), face_aliases: HashMap::new(), + shared: false, }; for (_, bytes) in BUNDLED { db.register_static_font(bytes.as_slice()); @@ -246,9 +299,19 @@ impl FontDb { self.face_cache.clear(); self.refresh_generics(); GENERATION.fetch_add(1, Ordering::Relaxed); + if self.shared { + SHARED_GENERATION.fetch_add(1, Ordering::Relaxed); + } names } + /// Marks this as the shared engine's database ([`crate::shared`]) and moves + /// [`shared_generation`], since its fonts replace whatever caches saw before. + pub(crate) fn mark_shared(&mut self) { + self.shared = true; + SHARED_GENERATION.fetch_add(1, Ordering::Relaxed); + } + fn refresh_generics(&mut self) { let c = &mut self.fcx.collection; if let Some(inter) = c.family_id(DEFAULT_FAMILY) { @@ -523,9 +586,37 @@ pub fn face_count(bytes: &[u8]) -> usize { /// Heuristic PostScript-name split: `MyriadPro-BoldIt` → ("Myriad Pro", 700, italic). pub fn guess_from_postscript(ps: &str) -> ResolvedFont { - let (fam, style) = ps.split_once('-').unwrap_or((ps, "")); - let fam = fam.trim_end_matches("MT").trim_end_matches("PS").trim_end_matches("Std").trim_end_matches("Pro"); - let pro = ps.split_once('-').map_or(ps, |p| p.0); + // A vendor or family name may contain hyphens; start with the final face suffix. + let (mut pro, mut style) = ps.rsplit_once('-').unwrap_or((ps, "")); + // Keep compound style suffixes the old heuristic understood, such as Bold-It. + // Regular alone must not consume a family component named Bold or Black. + if matches!(style.to_ascii_lowercase().as_str(), "it" | "italic" | "oblique") + && let Some((family, weight)) = pro.rsplit_once('-') + && matches!( + weight.to_ascii_lowercase().as_str(), + "thin" + | "hairline" + | "extralight" + | "ultralight" + | "light" + | "regular" + | "roman" + | "book" + | "medium" + | "semibold" + | "demibold" + | "demi" + | "bold" + | "extrabold" + | "ultrabold" + | "heavy" + | "black" + ) + { + pro = family; + style = ps.strip_prefix(family).and_then(|s| s.strip_prefix('-')).unwrap_or(style); + } + let fam = pro.trim_end_matches("MT").trim_end_matches("PS").trim_end_matches("Std").trim_end_matches("Pro"); let suffix = if pro.ends_with("Pro") { " Pro" } else if pro.ends_with("Std") { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fdbb8a01f..720347d622 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -483,7 +483,8 @@ impl Layouter { let text = &t.text; let (box_rect, is_box) = match t.shape { TextShape::Box { x, y, width, height } => ((x, y, width, height), true), - TextShape::Point => ((0.0, 0.0, 0.0, 0.0), false), + // Path text is laid out as one point-text line, then bent onto the path. + TextShape::Point | TextShape::Path { .. } => ((0.0, 0.0, 0.0, 0.0), false), }; let mut prev_baseline: Option = None; let mut pending_space = 0.0f32; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index da323b5a22..7909199a6f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -125,7 +125,7 @@ impl TextEngine { /// Lays out and rasterizes a text layer into document space. pub fn render(&mut self, layer: &TextLayer, dpi: f32, format: PixelFormat) -> (TextLayout, Rendered) { let l = self.layout(layer, dpi); - let warp = render::layout_warp(&l, layer.warp.as_ref()); + let warp = render::layout_deform(&l, layer); let r = render::rasterize_warped(&l, &layer.transform, format, layer.antialias, warp.as_ref()); (l, r) } @@ -147,7 +147,9 @@ fn uses_system_fonts() -> bool { } fn new_shared() -> std::sync::Mutex { - std::sync::Mutex::new(if uses_system_fonts() { TextEngine::with_system_fonts() } else { TextEngine::new() }) + let mut engine = if uses_system_fonts() { TextEngine::with_system_fonts() } else { TextEngine::new() }; + engine.fonts.mark_shared(); + std::sync::Mutex::new(engine) } /// A process-wide engine for callers without their own (bundled fonts only on the web; bundled @@ -165,6 +167,43 @@ pub fn try_shared() -> Option<&'static std::sync::Mutex> { SHARED.try_get(new_shared, uses_system_fonts()) } +/// Preferences › Type › Enable Missing Glyph Protection for [`shared`]: 0 = not set yet, +/// 1 = off, 2 = on. Read under the engine's lock, so the newest setting always wins. +static WANT_GLYPH_PROTECTION: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); +/// A thread is already waiting for the font scan to apply [`WANT_GLYPH_PROTECTION`]. +static GLYPH_PROTECTION_WAITING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn apply_glyph_protection(engine: &std::sync::Mutex) { + let mut e = engine.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match WANT_GLYPH_PROTECTION.load(std::sync::atomic::Ordering::SeqCst) { + 1 => e.fonts.set_missing_glyph_protection(false), + 2 => e.fonts.set_missing_glyph_protection(true), + _ => {} + } +} + +/// Sets [`shared`]'s missing-glyph protection without waiting for the system font scan (#4519): +/// applied now when the engine is built, otherwise by a helper thread once the scan finishes. +/// Called from the UI thread at launch and on every preferences change, where [`shared`] would +/// block until the scan is done. +pub fn set_shared_missing_glyph_protection(on: bool) { + WANT_GLYPH_PROTECTION.store(if on { 2 } else { 1 }, std::sync::atomic::Ordering::SeqCst); + if let Some(engine) = try_shared() { + apply_glyph_protection(engine); + return; + } + if GLYPH_PROTECTION_WAITING.swap(true, std::sync::atomic::Ordering::SeqCst) { + // The waiting thread reads the newest setting when it applies it. + return; + } + let spawned = std::thread::Builder::new().name("photocraft-glyph-protection".into()).spawn(|| apply_glyph_protection(shared())); + if spawned.is_err() { + // No thread: apply it here, as before. + GLYPH_PROTECTION_WAITING.store(false, std::sync::atomic::Ordering::SeqCst); + apply_glyph_protection(shared()); + } +} + /// Starts building [`shared`] (the system font scan) on a background thread, so the Type tool's /// font menus don't stall the UI when they first draw. Call once at startup; calling again does /// nothing. diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 27b1909510..3f8ce72902 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -244,7 +244,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let m = arr_f(frame.path(&["2", "2"])); let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; let at = match layer.shape { - TextShape::Point => { + TextShape::Point | TextShape::Path { .. } => { let p = arr_f(frame.get("0")); if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY } } @@ -260,7 +260,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { // the frame folded into its transform and its `bounds` near the origin: applying the frame // again would move it twice. let origin = match layer.shape { - TextShape::Point => Point::new(0.0, 0.0), + TextShape::Point | TextShape::Path { .. } => Point::new(0.0, 0.0), TextShape::Box { .. } => { let b = engine_data(&t.text) .map(|e| { @@ -584,7 +584,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C let num = |key: &str| g(key).and_then(E::as_f64); let flag = |key: &str| g(key).and_then(E::as_bool); let mut s = CharStyle::default(); - if let Some(ps) = g("Font").and_then(E::as_i64).and_then(|i| fonts.get(i as usize)) { + if let Some(ps) = g("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)) { let r = guess_from_postscript(ps); s.font_family = r.family; s.weight = r.weight; @@ -1183,7 +1183,8 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E dict.set("UseFractionalGlyphWidths", E::Bool(true)); } let (shape_type, box_bounds) = match layer.shape { - TextShape::Point => (0, None), + // PSD path type is not written yet: the layer saves as point text. + TextShape::Point | TextShape::Path { .. } => (0, None), TextShape::Box { x, y, width, height } => (1, Some([x, y, x + width, y + height])), }; let mut photoshop = E::Dict(vec![("ShapeType".into(), E::Int(shape_type))]); @@ -1296,8 +1297,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec }, ), ); - let [l, t, r, b] = match (layer.shape, ink) { - (TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], + let [l, t, r, b] = match (&layer.shape, ink) { + (&TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], (_, Some(i)) => i, _ => [0.0; 4], }; diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index 4caba32115..7c4f69cd8a 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -38,7 +38,12 @@ pub struct PsdStyleSheets { } fn k_of(dpi: f32) -> f32 { - 72.0 / if dpi > 0.0 { dpi } else { 72.0 } + 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 } +} + +/// A sheet index from EngineData. A negative or overflowing index is not a slot (#4429). +fn sheet_index(v: i64) -> Option { + usize::try_from(v).ok() } fn names(set: Option<&E>) -> Vec { @@ -48,7 +53,7 @@ fn names(set: Option<&E>) -> Vec { /// Index a run's sheet dictionary refers to (`Parent` index, else `Name`), skipping `normal`. fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option { let s = sheet?; - let i = s.get("Parent").and_then(E::as_i64).map(|i| i as usize).or_else(|| { + let i = s.get("Parent").and_then(E::as_i64).and_then(sheet_index).or_else(|| { let n = s.get("Name").and_then(E::as_str)?; set_names.iter().position(|x| x == n) })?; @@ -78,8 +83,8 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { let fonts: Vec = names(res.get("FontSet")); let sset = res.get("StyleSheetSet").and_then(E::as_array).unwrap_or(&[]); let pset = res.get("ParagraphSheetSet").and_then(E::as_array).unwrap_or(&[]); - let normal_c = res.get("TheNormalStyleSheet").and_then(E::as_i64).unwrap_or(0) as usize; - let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).unwrap_or(0) as usize; + let normal_c = res.get("TheNormalStyleSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0); + let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0); let empty = E::dict(); let base_data = sset.get(normal_c).and_then(|s| s.get("StyleSheetData")); let normal_char = char_style(None, base_data.unwrap_or(&empty), &fonts, k); @@ -100,7 +105,7 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { } let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string(); let props = s.get("Properties").unwrap_or(&empty); - let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).map(|i| i as usize).filter(|i| *i != normal_c); + let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(sheet_index).filter(|i| *i != normal_c); let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(base_data, d.get("StyleSheetData").unwrap_or(&empty), &fonts, k)); out.para_sheets.push((i, name, para_style(base_props, props, k), cs)); } @@ -144,8 +149,8 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d for key in ["ResourceDict", "DocumentResources"] { let Some(rd) = e.get_mut(key) else { continue }; let mut fonts = names(rd.get("FontSet")); - let normal_c = rd.get("TheNormalStyleSheet").and_then(E::as_i64).unwrap_or(0) as usize; - let normal_p = rd.get("TheNormalParagraphSheet").and_then(E::as_i64).unwrap_or(0) as usize; + let normal_c = rd.get("TheNormalStyleSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0); + let normal_p = rd.get("TheNormalParagraphSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0); let keep_c = rd.get("StyleSheetSet").and_then(E::as_array).and_then(|a| a.get(normal_c).or(a.first())).cloned(); let keep_p = rd.get("ParagraphSheetSet").and_then(E::as_array).and_then(|a| a.get(normal_p).or(a.first())).cloned(); let mut font_index = |s: &CharStyle| { @@ -250,6 +255,14 @@ mod tests { use photocraft_doc::text::{TextAlign, TextRun}; use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef, diff_attrs}; + #[test] + fn a_negative_sheet_index_is_not_a_slot_and_infinite_dpi_uses_72() { + assert_eq!(sheet_index(-1), None); + assert_eq!(sheet_index(2), Some(2)); + assert_eq!(k_of(f32::INFINITY), 1.0); + assert_eq!(k_of(144.0), 0.5); + } + #[test] fn named_sheets_round_trip() { let mut styles = TextStyles::default(); diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index acd9d51132..af29dd10d6 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -1,6 +1,6 @@ //! Rasterizes a [`TextLayout`] into a document-space [`Surface`] of any pixel format. -use photocraft_color::{Color, PixelFormat}; +use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::text::AntiAlias; use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; @@ -10,12 +10,12 @@ use skrifa::{GlyphId, MetadataProvider}; use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; -use crate::warp::Warp; +use crate::warp::{Placement, Warp}; /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; -/// Faux-bold dilation radius as a fraction of the font size. -pub const FAUX_BOLD_RADIUS: f32 = 0.018; +/// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look). +pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; @@ -53,6 +53,30 @@ impl LineSink for WarpSink<'_, S> { /// when `color` is `None`) into `sink`, through `transform` (text space → sink space), bending /// the outlines with `warp` first (in text space). fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>) { + draw_pass(layout, transform, sink, only_color, warp, Pass::All); +} + +/// Which part of the layout [`draw_pass`] emits. Faux bold draws each glyph at several offsets; +/// summing those into one coverage buffer saturates the antialiased edge pixels (hard, pixelated +/// stems), so the rasteriser draws every offset into its own buffer and merges them with `max`. +#[derive(Clone, Copy, PartialEq)] +enum Pass { + /// Everything, all offsets (bounds, outlines). + All, + /// Non-bold glyphs and decorations. + Plain, + /// Bold glyphs at one offset (index into [`BOLD_OFFSETS`]). + Bold(usize), +} + +const BOLD_OFFSETS: [(f64, f64); 8] = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)]; + +fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool { + let face_bold = layout.faces.get(g.face as usize).is_some_and(|f| f.embolden); + layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold +} + +fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) { for g in &layout.glyphs { let st = &layout.styles[g.style as usize]; if only_color.is_some_and(|c| c != &st.color) { @@ -66,15 +90,36 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); + let mut glyph = glyph_xform(layout, g, face.skew_deg); + // Type on a path moves each glyph rigidly: turned to the curve at its centre. + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } let bold = st.faux_bold || face.embolden; let r = (face.size_px * FAUX_BOLD_RADIUS) as f64; - let offsets: &[(f64, f64)] = - if bold { &[(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] } else { &[(0.0, 0.0)] }; + let single = [(0.0, 0.0)]; + let offsets: &[(f64, f64)] = match (pass, bold) { + (Pass::Plain, true) => continue, + (Pass::Bold(_), false) => continue, + (Pass::Bold(k), true) => match BOLD_OFFSETS.get(k..=k) { + Some(o) => o, + None => continue, + }, + (Pass::All, true) => &BOLD_OFFSETS, + (_, false) => &single, + }; for &(ox, oy) in offsets { let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r])); let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); - match warp { + match bend { Some(w) => { let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; let mut pen = Pen::new(&mut ws, local); @@ -91,6 +136,9 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c } } } + if matches!(pass, Pass::Bold(_)) { + return; + } for d in &layout.decorations { let st = &layout.styles[d.style as usize]; if only_color.is_some_and(|c| c != &st.color) { @@ -125,6 +173,22 @@ fn glyph_xform(layout: &TextLayout, g: &PlacedGlyph, face_skew_deg: f32) -> Xfor } } +/// The horizontal centre (text space) of a placed glyph: the pen position plus half its advance. +fn glyph_center(layout: &TextLayout, g: &PlacedGlyph, font: &skrifa::FontRef, size_px: f32, coords: &[NormalizedCoord]) -> f64 { + let hs = layout.styles.get(g.style as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); + let advance = match g.orient { + GlyphOrient::Horizontal => font.glyph_metrics(Size::new(size_px), LocationRef::new(coords)).advance_width(GlyphId::new(g.id)).unwrap_or(0.0), + _ => 0.0, + }; + f64::from(g.x) + f64::from(advance * hs) / 2.0 +} + +/// The deformation of a type layer: the path it runs along, or its Warp Text settings (None +/// when neither applies). +pub fn layout_deform(layout: &TextLayout, layer: &photocraft_doc::TextLayer) -> Option { + Warp::on_path(&layer.shape).or_else(|| layout_warp(layout, layer.warp.as_ref())) +} + /// The warp to apply to `layout` (None when `warp` is absent, `warpNone` or flat). pub fn layout_warp(layout: &TextLayout, warp: Option<&photocraft_doc::text::TextWarp>) -> Option { Warp::new(warp?, layout.bounds()?) @@ -199,8 +263,17 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } for c in &colors { let mut cov = Coverage::new(w, h); - draw(layout, &xf, &mut cov, Some(c), warp); - let cov = cov.finish(); + draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); + let mut cov = cov.finish(); + if layout.glyphs.iter().any(|g| is_bold(layout, g)) { + for k in 0..BOLD_OFFSETS.len() { + let mut bc = Coverage::new(w, h); + draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k)); + for (a, b) in cov.iter_mut().zip(bc.finish()) { + *a = a.max(b); + } + } + } let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); for (i, &cv) in cov.iter().enumerate() { @@ -222,7 +295,10 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo let a = px[n]; if a > 0.0 { for v in &mut px[..n] { - *v = (*v / a).clamp(0.0, 1.0); + let straight = *v / a; + // Float text can carry HDR and signed colours. Keep integer output bounded + // and retain the old handling of non-finite intermediate values. + *v = if format.sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; } } } @@ -299,8 +375,19 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); - let mut rec = Recorder { glyph, post: Xform(transform.m), warp, out: Vec::new() }; + let mut glyph = glyph_xform(layout, g, face.skew_deg); + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } + let mut rec = Recorder { glyph, post: Xform(transform.m), warp: bend, out: Vec::new() }; let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() { glyphs.push(rec.out); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ae3092ca46..47be8f3880 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -13,6 +13,44 @@ fn styled(text: &str, style: CharStyle) -> TextLayer { TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } +#[test] +fn raster_preserves_hdr_and_signed_float_text_colors() { + let mut e = TextEngine::new(); + for alpha in [1.0, 0.4] { + let color = Color::rgba(2.5, -0.25, 0.5, alpha); + let style = CharStyle { size_pt: 48.0, color, ..Default::default() }; + let (_, rendered) = e.render(&styled("HDR", style), 72.0, PixelFormat::RGBA32F); + let pixels = rendered.surface.read_region(rendered.rect); + let mut covered = 0; + for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { + covered += 1; + for channel in 0..3 { + assert!((pixel[channel] - color.c[channel]).abs() < 1e-5, "alpha={alpha}, pixel={pixel:?}"); + } + assert!(pixel[3] <= alpha + 1e-6); + } + assert!(covered > 100); + } +} + +#[test] +fn raster_integer_text_colors_stay_bounded() { + let mut e = TextEngine::new(); + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + let style = CharStyle { size_pt: 48.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }; + let (_, rendered) = e.render(&styled("HDR", style), 72.0, format); + let pixels = rendered.surface.read_region(rendered.rect); + let mut covered = 0; + for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { + covered += 1; + assert_eq!(&pixel[..2], &[1.0, 0.0]); + assert!((pixel[2] - 0.5).abs() < 1.0 / 255.0); + assert!(pixel[3] <= 0.4 + 1.0 / 255.0); + } + assert!(covered > 100); + } +} + fn with_para(mut t: TextLayer, p: ParagraphStyle) -> TextLayer { t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: p }]; t @@ -47,6 +85,21 @@ fn registering_fonts_moves_the_generation() { assert!(fonts::generation() > g); } +/// Font menus cache on the shared engine's generation: other engines registering fonts (a test's +/// throwaway one, in parallel) must not invalidate them, or the menus re-render every frame. +#[test] +fn only_the_shared_database_moves_the_shared_generation() { + let _ = crate::shared(); + let g = fonts::shared_generation(); + TextEngine::new().fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); + assert_eq!(fonts::shared_generation(), g, "an unrelated engine leaves it alone"); + let mut e = TextEngine::new(); + e.fonts.mark_shared(); + let g = fonts::shared_generation(); + e.fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); + assert!(fonts::shared_generation() > g); +} + #[test] fn literal_psd_tabs_shape_as_whitespace_without_shifting_text_offsets() { let mut e = TextEngine::new(); @@ -99,7 +152,7 @@ fn tabs_advance_to_the_next_default_tab_stop() { #[test] fn psd_round_trips_tabs() { for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }] { - let t = TextLayer { shape, ..point("Name\tValue\n\tIndented", 20.0) }; + let t = TextLayer { shape: shape.clone(), ..point("Name\tValue\n\tIndented", 20.0) }; let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap(); assert_eq!(back.text, t.text, "{shape:?}"); } @@ -381,6 +434,41 @@ fn styles_change_pixels() { assert!(hrect.width() as f32 > prect.width() as f32 * 1.7); } +/// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one +/// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the +/// ink must grow only by the dilation (about perimeter x radius), not by edge saturation. +#[test] +fn faux_bold_keeps_antialiased_edges_at_small_sizes() { + let mut e = TextEngine::new(); + let base = CharStyle { size_pt: 12.0, ..Default::default() }; + let stats = |e: &mut TextEngine, s: CharStyle| { + let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 20.0), ..styled("Bold Test", s) }, 72.0, PixelFormat::RGBA8); + let (w, h) = (r.rect.width() as usize, r.rect.height() as usize); + let mut partial = 0usize; + for y in 0..h { + for x in 0..w { + let a = alpha_sum( + &r.surface, + photocraft_geom::Rect::new(r.rect.x0 + x as i32, r.rect.y0 + y as i32, r.rect.x0 + x as i32 + 1, r.rect.y0 + y as i32 + 1), + ); + if a > 0.05 && a < 0.95 { + partial += 1; + } + } + } + (alpha_sum(&r.surface, r.rect), partial) + }; + let (plain_ink, plain_partial) = stats(&mut e, base.clone()); + let (bold_ink, bold_partial) = stats(&mut e, CharStyle { faux_bold: true, ..base }); + assert!(bold_ink > plain_ink, "bold must add ink"); + // Weight: the old summed-coverage faux bold (before #4130) measured 282.7 total ink for this + // text at 12 px (plain 138.0). The antialiased version must stay close to that weight. + const MAIN_BOLD_INK: f64 = 282.7; + assert!(bold_ink >= MAIN_BOLD_INK * 0.85, "stems too thin: {bold_ink} vs old {MAIN_BOLD_INK}"); + assert!(bold_ink <= MAIN_BOLD_INK * 1.15, "stems too heavy: {bold_ink} vs old {MAIN_BOLD_INK}"); + assert!(bold_partial * 10 >= plain_partial * 8, "edges stay antialiased: {plain_partial} -> {bold_partial}"); +} + /// #3705: Faux bold dilation must scale with horizontal and vertical glyph scaling. #[test] fn faux_bold_scales_with_glyph_scaling() { @@ -540,6 +628,28 @@ fn forced_line_break_starts_a_line_in_the_same_paragraph() { } } +#[test] +fn hyphenated_postscript_names_preserve_family_prefixes() { + for (ps, family, weight, italic) in + [("FOT-NewRodinPro-Regular", "FOT-New Rodin Pro", 400, false), ("Vendor-FamilyPro-BoldIt", "Vendor-Family Pro", 700, true)] + { + let guessed = fonts::guess_from_postscript(ps); + assert_eq!((guessed.family.as_str(), guessed.weight, guessed.italic), (family, weight, italic)); + } +} + +#[test] +fn hyphenated_postscript_style_suffix_keeps_weight_and_slant() { + for (ps, family, weight, italic) in [ + ("MyriadPro-Bold-It", "Myriad Pro", 700, true), + ("FOT-NewRodinPro-Bold-It", "FOT-New Rodin Pro", 700, true), + ("Vendor-Bold-Regular", "Vendor-Bold", 400, false), + ] { + let guessed = fonts::guess_from_postscript(ps); + assert_eq!((guessed.family.as_str(), guessed.weight, guessed.italic), (family, weight, italic)); + } +} + #[test] fn postscript_names() { let g = fonts::guess_from_postscript("MyriadPro-BoldIt"); @@ -1567,6 +1677,59 @@ fn southeast_asian_fallback_candidates_cover_every_platform() { } } +/// The Indic scripts of document text, with conjuncts, matras and viramas (#218). +const INDIC: &[(&str, &str, &[&str])] = &[ + ("Devanagari", "हिन्दी भाषा क्षत्रिय श्री", fonts::DEVANAGARI_FAMILIES), + ("Bengali", "বাংলা ভাষা ক্ষত্রিয় শ্রী", fonts::BENGALI_FAMILIES), + ("Gurmukhi", "ਪੰਜਾਬੀ ਭਾਸ਼ਾ ਸਤਿ ਸ੍ਰੀ", fonts::GURMUKHI_FAMILIES), + ("Gujarati", "ગુજરાતી ભાષા ક્ષત્રિય શ્રી", fonts::GUJARATI_FAMILIES), + ("Tamil", "தமிழ் மொழி ஸ்ரீ கொடை", fonts::TAMIL_FAMILIES), + ("Telugu", "తెలుగు భాష క్షత్రియ శ్రీ", fonts::TELUGU_FAMILIES), + ("Kannada", "ಕನ್ನಡ ಭಾಷೆ ಕ್ಷತ್ರಿಯ ಶ್ರೀ", fonts::KANNADA_FAMILIES), + ("Malayalam", "മലയാളം ഭാഷ ക്ഷത്രിയ ശ്രീ", fonts::MALAYALAM_FAMILIES), + ("Sinhala", "සිංහල භාෂාව ක්‍ෂත්‍රිය ශ්‍රී", fonts::SINHALA_FAMILIES), +]; + +#[test] +fn indic_scripts_fall_back_to_installed_fonts() { + for (name, sample, families) in INDIC { + let mut e = TextEngine::with_system_fonts(); + let Some(hit) = families.iter().find(|f| e.fonts.has_family(f)) else { + eprintln!("skipped: no {name}-capable font installed"); + continue; + }; + let direct = TextLayer { text: (*sample).into(), font_family: hit.to_string(), size_pt: 24.0, ..Default::default() }; + let l = e.layout(&direct, 72.0); + assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{hit} is installed but shapes .notdef for {name}"); + let mixed = format!("Latin {sample}"); + let l = e.layout(&point(&mixed, 24.0), 72.0); + assert!(l.glyphs.iter().all(|g| g.id != 0), "{name} in a Latin font drawn with .notdef although {hit} is installed"); + let latin = l.glyphs.first().map(|g| g.face); + assert!(l.glyphs.iter().any(|g| Some(g.face) != latin), "Latin and {name} drawn with the same face"); + } +} + +#[test] +fn indic_fallback_candidates_cover_every_platform() { + // Logic-level half (runs without these fonts): every script has a Windows, macOS and Linux + // family in the candidates, ahead of the broad last-resort fonts; the shared Nirmala UI is + // probed once. + for order in [crate::cjk::script_order(None), crate::cjk::script_order(Some("ja"))] { + let fb = fonts::fallback_candidates(&order); + let last = fb.iter().position(|f| *f == "Arial Unicode MS").unwrap(); + assert_eq!(fb.iter().filter(|f| **f == "Nirmala UI").count(), 1, "{fb:?}"); + for (name, _, families) in INDIC { + assert!(families.contains(&"Nirmala UI"), "{name}"); + assert!(families.iter().any(|f| f.ends_with("Sangam MN") || f.starts_with("Kohinoor")), "{name}: no macOS family"); + assert!(families.iter().any(|f| f.starts_with("Noto Sans ")), "{name}: no Linux family"); + for fam in *families { + let i = fb.iter().position(|f| f == fam).unwrap_or_else(|| panic!("{fam} missing from {fb:?}")); + assert!(i < last, "{fam} after the last-resort fonts"); + } + } + } +} + #[test] fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else { @@ -1697,6 +1860,21 @@ fn missing_glyph_protection_can_turn_the_fallback_stack_off() { assert!(db.fallback_stack().any(|f| f == "Cairo"), "turning it back on restores the coverage fonts"); } +/// The shared engine's missing-glyph protection follows the setting without waiting for the +/// system font scan (#4519). Under test the shared engine has no scan, so it applies at once; +/// the newest setting wins. +#[test] +fn shared_missing_glyph_protection_follows_the_setting() { + let read = || crate::shared().lock().unwrap_or_else(std::sync::PoisonError::into_inner).fonts.missing_glyph_protection(); + crate::set_shared_missing_glyph_protection(false); + assert!(!read()); + crate::set_shared_missing_glyph_protection(true); + crate::set_shared_missing_glyph_protection(false); + assert!(!read(), "the newest setting wins"); + crate::set_shared_missing_glyph_protection(true); + assert!(read()); +} + #[test] fn translate_tysh_moves_only_the_translation() { let mut t = point("Hello!", 26.0); @@ -1751,3 +1929,65 @@ fn engine_data_antialias_numbers_match_photoshop() { assert_eq!(back.antialias, mode, "{n}"); } } + +/// Type on a path (#218): along a vertical line the type runs top to bottom with its glyphs +/// turned, along a curve it follows the curve, and glyphs beyond the end are not drawn. +#[test] +fn type_on_a_path_follows_the_curve() { + use photocraft_doc::vector::{Knot, Subpath}; + let fmt = PixelFormat::RGBA8; + let mut eng = TextEngine::new(); + let ink = |eng: &mut TextEngine, t: &TextLayer| { + let (_, r) = eng.render(t, 72.0, fmt); + r.rect + }; + let straight = point("Path text", 24.0); + let flat = ink(&mut eng, &straight); + assert!(flat.width() > flat.height() * 3, "{flat:?}"); + + let mut down = straight.clone(); + down.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (0.0, 400.0)]), start: 0.0, flip: false }; + let r = ink(&mut eng, &down); + assert!(r.height() > r.width() * 3, "vertical path gives tall ink: {r:?}"); + assert!(r.height() >= flat.width() - 8, "arc length is preserved: {r:?} vs {flat:?}"); + + // A half circle bends the text through a larger area than the straight line. + let k = 0.5523 * 60.0; + let arc = Subpath { + closed: false, + knots: vec![ + Knot { + anchor: photocraft_geom::Point::new(-60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(-60.0, 0.0), + out_ctrl: photocraft_geom::Point::new(-60.0, -k), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(0.0, -60.0), + in_ctrl: photocraft_geom::Point::new(-k, -60.0), + out_ctrl: photocraft_geom::Point::new(k, -60.0), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(60.0, -k), + out_ctrl: photocraft_geom::Point::new(60.0, 0.0), + smooth: true, + }, + ], + ..Default::default() + }; + let mut curved = straight.clone(); + curved.shape = TextShape::Path { path: arc.clone(), start: 0.0, flip: false }; + let c = ink(&mut eng, &curved); + assert!(c.height() > flat.height() * 2, "curved ink is taller: {c:?} vs {flat:?}"); + + // A path too short for the text hides the glyphs past its end. + let mut short = straight.clone(); + short.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (30.0, 0.0)]), start: 0.0, flip: false }; + assert!(ink(&mut eng, &short).width() < flat.width() / 2); + // A degenerate path falls back to straight point text instead of failing. + let mut dot = straight.clone(); + dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false }; + assert_eq!(ink(&mut eng, &dot), flat); +} diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index e056bdff33..f148a18e1b 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -6,7 +6,8 @@ //! formulas), the identity at `bend = 0` and continuous in `bend`. The mapping is evaluated per //! point; the renderer subdivides long segments so straight edges bend. -use photocraft_doc::text::TextWarp; +use photocraft_doc::text::{TextShape, TextWarp}; +use photocraft_doc::vector::Subpath; use photocraft_geom::warp::{StyleWarp, WarpStyle}; /// Warp styles in Photoshop's menu order: (PSD `warpStyle` value, short id used by commands). @@ -45,10 +46,29 @@ pub fn short_style(psd: &str) -> Option<&'static str> { STYLES.iter().find(|(p, _)| *p == psd).map(|(_, short)| *short) } +/// Where a glyph goes when the layout is bent: see [`Warp::place_glyph`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Placement { + /// Not path text: bend the outline point by point with [`Warp::apply`]. + Bend, + /// Beyond either end of the path: the glyph is not drawn. + Hidden, + /// Path text: the glyph keeps its shape and is moved rigidly by this text-space map + /// (`[a, b, c, d, e, f]`, applied to text-space points). + Frame([f64; 6]), +} + /// A warp prepared for one layout: the shared style math of -/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box. -#[derive(Clone, Copy, Debug)] -pub struct Warp(StyleWarp); +/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box, +/// or a path the type runs along. +#[derive(Clone, Debug)] +pub struct Warp(Kind); + +#[derive(Clone, Debug)] +enum Kind { + Style(Box), + Path(PathFrame), +} impl Warp { /// Prepares `w` for text whose bounds are `[x0, y0, x1, y1]` (text space). `None` for @@ -56,17 +76,117 @@ impl Warp { pub fn new(w: &TextWarp, bounds: [f32; 4]) -> Option { let style = WarpStyle::parse(&w.style).filter(|s| s.is_preset() && s.psd_name() == w.style)?; StyleWarp::new(style, f64::from(w.value), f64::from(w.horizontal_distortion), f64::from(w.vertical_distortion), !w.horizontal, bounds.map(f64::from)) - .map(Warp) + .map(|s| Warp(Kind::Style(Box::new(s)))) + } + + /// The deformation of type on a path (`None` for any other shape, or a path without length). + pub fn on_path(shape: &TextShape) -> Option { + let TextShape::Path { path, start, flip } = shape else { return None }; + PathFrame::new(path, *start, *flip).map(|f| Warp(Kind::Path(f))) } /// Longest segment (text-space px) worth sending through [`Warp::apply`] unsplit. pub fn max_segment(&self) -> f64 { - self.0.max_segment() + match &self.0 { + Kind::Style(s) => s.max_segment(), + Kind::Path(_) => 2.0, + } } /// Maps a text-space point. pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { - self.0.apply(x, y) + match &self.0 { + Kind::Style(s) => s.apply(x, y), + Kind::Path(p) => p.apply(x, y), + } + } + + /// How to place a glyph whose horizontal centre is at text-space `cx`. + pub fn place_glyph(&self, cx: f64) -> Placement { + match &self.0 { + Kind::Style(_) => Placement::Bend, + Kind::Path(p) => p.frame(cx).map_or(Placement::Hidden, Placement::Frame), + } + } +} + +/// Most cubic segments of a type path that are followed (a hostile file cannot make this large). +const MAX_PATH_SEGMENTS: usize = 4096; + +/// A flattened path with arc lengths: text-space `x` is arc length from `start`, `y` the +/// distance to the left of the direction of travel (the side the glyph tops point to). +#[derive(Clone, Debug)] +struct PathFrame { + pts: Vec<(f64, f64)>, + /// Arc length at each point. + cum: Vec, + start: f64, +} + +impl PathFrame { + fn new(path: &Subpath, start: f32, flip: bool) -> Option { + let mut pts: Vec<(f64, f64)> = Vec::new(); + for seg in path.segments().iter().take(MAX_PATH_SEGMENTS) { + let p = seg.map(|q| (q.x, q.y)); + if p.iter().any(|q| !q.0.is_finite() || !q.1.is_finite()) { + return None; + } + let poly = (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1) + (p[2].0 - p[1].0).hypot(p[2].1 - p[1].1) + (p[3].0 - p[2].0).hypot(p[3].1 - p[2].1); + let n = ((poly / 2.0).ceil() as usize).clamp(4, 128); + if pts.is_empty() { + pts.push(p[0]); + } + for i in 1..=n { + let t = i as f64 / n as f64; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + pts.push((a * p[0].0 + b * p[1].0 + c * p[2].0 + d * p[3].0, a * p[0].1 + b * p[1].1 + c * p[2].1 + d * p[3].1)); + } + } + // Drop repeated points so every piece has a direction. + pts.dedup_by(|b, a| (b.0 - a.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9); + if flip { + pts.reverse(); + } + let mut cum = Vec::with_capacity(pts.len()); + let mut acc = 0.0; + for (i, p) in pts.iter().enumerate() { + if let Some(q) = i.checked_sub(1).and_then(|j| pts.get(j)) { + acc += (p.0 - q.0).hypot(p.1 - q.1); + } + cum.push(acc); + } + (pts.len() >= 2 && acc.is_finite() && acc > 1e-6 && start.is_finite()).then(|| PathFrame { pts, cum, start: f64::from(start) }) + } + + fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Point and unit tangent at arc length `s`; past either end the end tangent continues straight. + fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) { + let last = self.pts.len() - 1; + let i = self.cum.partition_point(|&c| c < s).clamp(1, last); + let (a, b) = (self.pts[i - 1], self.pts[i]); + let len = (b.0 - a.0).hypot(b.1 - a.1).max(1e-12); + let t = ((b.0 - a.0) / len, (b.1 - a.1) / len); + let along = s - self.cum[i - 1]; + ((a.0 + t.0 * along, a.1 + t.1 * along), t) + } + + fn apply(&self, x: f64, y: f64) -> (f64, f64) { + let (p, t) = self.at(self.start + x); + (p.0 - t.1 * y, p.1 + t.0 * y) + } + + /// The rigid map for a glyph centred at text-space `cx`; `None` outside the path. + fn frame(&self, cx: f64) -> Option<[f64; 6]> { + let s = self.start + cx; + if !(0.0..=self.length()).contains(&s) { + return None; + } + let (p, t) = self.at(s); + Some([t.0, t.1, -t.1, t.0, p.0 - t.0 * cx, p.1 - t.1 * cx]) } } diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 7902a39bba..e0f3713f2e 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -37,10 +37,12 @@ struct RenameAction { focused: bool, } -fn label_of(id: &str) -> String { +pub(crate) fn label_of(id: &str) -> String { photocraft_engine::commands::find(id) .map(|c| c.label) .or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1)) + // Shell menu items recorded as steps, like Save a Copy (#4373). + .or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).map(|c| c.1)) .map(|label| label.trim_end_matches('…').to_string()) .unwrap_or_else(|| id.to_string()) } diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index a9235881ea..5f43348507 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -209,6 +209,9 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Self { - Edit { changed, commit: changed } + Edit { changed, commit: changed, ..Default::default() } } fn of(r: &egui::Response) -> Self { - Edit { changed: r.changed(), commit: r.drag_stopped() || (r.changed() && !r.dragged()) } + Edit { changed: r.changed(), commit: r.drag_stopped() || (r.changed() && !r.dragged()), ..Default::default() } } } @@ -115,18 +118,18 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Ed ui, v, &[ - ("exposure", tl!("Exposure"), -20.0, 20.0, 0.0, ""), - ("offset", tl!("Offset"), -0.5, 0.5, 0.0, ""), - ("gamma", tl!("Gamma Correction"), 0.01, 9.99, 1.0, ""), + ("exposure", tl!("Exposure"), -20.0, 20.0, 0.0, "", Some(2)), + ("offset", tl!("Offset"), -0.5, 0.5, 0.0, "", Some(4)), + ("gamma", tl!("Gamma Correction"), 0.01, 9.99, 1.0, "", None), ], ), - "vibrance" => sliders(ui, v, &[("vibrance", "Vibrance", -100.0, 100.0, 0.0, "%"), ("saturation", "Saturation", -100.0, 100.0, 0.0, "%")]), + "vibrance" => sliders(ui, v, &[("vibrance", "Vibrance", -100.0, 100.0, 0.0, "%", None), ("saturation", "Saturation", -100.0, 100.0, 0.0, "%", None)]), "hueSaturation" => hue_saturation(ui, v, cx), "colorBalance" => color_balance(ui, v, cx), "blackWhite" => black_white(ui, v, cx), "photoFilter" => photo_filter(ui, v, cx), "channelMixer" => channel_mixer(ui, v, cx), - "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "")]), + "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "", None)]), "threshold" => threshold(ui, v, cx), "gradientMap" => gradient_map(ui, v, cx), _ => Edit::default(), @@ -190,15 +193,31 @@ fn label(ui: &mut egui::Ui, text: &str) { } /// Labelled slider rows over top-level keys: (key, label, min, max, default, unit). -fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[(&str, &str, f32, f32, f32, &str)]) -> Edit { +/// A row of [`sliders`]: params key, label, range, default, unit, and its decimal places (None: +/// two for a range of 10 or less, else whole numbers). +type SliderRow<'a> = (&'a str, &'a str, f32, f32, f32, &'a str, Option); + +/// The value a [`sliders`] row stores for `x`: rounded to `decimals` places, or by its range. +pub(crate) fn row_value(x: f32, min: f32, max: f32, decimals: Option) -> f32 { + match decimals { + Some(d) => { + let scale = 10f32.powi(d.min(6) as i32); + (x * scale).round() / scale + } + None if max - min <= 10.0 => round(x, 0.01), + None => x.round(), + } +} + +fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[SliderRow]) -> Edit { let mut e = Edit::default(); - for &(key, text, min, max, default, unit) in rows { + for &(key, text, min, max, default, unit, decimals) in rows { let mut x = num(v, key, default).clamp(min, max); // A double-click on the slider puts it back to its default (Photoshop). let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None }; - let r = widgets::slider_row_with(ui, text, &mut x, min..=max, unit, None, gestures); + let r = widgets::slider_row_prec(ui, text, &mut x, min..=max, unit, None, gestures, decimals); if r.changed() { - v[key] = json!(if max - min <= 10.0 { round(x, 0.01) } else { x.round() }); + v[key] = json!(row_value(x, min, max, decimals)); } e.add(Edit::of(&r)); } @@ -222,7 +241,7 @@ fn track_stops<'a>(cx: &EditorCx, stops: &'a [Color32]) -> Option<&'a [Color32]> fn brightness_contrast(ui: &mut egui::Ui, v: &mut Value) -> Edit { let legacy = flag(v, "legacy", false); let lo = if legacy { -100.0 } else { -50.0 }; - let mut e = sliders(ui, v, &[("brightness", tl!("Brightness"), -150.0, 150.0, 0.0, ""), ("contrast", tl!("Contrast"), lo, 100.0, 0.0, "")]); + let mut e = sliders(ui, v, &[("brightness", tl!("Brightness"), -150.0, 150.0, 0.0, "", None), ("contrast", tl!("Contrast"), lo, 100.0, 0.0, "", None)]); let mut l = legacy; if widgets::checkbox(ui, &mut l, tl!("Use Legacy")).changed() { v["legacy"] = json!(l); @@ -798,7 +817,7 @@ fn threshold(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { p.line_segment([pos2(x, hr.top()), pos2(x, hr.bottom())], Stroke::new(1.0, t.accent)); p.rect_stroke(hr, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); ui.add_space(4.0); - sliders(ui, v, &[("level", tl!("Threshold Level"), 1.0, 255.0, 128.0, "")]) + sliders(ui, v, &[("level", tl!("Threshold Level"), 1.0, 255.0, 128.0, "", None)]) } // --------------------------------------------------------------------------------------------- @@ -912,7 +931,11 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { o["hue"] = json!(h.round()); } sub.add(Edit::of(&r)); - sub.add(sliders(ui, &mut o, &[("saturation", tl!("Saturation"), -100.0, 100.0, 0.0, "%"), ("lightness", tl!("Lightness"), -100.0, 100.0, 0.0, "%")])); + sub.add(sliders( + ui, + &mut o, + &[("saturation", tl!("Saturation"), -100.0, 100.0, 0.0, "%", None), ("lightness", tl!("Lightness"), -100.0, 100.0, 0.0, "%", None)], + )); if sub.changed { v[key] = o; } @@ -1143,7 +1166,7 @@ fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { e.add(r); }); ui.data_mut(|d| d.insert_temp(mode_id, use_color)); - e.add(sliders(ui, v, &[("density", tl!("Density"), 0.0, 100.0, 25.0, "%")])); + e.add(sliders(ui, v, &[("density", tl!("Density"), 0.0, 100.0, 25.0, "%", None)])); let mut pl = flag(v, "preserveLuminosity", true); if widgets::checkbox(ui, &mut pl, tl!("Preserve Luminosity")).changed() { v["preserveLuminosity"] = json!(pl); @@ -1368,6 +1391,14 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { .icon(widgets::chevron_icon) .close_behavior(close) .show_ui(ui, |ui| { + let gear = crate::icons::button(ui, "settings", 24.0, false, tl!("Gradient Preset Options")); + egui::containers::menu::SubMenu::new().show(ui, &gear, |ui| { + if ui.button(tl!("Import .GRD")).clicked() { + e.import_gradients = true; + ui.close(); + } + }); + ui.separator(); for g in &cx.gradients { ui.label(RichText::new(&g.name).color(t.text_faint)); for p in &g.items { @@ -1597,6 +1628,9 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj crate::layer_picker::controls(app, ui, id); } let e = editor(ui, kind, &mut values, &cx); + if e.import_gradients { + crate::preset_files_ui::import_gradients_dialog(app); + } if e.changed { app.live_adjust = Some((id, values.clone())); } diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 478b2de68d..9c39088bef 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -592,6 +592,7 @@ fn gradient_map_picker_offers_the_gradient_library() { let mut h = app_harness("gradientMap", "rgb", 8); // A short library so every row fits the popup: Basics, Blues and a user-saved group. let mut lib: Vec<_> = gradients::builtin().into_iter().take(2).collect(); + lib[1].items.truncate(3); lib.push(Group::new("Mine", vec![gradients::GradientPreset::new("My Teal", &["#008080", "#ffee00"])])); h.state_mut().session.presets.gradients = lib; h.run_steps(2); @@ -611,6 +612,101 @@ fn gradient_map_picker_offers_the_gradient_library() { } } +fn import_gradients_from_picker(h: &mut Harness<'_, PhotocraftApp>) { + let preset = label_rect(h, "Preset:").unwrap(); + click(h, Pos2::new(preset.right() + 60.0, preset.center().y)); + h.get_by_label("Gradient Preset Options").click(); + h.run_steps(2); + h.get_by_label("Import .GRD").click(); + h.run_steps(3); +} + +#[test] +fn gradient_map_picker_imports_grd_without_editing_the_map() { + use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop}; + let mut h = app_harness("gradientMap", "rgb", 16); + h.state_mut().session.presets.gradients.truncate(1); + let before = layer(&h); + let history = h.state().session.active().unwrap().history.past_len(); + let tool = h.state().session.presets.gradient.clone(); + let bytes = photocraft_psd::grd::write(&[GrdGradient { + name: "Imported Teal".into(), + stops: vec![ + GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([0.0, 0.5, 0.5]) }, + GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 1.0, 0.0]) }, + ], + ..Default::default() + }]); + h.state_mut().services.file_dialog = Some(Box::new(move |request, _, reply| { + assert!(matches!(request, crate::FileDialogRequest::Open { multiple: false, extensions: Some(ref exts), .. } if exts == &["grd"])); + reply.send(Some(crate::FileDialogAnswer::Contents("My Gradients.GRD".into(), bytes.clone()))); + })); + import_gradients_from_picker(&mut h); + assert_eq!(layer(&h), before, "import does not edit the adjustment"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert_eq!(h.state().session.presets.gradient, tool); + assert!(!h.state().ui.status_error); + assert!(h.state().ui.status.contains("Imported 1 gradient")); + let preset = label_rect(&h, "Preset:").unwrap(); + click(&mut h, Pos2::new(preset.right() + 60.0, preset.center().y)); + h.get_by_label("My Gradients"); + h.get_by_label("Imported Teal").click(); + h.run_steps(3); + assert_eq!(map_stops(&layer(&h).1), vec![(0.0, [0, 128, 128]), (1.0, [255, 255, 0])]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&h), before); +} + +#[test] +fn gradient_map_picker_import_cancel_and_errors_preserve_state() { + use crate::FileDialogAnswer; + for answer in [ + None, + Some(FileDialogAnswer::Contents("broken.grd".into(), b"invalid".to_vec())), + Some(FileDialogAnswer::Contents("old.grd".into(), b"8BGR\0\x03".to_vec())), + Some(FileDialogAnswer::Contents( + "noise.grd".into(), + photocraft_psd::grd::write(&[photocraft_psd::grd::GrdGradient { noise: true, ..Default::default() }]), + )), + Some(FileDialogAnswer::Contents("wrong.abr".into(), Vec::new())), + Some(FileDialogAnswer::Paths(vec!["/nonexistent/photocraft-gradients.grd".into()])), + Some(FileDialogAnswer::Unavailable("dialog unavailable".into())), + ] { + let mut h = app_harness("gradientMap", "rgb", 8); + h.state_mut().session.presets.gradients.truncate(1); + let before = layer(&h); + let presets = h.state().session.presets.gradients.clone(); + let history = h.state().session.active().unwrap().history.past_len(); + let error = answer.is_some(); + h.state_mut().services.file_dialog = Some(Box::new(move |_, _, reply| reply.send(answer.clone()))); + import_gradients_from_picker(&mut h); + assert_eq!(layer(&h), before); + assert_eq!(h.state().session.presets.gradients, presets); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert_eq!(h.state().ui.status_error, error); + if error { + assert!(!h.state().ui.status.is_empty()); + } + } +} + +#[test] +fn gradient_map_dialog_import_preserves_pending_settings() { + let mut h = app_harness("gradientMap", "rgb", 8); + let id = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.gradientMap").unwrap(); + let fields = h.state_mut().ui.dialog_mut(id).unwrap().fields.clone(); + h.state_mut().services.file_dialog = Some(Box::new(|request, _, reply| { + assert!(matches!(request, crate::FileDialogRequest::Open { extensions: Some(ref exts), .. } if exts == &["grd"])); + reply.send(None); + })); + h.run_steps(3); + import_gradients_from_picker(&mut h); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields, fields); + assert!(!h.state().file_dialog_open()); + assert!(!h.state().ui.status_error); +} + /// Document point (x, y) on screen in the app's first view. fn doc_on_screen(h: &Harness<'_, PhotocraftApp>, x: f32, y: f32) -> Pos2 { let app = h.state(); @@ -664,3 +760,89 @@ fn properties_white_point_eyedropper_edits_the_layer(kind: &str) { h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(layer(&h).1, before); } + +/// A bare `kind` editor over `params`, as the Properties panel and the dialog host it. +fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'static, serde_json::Value> { + use crate::adjust_editors::{EditorCx, editor}; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state( + move |ui, values: &mut serde_json::Value| { + let cx = EditorCx { + mem: egui::Id::new("precision"), + hist: None, + gray: false, + swatches: [[0.0; 3], [1.0; 3]], + gradients: Vec::new(), + dialog: true, + dynamic_color_sliders: true, + }; + editor(ui, kind, values, &cx); + }, + params, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +/// Types `text` into the `index`th number field and commits it with Enter. +fn type_into_field(h: &mut Harness<'static, serde_json::Value>, index: usize, text: &str) { + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} + +/// The text the `index`th number field shows. +fn field_text(h: &Harness<'static, serde_json::Value>, index: usize) -> String { + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().value().unwrap_or_default() +} + +/// #4164: Exposure keeps fractional stops (Photoshop: two decimals) and Offset four decimals, +/// instead of rounding Exposure to whole stops because its range is wider than 10. +#[test] +fn exposure_keeps_fractional_stops_and_offset_four_decimals() { + let mut h = editor_harness("exposure", json!({})); + type_into_field(&mut h, 0, "0.5"); + assert_eq!(h.state()["exposure"].as_f64(), Some(0.5), "{}", h.state()); + type_into_field(&mut h, 0, "-1.37"); + assert!((h.state()["exposure"].as_f64().unwrap() + 1.37).abs() < 1e-4, "{}", h.state()); + type_into_field(&mut h, 1, "0.0123"); + assert!((h.state()["offset"].as_f64().unwrap() - 0.0123).abs() < 1e-6, "{}", h.state()); + type_into_field(&mut h, 2, "1.234"); + assert!((h.state()["gamma"].as_f64().unwrap() - 1.23).abs() < 1e-4, "Gamma keeps two decimals: {}", h.state()); + h.run_steps(2); + assert_eq!(field_text(&h, 0), "-1.37"); + assert_eq!(field_text(&h, 1), "0.0123"); + assert_eq!(field_text(&h, 2), "1.23"); +} + +/// #4164: the integer rows that share Exposure's slider code stay whole numbers. +#[test] +fn integer_adjustment_rows_stay_whole_numbers() { + for (kind, key, typed, want) in [ + ("posterize", "levels", "4.6", 5.0), + ("threshold", "level", "100.4", 100.0), + ("brightnessContrast", "brightness", "12.7", 13.0), + ("vibrance", "vibrance", "-20.4", -20.0), + ] { + let mut h = editor_harness(kind, json!({})); + type_into_field(&mut h, 0, typed); + assert_eq!(h.state()[key].as_f64(), Some(want), "{kind}: {}", h.state()); + } +} + +/// #4164: a row's own decimals win over the range rule; rows without them keep it. +#[test] +fn row_values_round_to_their_own_decimals() { + use crate::adjust_editors::row_value; + assert_eq!(row_value(0.5, -20.0, 20.0, Some(2)), 0.5); + assert_eq!(row_value(-1.374, -20.0, 20.0, Some(2)), -1.37); + assert!((row_value(0.012_34, -0.5, 0.5, Some(4)) - 0.0123).abs() < 1e-7); + assert_eq!(row_value(4.6, 2.0, 255.0, None), 5.0, "wide ranges stay whole numbers"); + assert!((row_value(1.234, 0.01, 9.99, None) - 1.23).abs() < 1e-6, "small ranges keep two decimals"); + assert!(row_value(f32::NAN, -20.0, 20.0, Some(u32::MAX)).is_nan(), "no panic on odd input"); +} diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index b0ad27bdd3..09aaf17670 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -565,7 +565,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ui.label(RichText::new(tl!("Label Size")).color(t.text_dim).size(11.0)); let b = crate::widgets::value_field(ui, &mut ls, 8.0..=72.0, "", 44.0).changed(); if a || b { - let _ = app.run("count.setGroup", json!({"markerSize": ms.round(), "labelSize": ls.round()})); + let _ = app.run("count.setGroup", count_size_params(ms, ls)); } } true @@ -613,6 +613,13 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // ------------------------------------------------------------------ panels and dialogs +/// `count.setGroup` params for the options bar's Marker Size and Label Size fields, sent as JSON +/// integers (the fields edit `f32`s; `8.0` is a float on the wire). +fn count_size_params(marker: f32, label: f32) -> Value { + let whole = |v: f32, lo: f32, hi: f32| if v.is_finite() { v.round().clamp(lo, hi) as u32 } else { lo as u32 }; + json!({"markerSize": whole(marker, 1.0, 10.0), "labelSize": whole(label, 8.0, 72.0)}) +} + fn float_frame(t: &Tokens) -> egui::Frame { egui::Frame::NONE .fill(t.card) @@ -1223,4 +1230,17 @@ pub(crate) mod tests { assert!(empty.run("view.colorSamplers.add", json!({"x": 1, "y": 1})).is_err()); assert!(empty.run("view.colorSamplers.list", json!({})).is_err()); } + + #[test] + fn count_size_fields_send_whole_numbers_the_engine_applies() { + let p = count_size_params(7.6, 20.0); + assert_eq!(p, json!({"markerSize": 8, "labelSize": 20})); + assert!(p["markerSize"].is_u64() && p["labelSize"].is_u64()); + assert_eq!(count_size_params(f32::NAN, 500.0), json!({"markerSize": 1, "labelSize": 72})); + let (mut app, _ctx) = app(); + app.run("count.add", json!({"x": 10, "y": 10})).unwrap(); + app.run("count.setGroup", count_size_params(8.0, 20.0)).unwrap(); + let g = &app.session.active().unwrap().doc.measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (8, 20)); + } } diff --git a/crates/ui-egui/src/anchor_tools.rs b/crates/ui-egui/src/anchor_tools.rs index a314db5fc9..149792b39e 100644 --- a/crates/ui-egui/src/anchor_tools.rs +++ b/crates/ui-egui/src/anchor_tools.rs @@ -7,6 +7,9 @@ //! * Delete Anchor Point: hovering an anchor highlights it; a click removes it //! (`path.deleteAnchor`), joining its neighbours. //! * ⌥ swaps the two tools and ⌘/Ctrl is Direct Selection (`direct_select`) while held. +//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144), on the +//! active path only and while no path is being drawn: a click on a segment adds, a click on an +//! anchor deletes, except an open subpath's end points; ⇧ turns it off while held. //! //! Each click is one command and one history step; a click on nothing it can edit does nothing. @@ -43,8 +46,42 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path } } -/// Canvas pointer events for the anchor tools; false when the current tool isn't one. +/// The Pen's Auto Add/Delete (#4144): what a Pen click at `p` does to the active path instead of +/// starting a new one, as the anchor tool that does it (Add Anchor Point on a segment, Delete +/// Anchor Point on an anchor), the path and the hit. `None` when the option is off, ⇧, ⌥ or ⌘ is +/// held, a path is being drawn, or the click hits nothing it edits. The end points of an open +/// subpath are left to the Pen, as in Photoshop. +pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) -> Option<(Tool, PathRef, Path, Hit)> { + if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || app.ui.pen.is_some() || mods.shift || mods.alt || mods.command { + return None; + } + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let (r, path, hit) = + crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let tool = match hit { + Hit::Segment(..) => Tool::AddAnchorPoint, + Hit::Anchor([s, k]) => { + let sp = path.subpaths.get(s)?; + if !sp.closed && (k == 0 || k + 1 == sp.knots.len()) { + return None; + } + Tool::DeleteAnchorPoint + } + Hit::Handle(..) => return None, + }; + Some((tool, r, path, hit)) +} + +/// Canvas pointer events for the anchor tools, and a Pen press that Auto Add/Delete takes; false +/// when they aren't theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + if let ToolEvent::Down { x, y, .. } = ev + && let Some((_, r, _, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) + { + // The release finds no Pen path to finish, so the Pen draws nothing. + apply(app, r, hit); + return true; + } let Some(tool) = effective(app.ui.tool, mods) else { return false }; if let ToolEvent::Down { x, y, .. } = ev { click(app, tool, [x, y]); @@ -54,6 +91,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { let Some((r, _, hit)) = target(app, tool, p) else { return }; + apply(app, r, hit); +} + +/// Add an anchor where `hit` is on a segment of `r`, or delete the anchor it is on: one command. +fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { let (id, mut params) = match hit { Hit::Segment([s, k], t) => ("path.addAnchor", serde_json::json!({"subpath": s, "knot": k, "t": t})), Hit::Anchor([s, k]) => ("path.deleteAnchor", serde_json::json!({"subpath": s, "knot": k})), @@ -82,6 +124,18 @@ pub fn draw_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn( return; } let Some((_, path, hit)) = target(app, tool, p) else { return }; + draw_hit(painter, to_scr, accent, &path, hit); +} + +/// The same feedback for the Pen when Auto Add/Delete would act on a click. +pub fn draw_pen_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, mods: Modifiers) { + let Some(p) = app.hover_doc else { return }; + if let Some((_, _, path, hit)) = pen_auto(app, app.ui.tool, mods, p) { + draw_hit(painter, to_scr, accent, &path, hit); + } +} + +fn draw_hit(painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, path: &Path, hit: Hit) { match hit { Hit::Segment([s, k], t) => { let Some(seg) = path.subpaths.get(s).and_then(|sp| sp.segments().get(k).copied()) else { return }; diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index c88f1a2b19..87c045eba9 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -154,3 +154,79 @@ fn the_cursor_shows_plus_or_minus() { assert_eq!(badge(&app, Tool::DeleteAnchorPoint, Modifiers::NONE), Some(Badge::Subtract)); assert_eq!(badge(&app, Tool::DeleteAnchorPoint, Modifiers::COMMAND), None, "⌘ is Direct Selection"); } + +/// #4144: with Auto Add/Delete (on by default) a Pen click on a segment of the active path adds +/// an anchor and a click on one of its anchors deletes it, one step each, and no Pen path starts. +#[test] +fn the_pen_adds_and_deletes_anchors_on_the_active_path() { + let mut app = app(Tool::Pen); + assert!(app.ui.tool_options.pen_auto_add_delete, "on by default, as in Photoshop"); + with_path(&mut app); + let n = steps(&app); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 5); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Add Anchor Point")); + assert!(app.ui.pen.is_none(), "no Pen path was started"); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + let p = work(&app); + assert_eq!(p.subpaths[0].knots.len(), 4); + assert!(!p.subpaths[0].knots.iter().any(|k| k.anchor == Point::new(40.0, 120.0))); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Delete Anchor Point")); + assert_eq!(steps(&app), n + 2); + assert!(app.ui.pen.is_none()); + // Off the path the Pen draws. + click(&mut app, [170.0, 170.0], Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(1)); +} + +/// Auto Add/Delete off, or ⇧ held, or a path being drawn: the Pen click places an anchor. +#[test] +fn the_pen_draws_over_the_path_with_auto_add_delete_off_shift_or_while_drawing() { + let mut app = app(Tool::Pen); + with_path(&mut app); + let before = work(&app); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before.clone(), Some(1))); + app.ui.pen = None; + + app.ui.tool_options.pen_auto_add_delete = true; + click(&mut app, [80.0, 120.0], Modifiers::SHIFT); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before.clone(), Some(1)), "⇧ turns it off"); + app.ui.pen = None; + + click(&mut app, [170.0, 170.0], Modifiers::NONE); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before, Some(2)), "a path being drawn takes the click"); +} + +/// The end points of an open subpath stay the Pen's; its middle anchors are deleted. +#[test] +fn the_pen_leaves_open_end_points_alone() { + let mut app = app(Tool::Pen); + let path = json!({"subpaths": [{"closed": false, "knots": [[40, 40], [120, 40], [120, 120]]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + let before = work(&app); + for end in [[40.0, 40.0], [120.0, 120.0]] { + click(&mut app, end, Modifiers::NONE); + assert_eq!(work(&app), before, "{end:?}"); + app.ui.pen = None; + } + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 2); +} + +#[test] +fn the_pen_cursor_shows_what_auto_add_delete_would_do() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::Pen); + with_path(&mut app); + for (at, want) in [([80.0, 120.0], Some(Badge::Add)), ([40.0, 120.0], Some(Badge::Subtract)), ([80.0, 80.0], None)] { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), want, "{at:?}"); + } + app.hover_doc = Some([80.0, 120.0]); + assert_eq!(badge(&app, Tool::Pen, Modifiers::SHIFT), None); + app.ui.tool_options.pen_auto_add_delete = false; + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); +} diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 79a7cd3b78..6b996c3f14 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -417,7 +417,11 @@ mod tests { let press = |app: &mut PhotocraftApp, modifiers| { let key = egui::Key::V; let raw = egui::RawInput { - events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], ..Default::default() }; ctx.begin_pass(raw); diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index 2cb30806aa..49c444c154 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -42,11 +42,25 @@ impl Key { } } -/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom); -/// `normal` is Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). -pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, normal: bool, centre: bool) -> CursorIcon { +/// The screen vectors of one tip radius (`size / 2` document pixels) along the document's x and +/// y axes: the view's zoom, rotation, flip and pixel aspect, which the outline follows as the +/// stroke does. +pub(crate) fn tip_axes(xf: &crate::canvas::ViewXform, size: f32) -> [Vec2; 2] { + let r = if size.is_finite() { size.max(0.0) / 2.0 } else { 0.0 }; + [xf.map_vec(Vec2::new(r, 0.0)), xf.map_vec(Vec2::new(0.0, r))] +} + +/// Rings in radius units (document orientation) on screen, through [`tip_axes`]. +fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec> { + rings.iter().map(|r| r.iter().map(|p| axes[0] * p[0] + axes[1] * p[1]).collect()).collect() +} + +/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom), +/// for the plain circle; `axes` comes from [`tip_axes`], for a traced outline. `normal` is +/// Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). +pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, axes: [Vec2; 2], normal: bool, centre: bool) -> CursorIcon { if let Some(rings) = rings(painter.ctx(), brush, !normal) { - let pts: Vec> = rings.iter().map(|r| r.iter().map(|p| Vec2::new(p[0], p[1]) * radius).collect()).collect(); + let pts = on_screen(&rings, axes); let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length())); if extent < MIN_RADIUS { return crate::tool_cursor::crosshair(painter, at, 4.0, 0.0); @@ -59,7 +73,7 @@ pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: /// The tip outline in radius units, cached across frames; `None` when the plain circle says it all /// (a plain round tip) or the tip has no traceable shape. -fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option>>> { +pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option>>> { if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 { return None; } @@ -73,3 +87,111 @@ fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec { + let [x0, y0, x1, y1] = rect; + let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0)); + let mut mask = vec![false; w.saturating_mul(h)]; + let mut hits: Vec = Vec::new(); + for row in 0..h { + let y = f64::from(y0) + row as f64 + 0.5; + hits.clear(); + for ring in rings { + for (i, a) in ring.iter().enumerate() { + let Some(b) = ring.get((i + 1) % ring.len()) else { continue }; + let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius); + let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius); + if (ay <= y) != (by <= y) { + hits.push(ax + (y - ay) / (by - ay) * (bx - ax)); + } + } + } + hits.sort_by(f64::total_cmp); + for pair in hits.chunks(2) { + let [a, b] = pair else { continue }; + let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5); + let start = from.ceil().max(0.0) as usize; + let end = ((to.floor() + 1.0).max(0.0) as usize).min(w); + for x in start..end { + if let Some(m) = mask.get_mut(row * w + x) { + *m = true; + } + } + } + } + mask +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::ViewXform; + use egui::{Rect, pos2}; + + fn view(rotation: f32, flip: bool, aspect: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 300.0)), zoom: 2.0, center: [50.0, 40.0], flip, rotation, aspect } + } + + fn close(a: Vec2, b: Vec2) -> bool { + (a - b).length() < 1e-4 + } + + /// The axes are the screen offsets of the document points one radius right of and below the + /// pointer, whatever the view does. + #[test] + fn tip_axes_follow_the_view_like_to_screen() { + for (rotation, flip, aspect) in [(0.0, false, 1.0), (30.0, false, 1.0), (-90.0, true, 1.0), (45.0, true, 2.0), (200.0, false, 0.5)] { + let xf = view(rotation, flip, aspect); + let [ex, ey] = tip_axes(&xf, 20.0); + let o = xf.to_screen(60.0, 30.0); + assert!(close(ex, xf.to_screen(70.0, 30.0) - o), "x axis at {rotation}°, flip {flip}, aspect {aspect}"); + assert!(close(ey, xf.to_screen(60.0, 40.0) - o), "y axis at {rotation}°, flip {flip}, aspect {aspect}"); + } + } + + /// An unrotated, unflipped view gives what upstream's radius scaling gave. + #[test] + fn a_plain_view_scales_the_outline_by_the_screen_radius() { + let rings = vec![vec![[1.0, 0.0], [0.0, 0.5], [-1.0, 0.0]]]; + let pts = on_screen(&rings, tip_axes(&view(0.0, false, 1.0), 20.0)); + let want = [Vec2::new(20.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(-20.0, 0.0)]; + assert!(pts[0].iter().zip(want).all(|(a, b)| close(*a, b)), "{pts:?}"); + } + + /// A tip pointing along the document's x axis points down the screen in a view turned 90°, + /// and left in a flipped one. + #[test] + fn the_outline_turns_and_flips_with_the_view() { + let rings = vec![vec![[1.0, 0.0]]]; + let turned = on_screen(&rings, tip_axes(&view(90.0, false, 1.0), 20.0)); + assert!(close(turned[0][0], Vec2::new(0.0, 20.0)), "{turned:?}"); + let flipped = on_screen(&rings, tip_axes(&view(0.0, true, 1.0), 20.0)); + assert!(close(flipped[0][0], Vec2::new(-20.0, 0.0)), "{flipped:?}"); + } + + #[test] + fn hostile_sizes_give_finite_axes() { + for size in [f32::NAN, f32::INFINITY, -5.0, 0.0] { + let [ex, ey] = tip_axes(&view(30.0, true, 1.0), size); + assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); + } + } + + /// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear, + /// and a rotated flat tip leaves the corners of the brush square outside. + #[test] + fn tip_mask_follows_the_ring_shape() { + let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; + let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]]; + let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]); + let at = |x: usize, y: usize| mask[y * 20 + x]; + assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square"); + assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square"); + assert!(!at(10, 10) && at(8, 10), "the hole is clear"); + assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole"); + assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless"); + } +} diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index d018219238..d91e6ff459 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -248,6 +248,21 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting ui.vertical(|ui| { ui.set_width(150.0); ui.spacing_mut().item_spacing.y = 1.0; + let (br_rect, br_resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click()); + br_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Brushes"))); + if br_resp.hovered() { + ui.painter().rect_filled(br_rect, t.radius_sm, t.hover); + } + let icon_rect = egui::Rect::from_center_size(egui::pos2(br_rect.left() + 12.0, br_rect.center().y), vec2(13.0, 13.0)); + icons::paint(ui, icon_rect, "brush", 12.0, if br_resp.hovered() { t.text } else { t.icon }); + let galley = ui.painter().layout_no_wrap(tl!("Brushes").to_string(), theme::semibold(12.0), if br_resp.hovered() { t.text } else { t.text_dim }); + ui.painter().galley(egui::pos2(br_rect.left() + 26.0, br_rect.center().y - galley.size().y / 2.0), galley, t.text); + if br_resp.clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.panel.brushes", json!({})); + } + ui.add_space(3.0); + widgets::hairline(ui); + ui.add_space(3.0); for (i, (name, has_box)) in SECTIONS.iter().enumerate() { let toggle_only = checkbox_only(i); // Group the switches with whitespace, not separators between individual rows. @@ -280,22 +295,21 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting }); resp }); - if *flag { - ui.painter().rect_filled(br, 2.0, t.accent); - let (a, m, c) = (br.left_center() + vec2(3.0, 0.5), br.center_bottom() + vec2(-1.0, -3.5), br.right_top() + vec2(-3.0, 3.5)); - ui.painter().line_segment([a, m], Stroke::new(1.6, Color32::WHITE)); - ui.painter().line_segment([m, c], Stroke::new(1.6, Color32::WHITE)); - } else { - ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside); - } let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0); + // A press on the row (but not its lock) presses the box too: a release there + // turns the section on, or toggles a checkbox-only row (#4476). + let pressed = box_resp.as_ref().is_some_and(|b| b.is_pointer_button_down_on()) || (resp.is_pointer_button_down_on() && !on_lock); + let hovered = box_resp.as_ref().is_some_and(|b| b.hovered()) || (toggle_only && resp.hovered()); + widgets::check_box(ui.painter(), br, *flag, hovered, pressed, &t); if box_resp.is_some_and(|resp| resp.clicked()) || (toggle_only && resp.clicked() && !on_lock) { *flag = !*flag; } else if !toggle_only && resp.clicked() && !on_lock { *flag = true; } x += 20.0; - } else if i > 0 { + } else { + // No box (Brush Tip Shape): the name still starts in the names' column, + // as in Photoshop (#4282). x += 20.0; } let color = if sel { t.text } else { t.text_dim }; @@ -310,11 +324,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting if let Some(lock) = section_lock(b, i) { let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); - // Like Photoshop: the lock shows only when set or hovered. - if *lock || resp.hovered() || lresp.hovered() { - let tint = if *lock { t.text } else { t.text_faint }; - icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); - } + // Like Photoshop: lock icons stay visible on every lockable section row. + let tint = if *lock { t.text } else { t.text_faint }; + icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); let tip = if *lock { tl!("Unlock: picking a preset replaces these settings") } else { tl!("Lock: keep these settings when picking another preset") }; if lresp.on_hover_text(tip).clicked() { @@ -373,9 +385,9 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let tex = brush_preview::stroke_texture(ui.ctx(), "settings-strip", &b, STRIP.0, STRIP.1, t.text); ui.painter().image(tex.id(), r, full_uv(), Color32::WHITE); ui.vertical(|ui| { - if icons::button(ui, "square-plus", 24.0, false, tl!("Create new brush from these settings")).clicked() { - let name = new_preset_name(&app.session.tools.presets); - run_or_status(app, "brush.presets.save", json!({ "name": name, "brush": serde_json::to_value(&b).unwrap_or(Value::Null) })); + let new = icons::button(ui, "square-plus", 24.0, false, tl!("Create new brush from these settings")); + if crate::brush_picker::named(new, "Create new brush from these settings").clicked() { + crate::brush_preset_dialog::open(app, Some(&b)); } if icons::button(ui, "undo-2", 24.0, false, tl!("Reset the brush to the defaults")).clicked() { b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), locks: b.locks.clone(), ..Default::default() }; @@ -394,6 +406,92 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { commit_gesture(app, ui.ctx(), &before, &b); } +/// Clear brush controls: turns off every section except Smoothing and resets their options +/// to the defaults (Photoshop), keeping locked sections intact. +pub fn clear_brush_controls(current: &BrushSettings) -> BrushSettings { + let mut b = current.clone(); + let d = BrushSettings::default(); + let l = ¤t.locks; + if !l.shape_dynamics { + b.shape_dynamics = d.shape_dynamics; + } + if !l.scattering { + b.scattering = d.scattering; + } + if !l.texture { + b.texture = d.texture; + } + if !l.dual_brush { + b.dual_brush = d.dual_brush; + } + if !l.color_dynamics { + b.color_dynamics = d.color_dynamics; + } + if !l.transfer { + b.transfer = d.transfer; + } + if !l.pose { + b.pose = d.pose; + } + if !l.noise { + b.noise = d.noise; + } + if !l.wet_edges { + b.wet_edges = d.wet_edges; + } + if !l.build_up { + b.build_up = d.build_up; + b.build_up_rate = d.build_up_rate; + } + if !l.protect_texture { + b.protect_texture = d.protect_texture; + } + b +} + +/// Copy the current brush's Texture settings to other painting tools that have Texture enabled. +pub fn copy_texture_to_other_tools(app: &mut PhotocraftApp) { + let texture = app.session.tools.brush.texture.clone(); + for (_, brush) in &mut app.ui.tool_brushes { + if brush.texture.enabled { + brush.texture = texture.clone(); + } + } +} + +/// The Brush Settings panel menu actions (hamburger menu). +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if ui.button(tl!("New Brush Preset…")).clicked() { + let name = new_preset_name(&app.session.tools.presets); + run_or_status(app, "brush.presets.save", json!({ "name": name })); + if app.session.tools.presets.iter().any(|p| p.name == name) { + app.ui.brushes_panel.renaming = Some(Renaming { group: false, name: name.clone(), folder: Vec::new(), text: String::new() }); + app.ui.brush_picker_list.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() }); + } + ui.close(); + } + ui.separator(); + if ui.button(tl!("Clear Brush Controls")).clicked() { + let before = app.session.tools.brush.clone(); + let cleared = clear_brush_controls(&before); + commit(app, &before, &cleared); + ui.close(); + } + let has_locks = app.session.tools.brush.locks != photocraft_paint::brush::SectionLocks::default(); + if ui.add_enabled(has_locks, egui::Button::new(tl!("Reset All Locked Settings"))).clicked() { + let before = app.session.tools.brush.clone(); + let mut after = before.clone(); + after.locks = photocraft_paint::brush::SectionLocks::default(); + commit(app, &before, &after); + ui.close(); + } + let has_texture = app.session.tools.brush.texture.enabled; + if ui.add_enabled(has_texture, egui::Button::new(tl!("Copy Texture to Other Tools"))).clicked() { + copy_texture_to_other_tools(app); + ui.close(); + } +} + pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.ui.panels.brush_settings { return; @@ -427,6 +525,21 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { if icons::button(ui, "x", 20.0, false, tl!("Close")).clicked() { open = false; } + let menu_btn = widgets::panel_menu_button(ui); + egui::Popup::menu(&menu_btn).show(|ui| { + widgets::style_spectrum_popup_menu(ui); + ui.set_min_width(180.0); + panel_menu(app, ui); + ui.separator(); + if ui.button(tl!("Close")).clicked() { + open = false; + ui.close(); + } + if ui.button(tl!("Close Tab Group")).clicked() { + open = false; + ui.close(); + } + }); }); }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 34dc0d9d4d..9bd669fefe 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -220,6 +220,61 @@ fn section_locks_round_trip_and_survive_preset_picks() { assert_eq!(b.tip, chalk.tip); } +#[test] +fn section_locks_are_always_visible_and_toggle_on_click() { + use egui_kittest::kittest::Queryable; + + fn collect_icon_rects(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Rect(r) if (r.rect.size() - vec2(12.0, 12.0)).length() < 0.5 => out.push(r.rect), + egui::Shape::Mesh(mesh) => out.push(mesh.calc_bounds()), + egui::Shape::Vec(shapes) => { + for s in shapes { + collect_icon_rects(s, out); + } + } + _ => {} + } + } + + let mut h = harness(0, 0); + let mut icons = Vec::new(); + for clipped in &h.output().shapes { + collect_icon_rects(&clipped.shape, &mut icons); + } + + // Every lockable section (1..SECTIONS.len()) paints its lock icon even when unlocked and unhovered. + for (name, _) in SECTIONS.iter().skip(1) { + let row = h.get_by_label(name).rect(); + let lock_center = egui::pos2(row.right() - 11.0, row.center().y); + assert!(icons.iter().any(|r| r.contains(lock_center)), "expected lock icon at {lock_center:?} for {name}"); + } + + // Clicking the lock toggles it on and off without changing the selected section. + let row = h.get_by_label("Scattering").rect(); + let lock_pos = egui::pos2(row.right() - 11.0, row.center().y); + assert!(!h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); + + h.hover_at(lock_pos); + h.run_steps(1); + h.drag_at(lock_pos); + h.run_steps(1); + h.drop_at(lock_pos); + h.run_steps(2); + assert!(h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); + + h.hover_at(lock_pos); + h.run_steps(1); + h.drag_at(lock_pos); + h.run_steps(1); + h.drop_at(lock_pos); + h.run_steps(2); + assert!(!h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); @@ -324,6 +379,26 @@ fn options_bar_edits_are_one_set_brush_per_gesture() { assert_eq!(s.tools.brush, h.state().session.tools.brush); } +#[test] +fn brush_mode_dropdown_shows_outside_the_pro_themes() { + // #4512: only the Pro themes drew the Brush's Mode; the Pencil has it in every theme. + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = options_bar_harness(crate::state::Tool::Brush); + let n = h.state().session.journal.len(); + assert!(h.query_by_label("Mode").is_some(), "the Brush's Mode label"); + h.get_all_by_role(egui::accesskit::Role::ComboBox) + .find(|c| c.accesskit_node().value().as_deref() == Some("Normal")) + .expect("the Brush's Mode dropdown") + .click(); + h.run(); + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Dissolve); + let j = &h.state().session.journal; + assert_eq!(j.len(), n + 1, "{:?}", &j[n..]); + assert_eq!(j[n].0, "tools.setBrush"); +} + #[test] fn airbrush_smoothing_options_and_symmetry_buttons_work() { // #1177: in the default (Photoshop) theme these options-bar buttons did nothing. @@ -403,7 +478,14 @@ fn drop_targets_and_actions_reorder_presets() { use crate::brushes_tab::{Action, apply, drop_target, group_key}; let mut app = app(); for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta")] { - app.session.tools.presets.push(paint::BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into(), folder: Vec::new() }); + app.session.tools.presets.push(paint::BrushPreset { + name: n.into(), + brush: Default::default(), + builtin: false, + group: g.into(), + folder: Vec::new(), + captures: Default::default(), + }); } let p = &app.session.tools.presets; // Dropping A1 on A3's lower half lands after A3 (index 2 once A1 is out); on its upper half, before it. @@ -599,6 +681,31 @@ fn editing_the_options_bar_amount_reenables_smoothing() { assert_eq!(last_journal(h.state()).unwrap().1["brush"]["smoothing"]["enabled"], true); } +/// #4282: every name in the section list starts in the same column, Brush Tip Shape included, +/// whether or not its row has an enable box (Photoshop indents the rows without one). +#[test] +fn section_names_line_up_whether_or_not_they_have_a_box() { + fn text_x(shape: &egui::Shape, out: &mut Vec<(String, f32)>) { + match shape { + egui::Shape::Text(text) => out.push((text.galley.job.text.clone(), text.pos.x)), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_x(s, out)), + _ => {} + } + } + // Shape Dynamics is shown, so its heading paints too; the list's copy is the leftmost. + let h = harness(0, 1); + let mut painted = Vec::new(); + for shape in &h.output().shapes { + text_x(&shape.shape, &mut painted); + } + let left = |name: &str| painted.iter().filter(|(t, _)| t == name).map(|(_, x)| *x).reduce(f32::min); + let columns: Vec<(&str, f32)> = SECTIONS.iter().map(|(name, _)| (*name, left(name).unwrap_or_else(|| panic!("{name} painted")))).collect(); + let (_, boxed) = columns[1]; + for (name, x) in &columns { + assert!((x - boxed).abs() < 0.5, "{name} starts at {x}, the boxed names at {boxed}: {columns:?}"); + } +} + #[test] fn preview_strip_re_renders_only_when_the_brush_changes() { let mut h = harness(0, 0); @@ -672,6 +779,7 @@ fn kit(name: &str, folder: &[&str]) -> paint::BrushPreset { builtin: false, group: "Kit".into(), folder: folder.iter().map(|s| s.to_string()).collect(), + captures: Default::default(), } } @@ -1095,3 +1203,203 @@ fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { assert!(h.state().ui.views[0].fill_pending); assert!(!h.state().ui.views[0].fit_pending); } + +#[test] +fn brush_settings_hamburger_menu_actions() { + use egui_kittest::kittest::Queryable; + + // 1. Menu opens and offers Photoshop actions + let mut h = harness(0, 0); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + + assert!(h.query_all_by_label("New Brush Preset…").count() > 0); + assert!(h.query_all_by_label("Clear Brush Controls").count() > 0); + assert!(h.query_all_by_label("Reset All Locked Settings").count() > 0); + assert!(h.query_all_by_label("Copy Texture to Other Tools").count() > 0); + assert!(h.query_all_by_label("Close").count() > 0); + assert!(h.query_all_by_label("Close Tab Group").count() > 0); + + // 2. New Brush Preset… saves a preset + let count0 = h.state().session.tools.presets.len(); + h.get_by_label("New Brush Preset…").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.presets.len(), count0 + 1); + assert_eq!(last_journal(h.state()).unwrap().0, "brush.presets.save"); + + // 3. Clear Brush Controls resets dynamics, keeps locked sections and smoothing + let mut h = harness(0, 0); + h.state_mut().session.tools.brush.size = 45.0; + h.state_mut().session.tools.brush.scattering.enabled = true; + h.state_mut().session.tools.brush.texture.enabled = true; + h.state_mut().session.tools.brush.locks.texture = true; + h.state_mut().session.tools.brush.smoothing.amount = 0.5; + h.run_steps(1); + + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Clear Brush Controls").click(); + h.run_steps(2); + + let b = &h.state().session.tools.brush; + assert!(!b.scattering.enabled, "unlocked scattering cleared"); + assert!(b.texture.enabled, "locked texture preserved"); + assert_eq!(b.smoothing.amount, 0.5, "smoothing preserved"); + assert_eq!(b.size, 45.0, "tip size preserved"); + + // 4. Reset All Locked Settings clears all locks + assert!(b.locks.texture); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Reset All Locked Settings").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.locks, photocraft_paint::brush::SectionLocks::default()); + + // 5. Copy Texture to Other Tools copies texture to other tools with texture enabled + let mut h = harness(0, 0); + h.state_mut().session.tools.brush.texture.enabled = true; + h.state_mut().session.tools.brush.texture.scale = 2.5; + let mut pencil_brush = BrushSettings::default(); + pencil_brush.texture.enabled = true; + pencil_brush.texture.scale = 1.0; + h.state_mut().ui.tool_brushes = vec![(crate::state::Tool::Pencil, pencil_brush)]; + h.run_steps(1); + + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Copy Texture to Other Tools").click(); + h.run_steps(2); + + let pencil = h.state().ui.tool_brushes.iter().find(|(t, _)| *t == crate::state::Tool::Pencil).unwrap(); + assert_eq!(pencil.1.texture.scale, 2.5); + + // 6. Close closes the window + let mut h = harness(0, 0); + assert!(h.state().ui.panels.brush_settings); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + let close_buttons: Vec<_> = h.query_all_by_label("Close").collect(); + assert!(close_buttons.len() >= 2); + close_buttons.last().unwrap().click(); + h.run_steps(2); + assert!(!h.state().ui.panels.brush_settings); + + // 7. Close Tab Group closes the window + let mut h = harness(0, 0); + assert!(h.state().ui.panels.brush_settings); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Close Tab Group").click(); + h.run_steps(2); + assert!(!h.state().ui.panels.brush_settings); +} + +#[test] +fn brushes_button_at_top_of_brush_settings_opens_brushes_panel() { + use egui_kittest::kittest::Queryable; + + let mut h = harness(0, 0); + assert_eq!(h.state().ui.brush_tab, 0); + h.get_by_label("Brushes").click(); + h.run_steps(3); + assert!(h.state().ui.panels.brush_settings); + assert_eq!(h.state().ui.brush_tab, 1); +} + +/// The fill painted for the check box in `rect` in the last frame (the topmost filled rect there). +fn box_fill(h: &Harness<'_, PhotocraftApp>, rect: egui::Rect) -> Option { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if (r.rect.min - rect.min).length() < 1.0 && (r.rect.max - rect.max).length() < 1.0 && r.fill != Color32::TRANSPARENT => { + Some(r.fill) + } + _ => None, + }) + .next_back() +} + +/// #4476: while the mouse is down on a section's row or its box, the box shows it's pressed, as +/// in Photoshop: an unchecked box darkens, a checked one turns less opaque. The release then +/// acts as before. +#[test] +fn section_boxes_show_a_pressed_state() { + use egui_kittest::kittest::Queryable; + let mut h = app_harness(); + h.key_press(egui::Key::F5); + h.run_steps(3); + let t = Tokens::get(&h.ctx); + let boxr = h.get_by_label("Enable Shape Dynamics").rect(); + let row = h.get_by_label("Shape Dynamics").rect(); + assert!(!h.state().session.tools.brush.shape_dynamics.enabled); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, false, &t))); + // Held on the row's name: the unchecked box darkens; the release turns the section on. + let at = egui::pos2(row.center().x, row.center().y); + h.drag_at(at); + h.run_steps(2); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, true, &t)), "pressed, unchecked"); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().session.tools.brush.shape_dynamics.enabled); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(true, false, &t))); + // Held on the checked box itself: less opaque, then the release turns it off. + h.drag_at(boxr.center()); + h.run_steps(2); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(true, true, &t)), "pressed, checked"); + h.drop_at(boxr.center()); + h.run_steps(3); + assert!(!h.state().session.tools.brush.shape_dynamics.enabled); + // A checkbox-only row (no settings of its own) is one checkbox across the row: a press + // anywhere on it presses its box, and the release toggles it. + let wet = h.get_by_label("Wet Edges").rect(); + let wet_box = egui::Rect::from_center_size(egui::pos2(wet.left() + 12.0, wet.center().y), vec2(13.0, 13.0)); + let was = h.state().session.tools.brush.wet_edges; + h.drag_at(wet.center()); + h.run_steps(2); + assert_eq!(box_fill(&h, wet_box), Some(widgets::check_box_fill(was, true, &t)), "pressed checkbox-only row"); + h.drop_at(wet.center()); + h.run_steps(3); + assert_ne!(h.state().session.tools.brush.wet_edges, was); + // The four looks are distinct. + let looks = [(false, false), (false, true), (true, false), (true, true)].map(|(on, pressed)| widgets::check_box_fill(on, pressed, &t)); + for (i, a) in looks.iter().enumerate() { + assert!(looks.iter().skip(i + 1).all(|b| b != a), "{looks:?}"); + } +} + +/// The shared check box (options bars, dialogs) does the same, and pressing its label counts. +#[test] +fn checkboxes_show_a_pressed_state_from_their_label() { + use egui_kittest::kittest::Queryable; + let mut h = Harness::builder().with_size(vec2(300.0, 100.0)).build_ui_state( + |ui, on: &mut bool| { + widgets::checkbox(ui, on, "Sample All Layers"); + }, + false, + ); + h.run(); + let t = Tokens::get(&h.ctx); + let label = h.get_by_label("Sample All Layers").rect(); + let boxr = egui::Rect::from_min_size(egui::pos2(label.left() - 6.0 - 14.0, label.center().y - 7.0), vec2(14.0, 14.0)); + let fill = |h: &Harness<'_, bool>| { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if (r.rect.center() - boxr.center()).length() < 1.5 && r.rect.width() < 15.0 && r.fill != Color32::TRANSPARENT => { + Some(r.fill) + } + _ => None, + }) + .next_back() + }; + assert_eq!(fill(&h), Some(widgets::check_box_fill(false, false, &t))); + h.drag_at(label.center()); + h.run(); + assert_eq!(fill(&h), Some(widgets::check_box_fill(false, true, &t)), "pressing the label presses the box"); + h.drop_at(label.center()); + h.run(); + assert!(*h.state(), "the release checks it"); + assert_eq!(fill(&h), Some(widgets::check_box_fill(true, false, &t))); +} diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 3cade4981c..8ac5bb8c2c 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -24,7 +24,7 @@ use photocraft_engine::BrushSettings; use photocraft_engine::paint::{BrushPreset, MAX_BRUSH_SIZE, TipShape}; use serde_json::{Value, json}; -use crate::brush_panel::{BrushesPanelState, Renaming, new_preset_name, run_or_status}; +use crate::brush_panel::{BrushesPanelState, Renaming}; use crate::brushes_tab::{self, Action, ListLayout}; use crate::theme::Tokens; use crate::{PhotocraftApp, icons, widgets}; @@ -159,13 +159,11 @@ pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, picks: Vec) { match pick { Pick::List(a) => brushes_tab::apply(app, vec![a]), Pick::OpenSettings => open_settings(app, ctx), + // Photoshop asks for the new preset's name and what it captures first, in a dialog that + // takes the picker's place. Pick::NewPreset => { - let name = new_preset_name(&app.session.tools.presets); - run_or_status(app, "brush.presets.save", json!({ "name": name })); - // Photoshop asks for the new preset's name: the picker's rename bar does. - if app.session.tools.presets.iter().any(|p| p.name == name) { - app.ui.brush_picker_list.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() }); - } + close(&mut app.ui); + crate::brush_preset_dialog::open(app, None); } Pick::Import => { let _ = app.open_dialog_file(); diff --git a/crates/ui-egui/src/brush_preset_dialog.rs b/crates/ui-egui/src/brush_preset_dialog.rs new file mode 100644 index 0000000000..129b9adf28 --- /dev/null +++ b/crates/ui-egui/src/brush_preset_dialog.rs @@ -0,0 +1,165 @@ +//! The New Brush Preset dialog (#4490), as in Photoshop: the brush's tip and size on the left, the +//! name and what the preset captures in the middle, OK and Cancel on the right. Every way of making +//! a preset opens it: the picker's [+] and its gear menu, the Brush Settings [+] and the Brushes +//! tab's [+]. Nothing is saved until OK. +use egui::{Color32, pos2, vec2}; +use photocraft_paint::{BrushPreset, BrushSettings}; +use serde_json::{Map, Value, json}; + +use crate::{PhotocraftApp, state::DialogKind, theme::Tokens, widgets}; + +const MARK: &str = "__newBrushPreset"; +const PREVIEW: f32 = 96.0; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// The name the dialog suggests: the current preset's name and the first free number after it +/// ("Soft Round 1"), so OK never overwrites a preset; "Brush N" when the brush isn't a preset's. +pub fn suggested_name(presets: &[BrushPreset], current: Option<&str>) -> String { + match current.filter(|n| presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))) { + Some(base) => (1..).map(|n| format!("{base} {n}")).find(|n| !taken(presets, n)).unwrap_or_else(|| base.to_string()), + None => crate::brush_panel::new_preset_name(presets), + } +} + +fn taken(presets: &[BrushPreset], name: &str) -> bool { + presets.iter().any(|p| p.name.eq_ignore_ascii_case(name.trim())) +} + +/// Open the dialog for the tool's brush, or for `brush` (the Brush Settings panel's copy, which +/// may be ahead of the tool's by a frame). Returns the dialog's id. +pub fn open(app: &mut PhotocraftApp, brush: Option<&BrushSettings>) -> u64 { + let tools = &app.session.tools; + let mut fields = Map::new(); + fields.insert(MARK.into(), json!(true)); + fields.insert("__label".into(), json!("New Brush Preset…")); + fields.insert("name".into(), json!(suggested_name(&tools.presets, tools.current_preset.as_deref()))); + fields.insert("captureSize".into(), json!(true)); + fields.insert("toolSettings".into(), json!(false)); + fields.insert("color".into(), json!(false)); + if let Some(b) = brush.and_then(|b| serde_json::to_value(b).ok()) { + fields.insert("brush".into(), b); + } + app.ui.open_dialog(DialogKind::Command, fields) +} + +fn on(f: &Map, key: &str) -> bool { + f.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn name(f: &Map) -> &str { + f.get("name").and_then(Value::as_str).unwrap_or("").trim() +} + +/// Why OK is greyed out, if it is. +fn problem(app: &PhotocraftApp, f: &Map) -> Option<&'static str> { + if name(f).is_empty() { + Some(tl!("Enter a name for the brush preset")) + } else if taken(&app.session.tools.presets, name(f)) { + Some(tl!("A brush preset with this name already exists")) + } else { + None + } +} + +fn check(ui: &mut egui::Ui, f: &mut Map, key: &str, label: &str) { + let mut v = on(f, key); + if widgets::checkbox(ui, &mut v, label).changed() { + f.insert(key.into(), json!(v)); + } +} + +/// The dialog's three columns; it owns its OK and Cancel, so they stand in their own column. +pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, interactive: bool) -> Option { + let t = Tokens::get(ui.ctx()); + let mut outcome = None; + let brush: BrushSettings = f.get("brush").and_then(|b| serde_json::from_value(b.clone()).ok()).unwrap_or_else(|| app.session.tools.brush.clone()); + ui.horizontal_top(|ui| { + // The tip as the preset cards draw it, and the size under it. + let (r, _) = ui.allocate_exact_size(vec2(PREVIEW, PREVIEW), egui::Sense::hover()); + ui.painter().rect_filled(r, t.radius_sm, t.field); + let tip = crate::brush_preview::tip_texture(ui.ctx(), "new-preset-tip", &brush.tip, (brush.hardness, brush.angle, brush.roundness), 64, t.text); + let side = (PREVIEW - 30.0) * crate::brush_sections::thumb_scale(brush.size); + let tr = egui::Rect::from_center_size(pos2(r.center().x, r.top() + 8.0 + (PREVIEW - 30.0) / 2.0), vec2(side, side)); + ui.painter().image(tip.id(), tr, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + ui.painter().text( + pos2(r.center().x, r.bottom() - 5.0), + egui::Align2::CENTER_BOTTOM, + format!("{}", brush.size.round() as i64), + egui::FontId::proportional(11.0), + t.text_dim, + ); + ui.add_space(14.0); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let mut text = f.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + let id = ui.id().with("new-brush-preset-name"); + let r = widgets::text_field(ui, id, &mut text, "", 220.0, false); + // Photoshop opens on the name, all of it selected, so typing replaces it. + let once = id.with("focused"); + if !ui.ctx().data(|d| d.get_temp::(once).unwrap_or(false)) { + r.request_focus(); + if egui::TextEdit::load_state(ui.ctx(), id).is_some() { + crate::field_tab::select_all(ui.ctx(), id, &text); + ui.ctx().data_mut(|d| d.insert_temp(once, true)); + } + } + if r.changed() { + f.insert("name".into(), json!(text)); + } + }); + ui.add_space(6.0); + check(ui, f, "captureSize", tl!("Capture Brush Size in Preset")); + check(ui, f, "toolSettings", tl!("Include Tool Settings")); + // Include Color belongs to the tool settings: only with them, as in Photoshop. + ui.horizontal(|ui| { + ui.add_space(20.0); + ui.add_enabled_ui(on(f, "toolSettings"), |ui| check(ui, f, "color", tl!("Include Color"))); + }); + if let Some(why) = problem(app, f).filter(|_| !name(f).is_empty()) { + ui.add_space(4.0); + ui.colored_label(t.text_dim, why); + } + }); + ui.add_space(14.0); + ui.vertical(|ui| { + let ok = problem(app, f).is_none(); + if ui.add_enabled_ui(ok, |ui| widgets::primary_button(ui, tl!("OK"), 84.0)).inner.clicked() { + outcome = Some(true); + } + ui.add_space(4.0); + if widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked() { + outcome = Some(false); + } + }); + }); + if outcome.is_none() && interactive && problem(app, f).is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + outcome = Some(true); + } + outcome +} + +/// OK: save the preset with the boxes as chosen. It becomes the current brush, as in Photoshop. +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + if let Some(why) = problem(app, f) { + return Err(why.into()); + } + let tool_settings = on(f, "toolSettings"); + let mut p = json!({ + "name": name(f), + "captureSize": on(f, "captureSize"), + "toolSettings": tool_settings, + "color": tool_settings && on(f, "color"), + }); + if let Some(b) = f.get("brush") { + p["brush"] = b.clone(); + } + app.run("brush.presets.save", p) +} + +#[cfg(test)] +#[path = "brush_preset_dialog_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/brush_preset_dialog_tests.rs b/crates/ui-egui/src/brush_preset_dialog_tests.rs new file mode 100644 index 0000000000..3e13ebc381 --- /dev/null +++ b/crates/ui-egui/src/brush_preset_dialog_tests.rs @@ -0,0 +1,138 @@ +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; + +use super::*; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) + }); + h.state_mut().run("file.new", json!({"width": 400, "height": 300})).unwrap(); + h.state_mut().ui.tool = crate::state::Tool::Brush; + h.run_steps(4); + h +} + +/// The open New Brush Preset dialog, if there is one (and never two). +fn dialog(h: &Harness<'_, PhotocraftApp>) -> Option { + let open: Vec = h.state().ui.dialogs.iter().filter(|d| owns(&d.fields)).map(|d| d.id).collect(); + assert!(open.len() <= 1, "one dialog at a time: {open:?}"); + open.first().copied() +} + +fn field<'a>(h: &'a Harness<'_, PhotocraftApp>, id: u64, key: &str) -> &'a Value { + &h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields[key] +} + +/// Every way of making a preset opens the dialog, and Cancel saves nothing. +#[test] +fn every_new_preset_button_opens_the_dialog_and_cancel_saves_nothing() { + let mut h = harness(); + let before = h.state().session.tools.presets.clone(); + let brush = h.state().session.tools.brush.clone(); + let mut opened = 0; + let mut cancel = |h: &mut Harness<'_, PhotocraftApp>| { + dialog(h).expect("the dialog opened"); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(dialog(h).is_none(), "Cancel closes it"); + assert_eq!(h.state().session.tools.presets, before, "and saves nothing"); + assert_eq!(h.state().session.tools.brush, brush, "nor touches the brush"); + opened += 1; + }; + // The picker's [+] and its gear menu. + h.state_mut().ui.brush_picker = Some([300.0, 120.0]); + h.run_steps(3); + h.get_by_label("New Brush Preset").click(); + h.run_steps(3); + cancel(&mut h); + h.state_mut().ui.brush_picker = Some([300.0, 120.0]); + h.run_steps(3); + h.get_by_label("Brush Preset Options").click(); + h.run_steps(2); + h.get_by_label("New Brush Preset…").click(); + h.run_steps(3); + cancel(&mut h); + // Brush Settings' [+], then the Brushes tab's. + h.state_mut().ui.brush_picker = None; + h.state_mut().ui.panels.brush_settings = true; + h.state_mut().ui.brush_tab = 0; + h.run_steps(3); + h.get_by_label("Create new brush from these settings").click(); + h.run_steps(3); + cancel(&mut h); + h.state_mut().ui.brush_tab = 1; + h.run_steps(3); + h.get_by_label("Create new brush from the current settings").click(); + h.run_steps(3); + cancel(&mut h); + assert_eq!(opened, 4); +} + +/// The dialog suggests the current brush's name and the next free number, never a name in use; OK +/// saves under the typed name with the boxes as set, and the next one suggests the next number. +#[test] +fn ok_saves_under_the_name_with_the_boxes_as_set() { + let mut h = harness(); + h.state_mut().run("tools.setBrush", json!({"preset": "Chalk"})).unwrap(); + let id = open(h.state_mut(), None); + h.run_steps(3); + assert_eq!(field(&h, id, "name"), "Chalk 1"); + assert_eq!((field(&h, id, "captureSize"), field(&h, id, "toolSettings"), field(&h, id, "color")), (&json!(true), &json!(false), &json!(false))); + // Include Color waits for Include Tool Settings. + assert!(h.get_by_label("Include Color").accesskit_node().is_disabled(), "Include Color is greyed out"); + h.get_by_label("Include Tool Settings").click(); + h.run_steps(2); + assert!(!h.get_by_label("Include Color").accesskit_node().is_disabled()); + h.get_by_label("Include Color").click(); + h.get_by_label("Capture Brush Size in Preset").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(dialog(&h).is_none()); + let p = h.state().session.tools.presets.iter().find(|p| p.name == "Chalk 1").expect("saved"); + assert_eq!(p.captures, photocraft_paint::PresetCaptures { size: false, tool_settings: true, color: true }); + assert!(h.state().session.tools.presets.iter().any(|p| p.name == "Chalk"), "the preset it came from is still there"); + assert_eq!(h.state().session.tools.current_preset.as_deref(), Some("Chalk 1")); + + // Again: the next number, and a name in use greys OK out. + let id = open(h.state_mut(), None); + h.run_steps(3); + assert_eq!(field(&h, id, "name"), "Chalk 1 1", "named after the current brush, now Chalk 1"); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("name".into(), json!("chalk")); + h.run_steps(2); + assert!(h.get_by_label("OK").accesskit_node().is_disabled(), "Chalk exists"); + assert!(h.query_by_label("A brush preset with this name already exists").is_some()); + assert!(crate::dialogs::confirm(h.state_mut(), id).is_err(), "automation can't overwrite it either"); +} + +/// Include Color unchecked with it ticked earlier: the preset doesn't take the colour. +#[test] +fn include_color_needs_include_tool_settings() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let id = open(&mut app, None); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("name".into(), json!("Mine")); + f.insert("color".into(), json!(true)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let p = app.session.tools.presets.iter().find(|p| p.name == "Mine").unwrap(); + assert_eq!(p.captures, photocraft_paint::PresetCaptures { size: true, tool_settings: false, color: false }); +} + +/// The dialog opens on the name, all of it selected: typing replaces it, and Enter is OK. +#[test] +fn typing_replaces_the_suggested_name_and_enter_is_ok() { + let mut h = harness(); + let id = open(h.state_mut(), None); + h.run_steps(4); + h.event(egui::Event::Text("Inky".into())); + h.run_steps(2); + assert_eq!(field(&h, id, "name"), "Inky"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(dialog(&h).is_none(), "Enter is OK"); + assert_eq!(h.state().session.tools.current_preset.as_deref(), Some("Inky")); +} diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index c4239a3586..dfba8e5166 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -111,9 +111,17 @@ pub fn release_stale(app: &mut PhotocraftApp, secondary_down: bool) { } fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) { - // Right is bigger on screen, also in a flipped view (View › Flip Horizontal). - let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 }; - let (size, v) = resized(r.start, dx, y - r.anchor[1], app.point_zoom()); + // Screen-relative, as in Photoshop (#4228): right is bigger and down harder on screen, however + // the view is rotated or flipped. The screen delta over the zoom is in document pixels at 0°. + let (dx, dy, zoom) = match ViewXform::active(app) { + Some(xf) if xf.zoom.is_finite() && xf.zoom > 0.0 => { + let d = xf.to_screen(x as f32, y as f32) - xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32); + (f64::from(d.x / xf.zoom), f64::from(d.y / xf.zoom), xf.zoom) + } + // No view: document axes, mirrored by View › Flip Horizontal. + _ => ((x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 }, y - r.anchor[1], app.point_zoom()), + }; + let (size, v) = resized(r.start, dx, dy, zoom); // "Vary Round Brush Hardness on HUD" (Preferences ▸ Tools, on by default): off, the vertical // movement takes the opacity instead of the hardness, as in Photoshop. let vary = app.session.prefs().tools.vary_round_brush_hardness_on_hud; @@ -484,6 +492,39 @@ mod tests { } } + /// One Control+Alt drag by `(dx, dy)` document pixels from (100, 100) in a view turned by + /// `rotation` degrees (flipped with `flip`): the resulting (size, hardness). + fn drag_in_view(rotation: f32, flip: bool, dx: f64, dy: f64) -> (f32, f32) { + let mut app = app(Tool::Brush); + app.sync_views(); + app.ui.views[0].rotation = rotation; + app.ui.view.flip_horizontal = flip; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, CTRL_ALT); + tool_event(&mut app, ToolEvent::Up { x: 100.0 + dx, y: 100.0 + dy }, CTRL_ALT); + let b = &app.session.tools.brush; + (b.size, b.hardness) + } + + /// #4228: the HUD is screen-relative, as in Photoshop: horizontal on screen is the size and + /// vertical the hardness however the view is rotated or flipped. + #[test] + fn a_rotated_view_keeps_horizontal_for_size_and_vertical_for_hardness() { + // Unrotated, with and without View › Flip Horizontal: unchanged. + assert_eq!(drag_in_view(0.0, false, 30.0, 0.0), (100.0, 0.2), "right grows"); + assert_eq!(drag_in_view(0.0, false, 0.0, 40.0), (40.0, 0.4), "down hardens"); + assert_eq!(drag_in_view(0.0, true, -30.0, 0.0), (100.0, 0.2), "flipped: screen right grows"); + assert_eq!(drag_in_view(0.0, true, 0.0, 40.0), (40.0, 0.4), "flipped: down hardens"); + // 90° clockwise: document +y points screen-left and document +x screen-down. + assert_eq!(drag_in_view(90.0, false, 0.0, 10.0), (20.0, 0.2), "screen-left shrinks, the hardness stays"); + assert_eq!(drag_in_view(90.0, false, 0.0, -30.0), (100.0, 0.2), "screen-right grows"); + assert_eq!(drag_in_view(90.0, false, 40.0, 0.0), (40.0, 0.4), "screen-down hardens, the size stays"); + // 180°: document -x is screen right. + assert_eq!(drag_in_view(180.0, false, -30.0, 0.0), (100.0, 0.2), "screen right still grows"); + assert_eq!(drag_in_view(180.0, false, 0.0, -40.0), (40.0, 0.4), "screen down still hardens"); + // 90° and flipped: document +y points screen-right. + assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows"); + } + /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). #[test] fn the_tip_preview_uses_the_brush_preview_colour() { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index 16332e4d08..db044557a9 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -89,11 +89,16 @@ fn dynamic(ui: &mut egui::Ui, id: &str, label: &str, d: &mut Dynamic, max: f32, pct(ui, label, &mut d.jitter, max); control_row(ui, id, d, angle); if let Some(m) = minimum { - pct(ui, m, &mut d.minimum, 1.0); + minimum_row(ui, m, d); } ui.add_space(6.0); } +/// Minimum belongs to the control. Off disables editing without discarding its saved value. +fn minimum_row(ui: &mut egui::Ui, label: &str, d: &mut Dynamic) { + ui.add_enabled_ui(d.control != Control::Off, |ui| pct(ui, label, &mut d.minimum, 1.0)); +} + /// Size: a value field over a logarithmic slider (small sizes get most of the travel). /// `restore` adds Photoshop's "Restore original size" button (sampled tips). fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32, restore: Option) { @@ -332,7 +337,7 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { let sd = &mut b.shape_dynamics; pct(ui, tl!("Size Jitter"), &mut sd.size.jitter, 1.0); control_row(ui, "brush-size-ctl", &mut sd.size, false); - pct(ui, tl!("Minimum Diameter"), &mut sd.size.minimum, 1.0); + minimum_row(ui, tl!("Minimum Diameter"), &mut sd.size); // Tilt Scale applies with the size control on Pen Tilt (greyed out otherwise, like Photoshop). let tilt = sd.size.control == Control::PenTilt; ui.add_enabled_ui(tilt, |ui| pct(ui, tl!("Tilt Scale"), &mut sd.tilt_scale, 2.0)); @@ -609,6 +614,127 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: #[cfg(test)] mod tests { use super::*; + use egui::accesskit::Role; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; + + fn minimum_brush() -> BrushSettings { + let mut b = BrushSettings::default(); + b.shape_dynamics.size.minimum = 0.2; + b.shape_dynamics.roundness.minimum = 0.35; + b.transfer.opacity.minimum = 0.4; + b.transfer.flow.minimum = 0.55; + b.transfer.wetness.minimum = 0.6; + b.transfer.mix.minimum = 0.75; + b + } + + fn section_harness(section: usize, b: BrushSettings) -> Harness<'static, BrushSettings> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(340.0, 1100.0)).build_ui_state( + move |ui, b: &mut BrushSettings| { + if !ready { + crate::PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); + ready = true; + return; + } + section_body(ui, b, section, &[], None); + }, + b, + ); + h.run_steps(3); + h + } + + fn set_controls(b: &mut BrushSettings, control: Control) { + for d in [ + &mut b.shape_dynamics.size, + &mut b.shape_dynamics.roundness, + &mut b.transfer.opacity, + &mut b.transfer.flow, + &mut b.transfer.wetness, + &mut b.transfer.mix, + ] { + d.control = control; + } + } + + /// #3825: all six minimum fields follow their own Control; jitter remains editable. + #[test] + fn minimum_fields_are_disabled_only_when_their_control_is_off() { + for (section, values) in [(1, vec![20.0, 35.0]), (6, vec![40.0, 55.0, 60.0, 75.0])] { + let mut h = section_harness(section, minimum_brush()); + for (control, _) in CONTROLS { + set_controls(h.state_mut(), control); + h.run_steps(2); + for value in &values { + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(*value)).expect("minimum input"); + assert_eq!(field.accesskit_node().is_disabled(), control == Control::Off, "section {section}, minimum {value}, {control:?}"); + } + assert!( + h.query_all_by_role(Role::SpinButton).any(|n| n.accesskit_node().numeric_value() == Some(0.0) && !n.accesskit_node().is_disabled()), + "jitter stays editable" + ); + } + set_controls(h.state_mut(), Control::PenPressure); + if section == 1 { + h.state_mut().shape_dynamics.size.control = Control::Off; + } else { + h.state_mut().transfer.opacity.control = Control::Off; + } + h.run_steps(2); + for (index, value) in values.iter().enumerate() { + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(*value)).unwrap(); + assert_eq!(field.accesskit_node().is_disabled(), index == 0, "each minimum follows its own control"); + } + } + } + + fn drag(h: &mut Harness<'static, BrushSettings>, from: egui::Pos2, to: egui::Pos2) { + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + + #[test] + fn disabled_minimum_ignores_input_and_keeps_its_value_for_reactivation() { + let mut h = section_harness(1, minimum_brush()); + let before = h.state().clone(); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(20.0)).unwrap(); + let center = field.rect().center(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("99".into())); + h.key_press(egui::Key::Enter); + // The slider below the number and its scrubby label are disabled too. + let track = pos2(300.0, center.y + 24.0); + drag(&mut h, track, track); + let label = h.get_by_label("Minimum Diameter").rect().center(); + drag(&mut h, label, label + vec2(60.0, 0.0)); + assert_eq!(h.state(), &before, "disabled interactions never rewrite brush settings"); + + h.state_mut().shape_dynamics.size.control = Control::PenPressure; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(20.0)).unwrap(); + assert!(!field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().shape_dynamics.size.minimum, 0.21, "active minimum is editable"); + drag(&mut h, track, track); + let minimum = h.state().shape_dynamics.size.minimum; + assert!(minimum > 0.8, "the active slider accepts the same pointer position: {minimum}"); + h.state_mut().shape_dynamics.size.control = Control::Off; + h.run_steps(2); + assert_eq!(h.state().shape_dynamics.size.minimum, minimum, "Off keeps the saved minimum"); + } #[test] fn ellipse_widget_drags_angle_and_roundness() { diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 31c1161b94..7a8d56b3d0 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -17,9 +17,7 @@ use egui::{Color32, RichText, Sense, Stroke, pos2, vec2}; use photocraft_engine::paint::{BrushPreset, MAX_BRUSH_SIZE}; use serde_json::json; -use crate::brush_panel::{ - BrushesPanelState, BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, new_preset_name, run_or_status, -}; +use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, run_or_status}; use crate::brush_preview; use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, icons, widgets}; @@ -975,9 +973,9 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { run_or_status(app, "brush.presets.delete", json!({ "name": name })); } - if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() { - let name = new_preset_name(&app.session.tools.presets); - run_or_status(app, "brush.presets.save", json!({ "name": name })); + let new = icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings"); + if crate::brush_picker::named(new, "Create new brush from the current settings").clicked() { + crate::brush_preset_dialog::open(app, None); } if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() { let _ = app.open_dialog_file(); diff --git a/crates/ui-egui/src/cam_preview.rs b/crates/ui-egui/src/cam_preview.rs new file mode 100644 index 0000000000..4065e3353c --- /dev/null +++ b/crates/ui-egui/src/cam_preview.rs @@ -0,0 +1,285 @@ +//! Content-Aware Move drags and its Transform On Drop box shown with the result: once the pointer +//! (or the box) rests for a moment, the move is computed as a background job +//! ([`photocraft_engine::retouch_cmds::content_aware_move_preview`]: a smaller sampling window than +//! the command and only small selections) and the canvas shows the document with it. Any change of +//! the offset, scale or rotation cancels the job and starts the wait again, so the UI never waits +//! for the blend. Releasing (or Enter) runs the exact `paint.contentAwareMove`; the document is +//! untouched until then. Not available on wasm (no threads). + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use photocraft_doc::{DocId, Document}; +use photocraft_engine::jobs::JobCtx; +use serde_json::Value; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// Preview keys: `BASE + n`, one per result shown (see `canvas::display_doc`). +const BASE: u64 = 1 << 45; +/// How long the move must stay unchanged before a preview is computed. +const DEBOUNCE_MS: f64 = 150.0; +/// How often a running job is polled for its result. +const POLL_MS: u64 = 40; + +struct Running { + key: String, + ctx: JobCtx, + rx: Receiver>, +} + +pub(crate) struct CamPreview { + doc: DocId, + revision: u64, + /// The params the move has now, and when they last changed (ms). + key: String, + since: f64, + /// Params whose preview failed (too large a selection, say): not tried again. + failed: Option, + running: Option, + /// The latest finished preview, with the params it shows. + shown: Option<(String, Arc)>, + serial: u64, +} + +impl CamPreview { + fn stop(&mut self) { + if let Some(r) = self.running.take() { + r.ctx.cancel(); + } + } +} + +impl Drop for CamPreview { + fn drop(&mut self) { + self.stop(); + } +} + +/// `paint.contentAwareMove`'s params for what the pointer or the box has now; `None` without a +/// move to preview. +fn wanted(app: &mut PhotocraftApp) -> Option { + if app.ui.tool != Tool::ContentAwareMove || cfg!(target_arch = "wasm32") { + return None; + } + if let Some(t) = &app.ui.cam_transform { + let place = crate::cam_transform::placement(t)?; + if place.offset == [0, 0] && place.is_plain_move() { + return None; + } + return Some(crate::cam_transform::params(app, &place)); + } + let d = app.drag.as_ref().filter(|d| d.tool == Tool::ContentAwareMove)?; + let (start, mods) = (d.start, d.modifiers); + let end = d.points.last().map_or(start, |p| [p[0], p[1]]); + if !crate::retouch_ui::patch_drags_selection(app, start, mods) { + return None; + } + let off = crate::retouch_ui::patch_offset(app, start, end); + (off != [0, 0]).then(|| crate::retouch_ui::content_aware_move_params(app, off.map(i64::from))) +} + +/// The document to show for the move on document `idx`: with the move applied, once its preview +/// has been computed for the current offset and transform. +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + if app.session.active_index() != Some(idx) { + return None; + } + step(app, crate::gpu_canvas::now_ms()) +} + +fn step(app: &mut PhotocraftApp, now: f64) -> Option<(Arc, u64)> { + let Some(params) = wanted(app) else { + app.cam_preview = None; + return None; + }; + let st = app.session.active()?; + let (doc, revision) = (st.doc.id, st.revision); + let key = params.to_string(); + if app.cam_preview.as_ref().is_some_and(|p| p.doc != doc || p.revision != revision) { + app.cam_preview = None; + } + let p = app.cam_preview.get_or_insert_with(|| CamPreview { + doc, + revision, + key: String::new(), + since: now, + failed: None, + running: None, + shown: None, + serial: 0, + }); + if p.key != key { + p.key = key.clone(); + p.since = now; + // What is computing is already out of date. + if p.running.as_ref().is_some_and(|r| r.key != key) { + p.stop(); + } + } + if let Some(r) = &p.running { + match r.rx.try_recv() { + Ok(result) => { + let r = p.running.take(); + match (result, r) { + (Ok(d), Some(r)) => { + // Duotone documents display through their inks. + let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d); + p.shown = Some((r.key, Arc::new(d))); + p.serial += 1; + } + (_, Some(r)) => p.failed = Some(r.key), + _ => {} + } + } + Err(TryRecvError::Disconnected) => { + if let Some(r) = p.running.take() { + p.failed = Some(r.key); + } + } + Err(TryRecvError::Empty) => {} + } + } + let settled = now - p.since >= DEBOUNCE_MS; + let done = p.shown.as_ref().is_some_and(|s| s.0 == key); + if settled && !done && p.running.is_none() && p.failed.as_deref() != Some(&key) { + match start(app, ¶ms, key.clone()) { + Some(r) => app.cam_preview.as_mut()?.running = Some(r), + None => app.cam_preview.as_mut()?.failed = Some(key.clone()), + } + } + let p = app.cam_preview.as_ref()?; + // Only a preview of the move as it is now: an older one would show a wrong place. + let (_, d) = p.shown.as_ref().filter(|s| s.0 == key)?; + Some((d.clone(), BASE + p.serial)) +} + +/// Compute the preview of `params` on a worker thread. +fn start(app: &PhotocraftApp, params: &Value, key: String) -> Option { + let job = photocraft_engine::retouch_cmds::content_aware_move_preview(&app.session, params).ok()?; + let ctx = JobCtx::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let worker = ctx.clone(); + std::thread::Builder::new() + .name("cam-preview".into()) + .spawn(move || { + let _ = tx.send(job.run(&worker).map(|(d, _)| d)); + }) + .ok()?; + Some(Running { key, ctx, rx }) +} + +/// Keep frames coming while a preview waits out its debounce or computes. +pub(crate) fn wake(app: &PhotocraftApp, ctx: &egui::Context) { + let Some(p) = &app.cam_preview else { return }; + if p.running.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(POLL_MS)); + } else if p.shown.as_ref().is_none_or(|s| s.0 != p.key) && p.failed.as_deref() != Some(&p.key) { + let left = (DEBOUNCE_MS - (crate::gpu_canvas::now_ms() - p.since)).max(1.0); + ctx.request_repaint_after(std::time::Duration::from_millis(left as u64)); + } +} + +/// Whether the canvas shows a preview. +pub(crate) fn showing(app: &PhotocraftApp) -> bool { + app.cam_preview.as_ref().is_some_and(|p| p.shown.as_ref().is_some_and(|s| s.0 == p.key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::{ToolEvent, tool_event}; + use serde_json::json; + + fn blue(d: &Document, x: i32, y: i32) -> bool { + d.layers[0].surface().unwrap().rgba(x, y)[0] < 0.5 + } + + #[test] + fn the_drag_previews_the_move_after_it_rests_and_never_touches_the_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("paint.pencil", json!({"points": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap(); + app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + let m = egui::Modifiers::NONE; + let steps = app.session.active().unwrap().history.past_len(); + + // Nothing to preview before the patch moves. + tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m); + assert!(step(&mut app, 0.0).is_none() && app.cam_preview.is_none()); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); + // Still moving: the wait starts and nothing is computed or shown yet. + assert!(step(&mut app, 1000.0).is_none()); + assert!(step(&mut app, 1000.0 + DEBOUNCE_MS / 2.0).is_none() && app.cam_preview.as_ref().unwrap().running.is_none()); + // At rest: the job runs, off this thread, and its result shows once it arrives. + let mut now = 1000.0 + DEBOUNCE_MS; + let mut shown = step(&mut app, now); + for _ in 0..600 { + if shown.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + now += 10.0; + shown = step(&mut app, now); + } + let (preview, key) = shown.expect("a preview once the pointer rested"); + assert!(key > BASE && showing(&app)); + assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30), "the preview shows the move"); + let doc = app.session.active().unwrap().doc.clone(); + assert!(blue(&doc, 22, 30) && !blue(&doc, 72, 30), "the document is untouched"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + + // Moving on drops the stale preview at once and cancels what is not shown yet. + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 30.0, pressure: 1.0 }, m); + assert!(step(&mut app, now + 5.0).is_none() && !showing(&app)); + // Release commits the exact move; the preview is gone. + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 30.0 }, m); + assert!(step(&mut app, now + 10.0).is_none() && app.cam_preview.is_none()); + } + + #[test] + fn the_transform_on_drop_box_previews_the_dropped_patch() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("paint.pencil", json!({"points": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap(); + app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + app.ui.tool_options.cam_transform_on_drop = true; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 30.0 }, m); + assert!(crate::cam_transform::active(&app)); + let (mut now, mut shown) = (0.0, step(&mut app, 0.0)); + for _ in 0..800 { + if shown.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + now += 10.0; + shown = step(&mut app, now); + } + let (preview, _) = shown.expect("the box shows the result"); + assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30)); + // Cancelling the box drops the preview. + crate::cam_transform::cancel(&mut app); + assert!(step(&mut app, now + 1.0).is_none() && app.cam_preview.is_none()); + } + + #[test] + fn a_selection_too_large_to_preview_is_not_retried() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 300, "height": 300})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 140.0, y: 100.0, pressure: 1.0 }, m); + assert!(step(&mut app, 0.0).is_none()); + assert!(step(&mut app, DEBOUNCE_MS + 1.0).is_none()); + let p = app.cam_preview.as_ref().unwrap(); + assert!(p.running.is_none() && p.failed.as_deref() == Some(p.key.as_str())); + } +} diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index 33912a28e8..a2afa8d812 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -59,6 +59,7 @@ pub(crate) fn begin(app: &mut PhotocraftApp, offset: [i32; 2]) -> bool { made: None, mode: Default::default(), content_aware: None, + skew_v: 0.0, }; app.ui.cam_transform = Some(CamTransform { document, revision, offset, frame, gesture: None }); app.ui.status = crate::i18n::fmt(tl!("Scale or rotate the moved area, then press {key}"), &[("key", &crate::shortcuts::pretty("Enter"))]); @@ -112,6 +113,17 @@ pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { } } +/// `paint.contentAwareMove`'s params for the box's placement and the options bar. +pub(crate) fn params(app: &PhotocraftApp, place: &Placement) -> serde_json::Value { + let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset); + if !place.is_plain_move() { + p["scaleX"] = place.scale[0].into(); + p["scaleY"] = place.scale[1].into(); + p["rotate"] = place.rotate.into(); + } + p +} + /// ↩ / ✓: the move with the transformed patch, as one background job and History step. pub(crate) fn commit(app: &mut PhotocraftApp) { cancel_stale(app); @@ -122,12 +134,7 @@ pub(crate) fn commit(app: &mut PhotocraftApp) { crate::retouch_ui::patch_hint(app); return; } - let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset); - if !place.is_plain_move() { - p["scaleX"] = place.scale[0].into(); - p["scaleY"] = place.scale[1].into(); - p["rotate"] = place.rotate.into(); - } + let p = params(app, &place); if let Err(e) = app.run("paint.contentAwareMove", p) { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/camera_raw_detail_ui.rs b/crates/ui-egui/src/camera_raw_detail_ui.rs index f8cbf66c0c..7b445f1775 100644 --- a/crates/ui-egui/src/camera_raw_detail_ui.rs +++ b/crates/ui-egui/src/camera_raw_detail_ui.rs @@ -341,17 +341,51 @@ fn visible_pixels(image: Rect, viewport: Rect, area: PixelRect, max_side: usize) (crop.width() as usize <= max_side && crop.height() as usize <= max_side && crop.width() as u64 * crop.height() as u64 <= 16_777_216).then_some(crop) } +/// Pixels per band [`display_image`] reads and colour-converts at once: at least `photocraft-cms`'s +/// parallel threshold (16K pixels), and small enough that a band's float scratch is 1 MiB. +const CROP_BAND_PIXELS: usize = 64 * 1024; + +/// The visible pixels of `surface`, as the canvas shows them. fn crop_image(surface: &Surface, area: PixelRect, display: &photocraft_engine::display_color::CanvasDisplay) -> ColorImage { - let width = area.width() as usize; - let mut row = vec![[0.0; 4]; width]; - let mut pixels = Vec::with_capacity(width * area.height() as usize); - for y in area.y0..area.y1 { - surface.read_rgba_into(PixelRect::new(area.x0, y, area.x1, y + 1), &mut row); - let buf = photocraft_compose::Buffer { rect: photocraft_geom::Rect::new(area.x0, y, area.x1, y + 1), px: row.clone() }; - let img = display.to_rgba8(&buf); - pixels.extend(img.pixels.as_chunks::<4>().0.iter().map(|&[r, g, b, a]| Color32::from_rgba_unmultiplied(r, g, b, a))); + display_image(area, display, |rect, out| surface.read_rgba_into(rect, out)) +} + +/// `area` as the canvas shows it, with `read` filling each band's float pixels. Converted in +/// bands of rows, in parallel off wasm, not one row or one whole copy at a time: a row is below +/// `photocraft-cms`'s parallel threshold, so a per-row conversion ran the display transform (any +/// document profile other than the monitor's, such as an Adobe RGB photo on sRGB) on one core of +/// the UI thread for every edit (#4682). +pub(crate) fn display_image( + area: PixelRect, + display: &photocraft_engine::display_color::CanvasDisplay, + read: impl Fn(PixelRect, &mut [[f32; 4]]) + Sync, +) -> ColorImage { + let (width, height) = (area.width() as usize, area.height() as usize); + let mut pixels = vec![Color32::TRANSPARENT; width * height]; + if width == 0 { + return ColorImage::new([width, height], pixels); } - ColorImage::new([width, area.height() as usize], pixels) + let band_rows = (CROP_BAND_PIXELS / width).max(1); + let band = |index: usize, out: &mut [Color32]| { + let y0 = area.y0.saturating_add((index * band_rows) as i32); + let rect = PixelRect::new(area.x0, y0, area.x1, y0.saturating_add((out.len() / width) as i32)); + let mut buf = photocraft_compose::Buffer { rect, px: vec![[0.0; 4]; out.len()] }; + read(rect, &mut buf.px); + let img = display.to_rgba8(&buf); + for (o, &[r, g, b, a]) in out.iter_mut().zip(img.pixels.as_chunks::<4>().0) { + *o = Color32::from_rgba_unmultiplied(r, g, b, a); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + pixels.par_chunks_mut(band_rows * width).enumerate().for_each(|(i, out)| band(i, out)); + } + #[cfg(target_arch = "wasm32")] + for (i, out) in pixels.chunks_mut(band_rows * width).enumerate() { + band(i, out); + } + ColorImage::new([width, height], pixels) } #[cfg(test)] @@ -467,4 +501,47 @@ mod tests { assert!(visible_pixels(image, Rect::from_min_size(Pos2::ZERO, vec2(8000.0, 6000.0)), area, 4096).is_none()); assert!(visible_pixels(Rect::NOTHING, visible, area, 4096).is_none()); } + + #[test] + fn banded_crop_matches_one_conversion_of_the_whole_area() { + // Several bands, a ragged last one and an area off the image origin, through a real + // transform (an Adobe RGB document on an sRGB monitor) and without one. + let area = PixelRect::new(-3, 5, 1021, 5 + 2 * (CROP_BAND_PIXELS / 1024) as i32 + 7); + let mut source = Surface::new(PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample: photocraft_color::SampleType::U8, alpha: true }); + let n = (area.width() * area.height()) as usize; + let px: Vec = (0..n).flat_map(|i| [(i % 251) as f32 / 255.0, (i % 241) as f32 / 255.0, (i % 239) as f32 / 255.0, (i % 7) as f32 / 6.0]).collect(); + source.write_region(area, &px); + for profile in [photocraft_cms::Builtin::AdobeRgbCompat, photocraft_cms::Builtin::Srgb] { + let mut doc = + photocraft_doc::Document::new("test", photocraft_geom::Size::new(1024, 1024), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + doc.icc_profile = Some(profile.profile().to_bytes()); + let mut color = photocraft_engine::color_cmds::ColorState::default(); + color.settings.monitor_profile = "srgb".into(); + let display = color.canvas_display(&doc).unwrap(); + assert_eq!(display.transform.is_some(), profile == photocraft_cms::Builtin::AdobeRgbCompat); + let mut whole = photocraft_compose::Buffer { rect: area, px: vec![[0.0; 4]; n] }; + source.read_rgba_into(area, &mut whole.px); + let expected = display.to_rgba8(&whole); + let image = crop_image(&source, area, &display); + assert_eq!(image.size, [area.width() as usize, area.height() as usize]); + let expected: Vec = expected.pixels.as_chunks::<4>().0.iter().map(|&[r, g, b, a]| Color32::from_rgba_unmultiplied(r, g, b, a)).collect(); + assert_eq!(image.pixels, expected, "{profile:?}"); + } + assert_eq!( + crop_image( + &source, + PixelRect::new(0, 0, 0, 4), + &photocraft_engine::color_cmds::ColorState::default() + .canvas_display(&photocraft_doc::Document::new( + "t", + photocraft_geom::Size::new(1, 1), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8 + )) + .unwrap() + ) + .size, + [0, 4] + ); + } } diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index c939714d9c..59ab86092d 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -7,7 +7,9 @@ //! one history step (or a smart filter on a smart object) and exactly replayable. //! //! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…}, -//! "commit":true | "cancel":true}}}`; the reply describes the dialog. +//! "commit":true | "cancel":true}}}`; the reply describes the dialog. `{"ui":{"saveSettings":true}}` +//! and `{"ui":{"loadSettings":true}}` run Save Settings… / Load Settings… (Camera Raw `.xmp` +//! settings files, see [`photocraft_io::camera_raw_xmp`]) through the file dialog. use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::camera_raw::{CameraRaw, Wheel, curve_lut}; @@ -122,9 +124,14 @@ impl CameraRawDialog { } fn image(px: &[[f32; 4]], w: usize, h: usize, display: &photocraft_engine::display_color::CanvasDisplay) -> egui::ColorImage { - let buf = photocraft_compose::Buffer { rect: Rect::new(0, 0, w as i32, h as i32), px: px.to_vec() }; - let img = display.to_rgba8(&buf); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) + // Bands copied straight from `px`, converted in parallel (#4682), not a whole copy of the + // proxy converted on one thread. + super::camera_raw_detail_ui::display_image(Rect::new(0, 0, w as i32, h as i32), display, |r, out| { + let start = (r.y0.max(0) as usize).saturating_mul(w); + if let Some(src) = px.get(start..start.saturating_add(out.len())) { + out.copy_from_slice(src); + } + }) } pub(crate) fn render(&mut self, ctx: &egui::Context) { @@ -669,14 +676,22 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val for (key, value) in fields { match key.as_str() { "set" | "scope" | "view" => {} - "before" | "commit" | "cancel" if value.is_boolean() => {} - "before" | "commit" | "cancel" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } if ui.get("commit").and_then(Value::as_bool) == Some(true) && ui.get("cancel").and_then(Value::as_bool) == Some(true) { return Some(Err("Camera Raw cannot commit and cancel together".into())); } + for (key, run) in [("saveSettings", save_settings as fn(&mut PhotocraftApp) -> Result), ("loadSettings", load_settings)] { + if ui.get(key).and_then(Value::as_bool) == Some(true) { + if fields.len() > 1 { + return Some(Err(format!("Camera Raw {key} must be sent on its own"))); + } + return Some(run(app)); + } + } let was_closed = app.camera_raw.is_none(); let previous_view = app.ui.camera_raw_scope.clone(); let previous_navigation = app.ui.camera_raw_preview; @@ -766,6 +781,52 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null))) } +/// Save Settings…: the dialog's settings as a Camera Raw `.xmp` settings file. +fn save_settings(app: &mut PhotocraftApp) -> Result { + let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; + let bytes = photocraft_io::camera_raw_xmp::save(&d.params).into_bytes(); + app.pick_save("Camera Raw Settings.xmp", move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + Ok(json!({"path": path})) + }) +} + +/// Load Settings…: applies a Camera Raw `.xmp` settings file's supported settings over the +/// dialog's; the status bar names how many were not supported. +fn load_settings(app: &mut PhotocraftApp) -> Result { + app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; + app.pick_file_bytes_filtered(&["xmp"], |app, name, bytes| { + let file = crate::file_open::display_name(&name); + let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?; + let mut next = d.params.clone(); + let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?; + if d.params.point_curve != next.point_curve { + d.curve_state = Default::default(); + } + d.dirty |= d.params != next; + d.params = next; + app.ui.status_error = false; + app.ui.status = if report.ignored.is_empty() { + crate::i18n::fmt(tl!("Loaded Camera Raw settings from {file}"), &[("file", &file)]) + } else { + let template = "Loaded Camera Raw settings from {file} ({n} not supported)"; + crate::i18n::fmt(tl!(template), &[("file", &file), ("n", &report.ignored.len().to_string())]) + }; + Ok(json!({"applied": report.applied, "ignored": report.ignored})) + }) +} + +fn report_file_error(app: &mut PhotocraftApp, r: Result) { + if let Err(e) = r + && e != crate::file_dialog::CANCELLED + { + app.ui.status_error = true; + app.ui.status = e; + } +} + fn commit(app: &mut PhotocraftApp) -> Result { let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; let result = match d.target { @@ -1133,6 +1194,13 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { fu.add_space(12.0); widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)")); fu.label(egui::RichText::new(format!("{:.0} ms", d.render_ms)).color(t.text_faint)); + fu.add_space(12.0); + if fu.button(tl!("Save Settings…")).on_hover_text(tl!("Save these settings as a Camera Raw .xmp file")).clicked() { + action = Some("save"); + } + if fu.button(tl!("Load Settings…")).on_hover_text(tl!("Load settings from a Camera Raw .xmp file")).clicked() { + action = Some("load"); + } }); super::camera_raw_scope_ui::persist(app, ctx); if ctx.input(|i| i.key_pressed(egui::Key::Y)) @@ -1150,6 +1218,14 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } } Some("cancel") => cancel(app), + Some("save") => { + let r = save_settings(app); + report_file_error(app, r); + } + Some("load") => { + let r = load_settings(app); + report_file_error(app, r); + } _ => {} } } @@ -1479,4 +1555,68 @@ mod tests { // A non-raw never queues the dialog; a closed document is an error, not a panic. assert!(open_raw(&mut app, &ctx, RawOpen { document: photocraft_doc::DocId(u64::MAX), name: "x".into(), path: None, bytes: None }).is_err()); } + + #[test] + fn save_and_load_settings_round_trip_through_xmp_files() { + use crate::file_dialog::FileDialogAnswer; + use std::sync::{Arc, Mutex}; + type Written = Arc)>>>; + let written: Written = Arc::default(); + let w = written.clone(); + let foreign = br#" + + "#; + let answers = vec![ + Some(FileDialogAnswer::SaveTo("/out/Look.xmp".into())), + Some(FileDialogAnswer::Contents("Look.xmp".into(), Vec::new())), + Some(FileDialogAnswer::Contents("Other.xmp".into(), foreign.to_vec())), + Some(FileDialogAnswer::Contents("Broken.xmp".into(), b" { @@ -80,7 +97,7 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32), color: tint, }; - if !overlay.clipped { + if !overlay.clipped || tip_rings.is_some() { for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] { mesh.vertices.push(vertex(f64::from(x), f64::from(y))); } @@ -680,6 +697,28 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { } } +/// The points the live retouching stroke has rendered. +#[cfg(test)] +pub(crate) fn live_retouch_points(app: &PhotocraftApp) -> Option> { + match &app.live_stroke.as_ref()?.stroke { + EngineStroke::Retouch(l) => Some(l.points().to_vec()), + _ => None, + } +} + +/// Offer the Clone Stamp's or History Brush's live stroke, with the drag points it hasn't rendered +/// yet, to the command `retouch_ui::finish_stroke` commits, which then takes its pixels. +fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) { + if !matches!(d.tool, Tool::CloneStamp | Tool::HistoryBrush) { + return; + } + let Some(LiveStroke { stroke: EngineStroke::Retouch(mut live), fed, .. }) = app.live_stroke.take() else { return }; + let rest: Vec<_> = d.points.get(fed..).unwrap_or_default().iter().map(|q| photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32)).collect(); + if rest.is_empty() || live.push(&rest).is_ok() { + let _ = app.session.offer_live_retouch(live); + } +} + /// A pen tap (a drag still at its one press point when released) paints its dab at the highest /// pressure the pen reached during the contact, not just the first sample's (#1798). A mouse is /// unchanged at full pressure. @@ -842,6 +881,15 @@ impl ViewXform { vec2((d.x * c + d.y * s) / self.sx(), -d.x * s + d.y * c) } + /// A document-space vector on screen: stretched, rotated, flipped and zoomed as the view + /// draws it (the inverse of [`Self::unmap_vec`], with the zoom). + pub fn map_vec(self, d: Vec2) -> Vec2 { + let (c, s) = self.sincos(); + let dx = d.x * self.sx(); + let v = vec2(dx * c - d.y * s, dx * s + d.y * c) * self.zoom; + if self.flip { vec2(-v.x, v.y) } else { v } + } + pub fn to_screen(&self, x: f32, y: f32) -> Pos2 { let (c, s) = self.sincos(); let dx = (x - self.center[0]) * self.sx(); @@ -856,6 +904,14 @@ impl ViewXform { let u = self.unmap_vec(d); [(u.x + self.center[0]) as f64, (u.y + self.center[1]) as f64] } + /// On-screen bounds of the image, including camera rotation and pixel aspect correction. + pub fn image_bounds(self, size: [u32; 2]) -> Rect { + let (c, s) = self.sincos(); + let w = size[0] as f32 * self.sx() * self.zoom; + let h = size[1] as f32 * self.zoom; + let center = self.to_screen(size[0] as f32 / 2.0, size[1] as f32 / 2.0); + Rect::from_center_size(center, vec2(c.abs() * w + s.abs() * h, s.abs() * w + c.abs() * h)) + } pub fn doc_rect(&self, r: DRect) -> Rect { let pts = [ self.to_screen(r.x0 as f32, r.y0 as f32), @@ -1020,6 +1076,10 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::patch_preview::display_doc(app, idx) { return shown; } + // Content-Aware Move drag or box: the result, once computed in the background. + if let Some(shown) = crate::cam_preview::display_doc(app, idx) { + return shown; + } // A blend mode hovered in the Layers panel. if let Some(shown) = crate::blend_preview::display_doc(app, idx) { return shown; @@ -1059,24 +1119,55 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar return (doc.clone(), key); } } - if let Some((layer, params)) = &app.live_adjust - && let Some(l) = st.doc.layer(*layer) + let snapshot = st.doc.clone(); + if let Some((layer, params)) = app.live_adjust.clone() + && let Some(l) = snapshot.layer(layer) && let LayerContent::Adjustment(a) = &l.content { - let kind = photocraft_engine::commands::adjustment_kind(a); - let preview = photocraft_engine::adjust_params::from_params(kind, params, Some(a), st.doc.mode).unwrap_or_else(|_| a.clone()); - let mut doc = (*st.doc).clone(); - if let Some(lm) = doc.layer_mut(*layer) { - lm.content = LayerContent::Adjustment(preview); - } let key = 1 + params.to_string().bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)); - return (std::sync::Arc::new(doc), key); + let shown = memo_display(app, &snapshot, key, || { + let kind = photocraft_engine::commands::adjustment_kind(a); + let preview = photocraft_engine::adjust_params::from_params(kind, ¶ms, Some(a), snapshot.mode).unwrap_or_else(|_| a.clone()); + let mut doc = (*snapshot).clone(); + if let Some(lm) = doc.layer_mut(layer) { + lm.content = LayerContent::Adjustment(preview); + } + Some(doc) + }); + if let Some(doc) = shown { + return (doc, key); + } } // Duotone documents display through their inks. - if let Some(shown) = photocraft_engine::mode_cmds::display_document(&st.doc) { - return (std::sync::Arc::new(shown), 1 << 41); + if let Some(shown) = memo_display(app, &snapshot, 1 << 41, || photocraft_engine::mode_cmds::display_document(&snapshot)) { + return (shown, 1 << 41); } - (st.doc.clone(), 0) + (snapshot, 0) +} + +/// `display_doc`'s live-adjust and Duotone documents, built once per document snapshot and key +/// rather than on each of its 3–4 calls a frame (#4527): each build clones the whole document. +fn memo_display( + app: &mut PhotocraftApp, + snapshot: &std::sync::Arc, + key: u64, + build: impl FnOnce() -> Option, +) -> Option> { + let id = snapshot.id; + if let Some((doc, k, shown)) = app.display_memo.get(&id) + && *k == key + && doc.ptr_eq(&std::sync::Arc::downgrade(snapshot)) + { + return Some(shown.clone()); + } + let Some(shown) = build().map(std::sync::Arc::new) else { + app.display_memo.remove(&id); + return None; + }; + // Entries of closed documents go when another is stored. + app.display_memo.retain(|_, (doc, _, _)| doc.strong_count() > 0); + app.display_memo.insert(id, (std::sync::Arc::downgrade(snapshot), key, shown.clone())); + Some(shown) } /// Longest side of the Navigator panel's image (about twice the panel's width, for HiDPI). @@ -1090,7 +1181,7 @@ const NAVIGATOR_SETTLE_MS: f64 = 200.0; /// of document `idx` and catches up on release: each step of the drag is a new preview, and a /// thumbnail composite of the whole document per step makes painting lag on large images. fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool { - crate::move_ui::showing(app) || crate::patch_preview::showing(app) || live_stroke(app, idx).is_some() + crate::move_ui::showing(app) || crate::patch_preview::showing(app) || crate::cam_preview::showing(app) || live_stroke(app, idx).is_some() } /// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture @@ -2626,7 +2717,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: fill_view(&mut view, &doc, rect.size(), ppp, aspect); } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) { ctx.request_repaint(); } let flip = app.ui.view.flip_horizontal; @@ -2784,6 +2875,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::pixel_grid::paint(app, &painter, &xf, idx, output, visible_doc_rect(&xf)); } + // Developer tile grid (`ui.set {"tileGrid": true}`): the active layer's storage tiles. + // Not tied to View › Extras (⌘H), which hides only the View › Show overlays. + if app.ui.view.tile_grid { + crate::tile_overlay::paint(app, &ctx, &painter, &xf, idx, visible_doc_rect(&xf)); + } + // View › Show › Layer Edges: drawn at this depth, but only once this frame's tool events are // in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer. let layer_edges = painter.add(egui::Shape::Noop); @@ -2872,6 +2969,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Under an open dialog the canvas widget is inert, but the image still pans and zooms. let under_dialog = !app.ui.dialogs.is_empty(); let free_hover = under_dialog && crate::dialogs::free_pointer_over(&ctx, rect).is_some(); + // A press on the canvas while a tool letter is held makes that tool temporary (#2672). + if !under_dialog && ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| rect.contains(p))) { + crate::spring_tools::note_canvas_press(app); + } // Navigation (wheel_nav.rs): scroll pans (⇧ or ⌘/Ctrl sideways); pinch and ⌥-scroll zoom around the pointer. let wheel = crate::wheel_nav::read(&ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch); @@ -2900,7 +3001,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad) { - let next = view.rotation + delta; + let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); } @@ -2956,10 +3057,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if under_dialog { // With a colour dialog picker armed the image is its eyedropper, whatever the tool; Space - // and the middle button still pan. Curves uses the same merged-composite sampler as the - // Color Picker, never the reduced adjustment preview. + // and the middle button still pan. Curves and Replace Color use the same merged-composite + // sampler as the Color Picker, never the reduced adjustment preview. let curves_picking = crate::adjust_dialog::picker_armed(app); - let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking); + let replace_picking = crate::filter_dialog::color_eyedropper_armed(app); + let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking || replace_picking); let range_picking = primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling); // Select and Mask's Refine Edge Brush paints on the image. @@ -2983,6 +3085,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let d = xf.to_doc(p); if curves_picking { crate::adjust_dialog::sample_at(app, d[0], d[1]); + } else if replace_picking { + crate::filter_dialog::color_eyedropper_sample(app, d[0], d[1]); } else { crate::color_picker_ui::sample_at(app, d[0], d[1]); } @@ -3020,7 +3124,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: view.center[1] -= d.y; } // Overscroll off: the glide stops at the pan limit, as the scrollbars do. - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) { ctx.data_mut(|m| m.get_temp_mut_or_default::(flick_id).stop()); } } @@ -3134,7 +3238,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::canvas_tool_menu::open_view(app, &ctx, tool, [p.x, p.y], xf.to_doc(p)); } } else { - crate::canvas_tool_menu::open(app, &ctx, tool, [p.x, p.y]); + crate::canvas_tool_menu::open_at(app, &ctx, tool, [p.x, p.y], Some(xf.to_doc(p))); } } // The (temporary) Hand pans above; its gestures never reach the tool underneath. While a @@ -3395,6 +3499,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let resizing = crate::brush_resize::draw(app, &painter, &xf); draw_transform_controls(app, &painter, &xf); crate::move_hover::draw(app, &painter, &xf, idx, response.hover_pos()); + crate::object_finder::draw(app, &painter, &xf, idx, response.hover_pos()); crate::layer_pick_ui::show(app, &ctx); let shown = app.ui.views.get(idx).cloned(); crate::canvas_tool_menu::show(app, &ctx); @@ -3415,6 +3520,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::type_tool::draw_overlay(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); crate::cam_transform::draw(app, &painter, &xf); + crate::cam_preview::wake(app, painter.ctx()); crate::distort_ui::draw_overlay(app, &painter, &xf); crate::perspective_crop_ui::draw_overlay(app, &painter, &xf); crate::retouch_ui::draw_source_marker(app, &painter, &xf); @@ -3435,16 +3541,28 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }); if let Some((vertical, _)) = guide_hover { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); - } else if let Some(c) = - response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command))) + } else if let Some(p) = response.hover_pos() + && let Some(c) = crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command)) { ui.ctx().set_cursor_icon(c); + // CursorIcon::None hides the pointer; the curved arrow has to be drawn or the + // rotate zone looks like a plain cursor (#4050). + let d = xf.to_doc(p); + if let Some(k) = crate::transform_tool::rotate_cursor_dir(app, d) { + let toward = xf.to_screen((d[0] + k[0] * 16.0) as f32, (d[1] + k[1] * 16.0) as f32) - p; + crate::crop_ui::draw_turn_cursor(&ctx, p, toward); + } } else if let Some(c) = response.hover_pos().and_then(|p| crate::cam_transform::cursor(app, &ctx, &xf, p)) { ui.ctx().set_cursor_icon(c); - } else if let Some(c) = response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p))) { + } else if let Some((r, p, c)) = + response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). ui.ctx().set_cursor_icon(c); + if let Some(toward) = transform_controls_turn(r, p) { + crate::crop_ui::draw_turn_cursor(&ctx, p, toward); + } } else if let Some(c) = response.hover_pos().filter(|_| tool.is_type()).and_then(|p| { let mods = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)); crate::type_transform::cursor(app, &ctx, &xf, p, mods) @@ -3546,41 +3664,61 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: egui::CursorIcon::None } // A shaped tip (sampled bitmap, or a rotated/flattened round tip) - // traces its outline; a plain round tip keeps the circle. + // traces its outline, turned and flipped with the view; a plain round + // tip keeps the circle. _ => { let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip); - crate::brush_cursor::cursor(&painter, p, brush, full, cur.painting == PaintingCursor::NormalTip, centre) + let axes = crate::brush_cursor::tip_axes(&xf, brush.size); + crate::brush_cursor::cursor(&painter, p, brush, full, axes, cur.painting == PaintingCursor::NormalTip, centre) } } } } // The Eyedropper: the comparison ring follows the pointer while it samples (#213); - // otherwise its pipette, or the crosshair for Precise Other Cursors. + // otherwise its pipette, or the crosshair for precise Other Cursors. Tool::Eyedropper => { if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) { icon - } else if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { + } else if precise_other_cursor(app) { egui::CursorIcon::Crosshair } else { pipette_cursor(ui.ctx(), p) } } - // The Color Sampler: a pipette, or the crosshair for Precise Other Cursors. + // The Color Sampler: a pipette, or the crosshair for precise Other Cursors. Tool::ColorSampler => { - if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { + if precise_other_cursor(app) { egui::CursorIcon::Crosshair } else { pipette_cursor(ui.ctx(), p) } } - // The Paint Bucket: a bucket, or the crosshair for Precise Other Cursors (#3968). + // The Paint Bucket: a bucket, or the crosshair for precise Other Cursors (#3968). Tool::PaintBucket => { - if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { + if precise_other_cursor(app) { egui::CursorIcon::Crosshair } else { paint_bucket_cursor(ui.ctx(), p) } } + // Over the first anchor, the close-path pointer (#4596), before the pens' own cursors. + Tool::Pen | Tool::CurvaturePen + if { + let [x, y] = xf.to_doc(p); + crate::vector_ui::closes_path_at(app, tool, x, y) + } => + { + crate::tool_cursor::close_path(&painter, p) + } + // The pen tools: their own icon with its nib on the point, or the crosshair for + // precise Other Cursors (#4639). + Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => { + if precise_other_cursor(app) { + egui::CursorIcon::Crosshair + } else { + pen_cursor(ui.ctx(), tool, p) + } + } // Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool. Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => @@ -3634,6 +3772,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some(b) = layer_edges_rect(app, idx) { painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); } + // A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the + // drag's next move then extends into what scrolled in. + if primary + && app.drag.is_some() + && tool != Tool::Hand + && !middle + && let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten()) + { + let step = crate::canvas_autoscroll::edge_step(p, rect, ui.input(|i| i.stable_dt)); + if crate::canvas_autoscroll::scroll(&mut view, step, xf) { + ctx.request_repaint(); + } + } // Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there. let t0 = crate::gpu_canvas::now_ms(); let before = view.center; @@ -4222,7 +4373,28 @@ fn transform_controls_cursor(r: Rect, p: Pos2) -> Option { }); } } - (r.expand(18.0).contains(p) && !r.expand(5.0).contains(p)).then_some(egui::CursorIcon::Alias) + // The rotation band: the pointer gives way to the curved rotate arrow (`transform_controls_turn`). + in_rotation_band(r, p).then_some(egui::CursorIcon::None) +} + +fn in_rotation_band(r: Rect, p: Pos2) -> bool { + r.expand(18.0).contains(p) && !r.expand(5.0).contains(p) +} + +/// In the rotation band just outside the Move tool's transform controls, the screen direction the +/// curved rotate arrow bends round: into the box along the nearest corner's bisector, as Free +/// Transform draws it (#4658). Before, the band showed a plain alias pointer, so nothing said a +/// drag there rotates. +fn transform_controls_turn(r: Rect, p: Pos2) -> Option { + if !in_rotation_band(r, p) + || [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] + .into_iter() + .any(|h| h.distance(p) <= crate::transform_tool::HANDLE_PX as f32) + { + return None; + } + let corner = pos2(if p.x < r.center().x { r.left() } else { r.right() }, if p.y < r.center().y { r.top() } else { r.bottom() }); + Some(vec2((r.center().x - corner.x).signum(), (r.center().y - corner.y).signum()).normalized() * 16.0) } /// A visible handle starts scaling; the narrow band just outside the box starts rotation. @@ -4394,6 +4566,26 @@ pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool { mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket) } +/// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the +/// standard cursor for it (and back), as in Photoshop (#4639). +fn precise_other_cursor(app: &PhotocraftApp) -> bool { + (app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise) != app.caps_lock +} + +/// A pen tool's standard cursor: its toolbar icon with the nib (the Convert Point tool: the +/// anchor) on `p`. Draws it at `p` and returns the OS cursor to set (hidden). +pub(crate) fn pen_cursor(ctx: &egui::Context, tool: Tool, p: Pos2) -> egui::CursorIcon { + // Where each icon's point is in its 24-unit box: the full-size pen's nib at (2.3, 2.3); the + // Freeform and Curvature pens draw it at 0.68 × from (7, 7); Convert Point's apex anchor. + let hot = match tool { + Tool::FreeformPen | Tool::CurvaturePen => vec2(8.56, 8.56), + Tool::ConvertPoint => vec2(12.0, 7.0), + _ => vec2(2.3, 2.3), + }; + crate::icons::cursor(ctx, crate::icons::tool_icon(tool), p, hot / 24.0, 22.0); + egui::CursorIcon::None +} + /// The sampling cursor: a pipette whose tip is the sampled pixel. Draws it at `p` and returns the /// OS cursor to set (hidden). pub(crate) fn pipette_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon { @@ -4836,10 +5028,16 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) // Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush. let clear = crate::paint_mouse::clears(app, tool); let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear; + // A 1 px pencil line depends on the pixels, not where inside the pixel the click + // landed (#4071). Snap both ends of the stroke to the aliased tip's grid. + let (x, y) = snap_aliased_tip(app, tool, x, y); // ⇧-click connects to the last endpoint, including its pen pressure (#2827), so // the renderer interpolates size and opacity across the segment. let active = app.session.active().map(|st| st.doc.id); - let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| p); + let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| { + let (sx, sy) = snap_aliased_tip(app, tool, p[0], p[1]); + [sx, sy, p[2]] + }); let mut points = vec![[x, y, pressure as f64]]; if let Some(p) = from { points.insert(0, p); @@ -5125,8 +5323,22 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } } +/// Pencil (and a Pencil-mode Eraser) dabs sit on the pixel grid. Clicks anywhere inside one +/// pixel must produce the same point, or a ⇧-click line changes with the sub-pixel position. +fn snap_aliased_tip(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> (f64, f64) { + let pencil = tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool); + if !pencil { + return (x, y); + } + let (cx, cy) = photocraft_engine::paint::grid_center(x, y, app.session.tools.brush.size); + (f64::from(cx), f64::from(cy)) +} + pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { - let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + let mut end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]); + end[0] = sx; + end[1] = sy; if let Some(floating) = d.sel_move { finish_selection_drag(app, floating, d.start, [end[0], end[1]]); return; @@ -5137,11 +5349,15 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { { app.last_stroke_end = Some((st.doc.id, end)); } - // A live Clone Stamp preview ends here; the commit below replaces it. + // A live Clone Stamp preview ends here; the commit below replaces it (the Clone Stamp's and + // History Brush's with the pixels it already drew). + offer_live_retouch(app, &d); if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement { app.live_stroke = None; } - if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) { + let finished = crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers); + app.session.clear_live_stroke(); + if finished { return; } match d.tool { @@ -5149,7 +5365,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight())), // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. t if crate::vector_ui::is_shape_tool(t) - && crate::shape_dialog::is_click(d.start, &d.points, app.current_zoom()) + && crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom()) && crate::shape_dialog::open(app, t, d.start).is_some() => {} t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live), Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]), @@ -5361,7 +5577,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { } } -/// "mask" when the Layers panel targets (or the canvas shows) the active layer's mask, else "pixels". +/// "mask" when the Layers panel targets (or the canvas shows) the active layer's mask, or the layer +/// is an adjustment layer with one (it has no pixels: #4298), else "pixels". pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.active() else { return json!("pixels") }; @@ -5375,7 +5592,8 @@ pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); // Viewing the mask (⌥-click its thumbnail, #196) paints the mask. let viewing = photocraft_engine::mask_view_cmds::current(st).is_some(); - json!(if (app.ui.mask_target || viewing) && has_mask { "mask" } else { "pixels" }) + let mask = app.ui.mask_target || viewing || crate::adjustment_layer_active(st); + json!(if mask && has_mask { "mask" } else { "pixels" }) } #[cfg(test)] @@ -6039,6 +6257,39 @@ mod tests { assert_eq!(centre("lighten"), Color32::WHITE); } + /// A shaped tip clips the Clone Source overlay to its outline, not to the brush circle. + #[test] + fn clone_overlay_is_clipped_to_a_shaped_tip() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.session.tools.brush.size = 20.0; + app.session.tools.brush.roundness = 0.25; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test"))); + draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); + }); + let image = output + .textures_delta + .set + .values() + .flatten() + .find_map(|d| match &d.image { + egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), + _ => None, + }) + .expect("the overlay uploads a texture"); + output.textures_delta.clear(); + let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a(); + assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source"); + assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped"); + assert_eq!(alpha(1, 10), 255, "the tip's long axis stays"); + } + #[test] fn wayland_start_screen_hint_does_not_claim_file_drop_works() { let x11 = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -6217,6 +6468,41 @@ mod tests { assert!(app.session.tools.foreground[1] > 0.99 && app.session.tools.foreground[0] < 0.01); } + /// #4298: an adjustment layer has no pixels, so after its icon thumbnail is clicked (the + /// Layers panel no longer targets the mask) Fill, the fill keys and the Paint Bucket still + /// edit its mask, as in Photoshop, instead of failing with "not a pixel layer". + #[test] + fn fills_on_an_adjustment_layer_edit_its_mask_when_the_icon_is_targeted() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); + // Click the Curves icon thumbnail: the panel targets the layer, not its mask. + app.ui.mask_target = false; + app.sync_mask_targets(); + assert!(!app.ui.mask_target, "the panel's target is the icon"); + let mask_at = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(10, 10, 0) + }; + assert_eq!(paint_target(&app), json!("mask")); + assert!(crate::menus::is_enabled(&app, "edit.fill"), "Edit > Fill (⇧F5) is enabled"); + assert!(crate::menus::is_enabled(&app, "edit.fillForeground")); + app.ui.tool = Tool::PaintBucket; + app.run("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0], "background": [1.0, 1.0, 1.0, 1.0]})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 10.0, y: 10.0 }, egui::Modifiers::NONE); + assert_eq!(mask_at(&app), 0.0, "the Paint Bucket filled the mask black"); + app.run("edit.fill", json!({"contents": "white"})).unwrap(); + assert_eq!(mask_at(&app), 1.0, "Edit > Fill filled the mask white"); + assert!(!app.ui.mask_target, "filling doesn't move the panel's target"); + // A pixel layer with an untargeted mask still paints its pixels. + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = false; + assert_eq!(paint_target(&app), json!("pixels")); + assert!(!app.layer_mask_targeted()); + } + #[test] fn alt_with_a_painting_tool_samples_the_foreground_instead_of_painting() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -6431,12 +6717,12 @@ mod tests { let (shown, key) = display_doc(&mut app, 0); assert_ne!(key, 0); // A partial value two thirds of the way out: the soft tip, not a hard flat stand-in. - assert!(alpha(&shown, 40, 30) > 0.5 && (0.01..0.5).contains(&alpha(&shown, 40, 36)), "soft stroke while drawing"); + assert!(alpha(&shown, 40, 30) > 0.5 && (0.01..0.95).contains(&alpha(&shown, 40, 36)), "soft stroke while drawing"); assert_eq!(alpha(&app.session.documents()[0].doc, 40, 30), 0.0, "not committed yet"); // The canvas redraws only what the stroke touched. let dk = canvas_display(&app, &app.session.documents()[0].doc, None).1; let d = damage_since(&app, 0, (rev, dk), (rev, key), dk, None).unwrap(); - assert!(d.contains(40, 30) && !d.contains(40, 2) && d.width() < 120, "{d:?}"); + assert!(d.contains(40, 30) && !d.contains(40, 2) && d.height() < 60, "{d:?}"); tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 30.0 }, m); let doc = app.session.documents()[0].doc.clone(); assert!(app.live_stroke.is_none() && display_doc(&mut app, 0).1 == 0); @@ -6897,7 +7183,9 @@ mod tests { } } assert!(soft > 50, "a soft brush shows a soft edge"); - assert_eq!(trail.coverage(50, 30 + 10), 0, "half pressure halves the 30 px tip"); + // Half pressure halves the 30 px tip: nothing past its (soft) reach. + let reach = photocraft_paint::tip_reach(7.5, app.session.tools.brush.hardness).ceil() as usize + 2; + assert_eq!(trail.coverage(50, 30 + reach), 0, "half pressure halves the 30 px tip"); } #[test] @@ -7133,14 +7421,23 @@ mod transform_controls_tests { (r.center_bottom(), egui::CursorIcon::ResizeVertical), (r.right_center(), egui::CursorIcon::ResizeHorizontal), (r.left_center(), egui::CursorIcon::ResizeHorizontal), - // Between handles: the rotation band (a point above the top-centre handle is that handle). - (pos2(r.left() + r.width() * 0.25, r.top() - 12.0), egui::CursorIcon::Alias), + // Between handles: the rotation band (a point above the top-centre handle is that handle), + // where the curved rotate arrow replaces the pointer (#4658). + (pos2(r.left() + r.width() * 0.25, r.top() - 12.0), egui::CursorIcon::None), ] { assert_eq!(transform_controls_cursor(r, p), Some(expected), "hover at {p:?}"); assert!(transform_controls_hit(r, p), "same region must start a transform"); } assert_eq!(transform_controls_cursor(r, r.center()), None, "interior stays Move"); assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), None); + // The rotate arrow bends round the nearest corner, pointing into the box; handles and the + // interior have none. + let into = |p: Pos2| transform_controls_turn(r, p).map(|v| (v.x.signum(), v.y.signum())); + assert_eq!(into(pos2(r.left() + r.width() * 0.25, r.top() - 12.0)), Some((1.0, 1.0)), "top left"); + assert_eq!(into(pos2(r.right() + 12.0, r.bottom() - r.height() * 0.25)), Some((-1.0, -1.0)), "bottom right"); + assert_eq!(into(pos2(r.left() - 12.0, r.bottom() + 12.0)), Some((1.0, -1.0)), "beyond the bottom-left corner"); + assert_eq!(into(r.left_top()), None); + assert_eq!(into(r.center()), None); // Changing the active shape changes the hover bounds without requiring a drag first. app.session.execute("shape.create", json!({"kind": "rect", "rect": [120, 100, 40, 50], "fill": "#00ff00"})).unwrap(); @@ -7402,3 +7699,7 @@ mod rotation_preview_isolation_tests { assert!(ensure_filter_preview(&mut app, 0, &egui::Context::default()).is_none(), "an inactive tab must never inherit the command preview"); } } + +#[cfg(test)] +#[path = "canvas_display_memo_tests.rs"] +mod display_memo_tests; diff --git a/crates/ui-egui/src/canvas_autoscroll.rs b/crates/ui-egui/src/canvas_autoscroll.rs new file mode 100644 index 0000000000..2fafbdb105 --- /dev/null +++ b/crates/ui-egui/src/canvas_autoscroll.rs @@ -0,0 +1,223 @@ +//! Auto-scroll while a tool drag runs past the canvas edge (#4183). As in Photoshop, dragging a +//! marquee, lasso, brush stroke or crop off the visible part of a zoomed image scrolls the view +//! toward the pointer, so a selection can be larger than what is on screen. The view stops at the +//! scroll limits (it never auto-scrolls an image that already fits into empty pasteboard). + +use crate::canvas::ViewXform; +use crate::state::View; +use egui::{Pos2, Rect, Vec2, vec2}; + +/// Slowest and fastest auto-scroll, in egui points per second. +const MIN_SPEED: f32 = 120.0; +const MAX_SPEED: f32 = 2400.0; +/// Extra speed per point the pointer is past the edge. +const GAIN: f32 = 12.0; +/// A long frame moves at most this many seconds' worth, so a stall doesn't jump the view. +const MAX_DT: f32 = 0.05; + +/// How far, in screen points, to scroll this frame for a drag whose pointer is at `pointer` over a +/// canvas of `rect`. Zero while the pointer is inside the canvas. Each axis points toward the +/// pointer and speeds up the further past the edge it is. +pub fn edge_step(pointer: Pos2, rect: Rect, dt: f32) -> Vec2 { + if !pointer.x.is_finite() || !pointer.y.is_finite() || !dt.is_finite() || dt <= 0.0 || rect.width() <= 0.0 || rect.height() <= 0.0 { + return Vec2::ZERO; + } + let past = |p: f32, lo: f32, hi: f32| { + if p < lo { + p - lo + } else if p > hi { + p - hi + } else { + 0.0 + } + }; + let dt = dt.min(MAX_DT); + let axis = |d: f32| if d == 0.0 { 0.0 } else { d.signum() * (MIN_SPEED + GAIN * d.abs()).min(MAX_SPEED) * dt }; + vec2(axis(past(pointer.x, rect.left(), rect.right())), axis(past(pointer.y, rect.top(), rect.bottom()))) +} + +/// Move the camera by `step` in screen points, never past the image's on-screen bounds in the +/// direction of travel. A view the user already panned past a limit (Overscroll on) is not pulled +/// back: it just doesn't scroll further out. True when the view moved. +pub fn scroll(view: &mut View, step: Vec2, xf: ViewXform) -> bool { + if !step.is_finite() || step == Vec2::ZERO || !xf.zoom.is_finite() || xf.zoom <= 0.0 { + return false; + } + let image = xf.image_bounds(view.doc_size); + if !image.is_finite() || !xf.rect.is_finite() { + return false; + } + // Compare screen axes before unrotating: a document axis that fits before rotation may + // be clipped afterwards, and a vertical screen drag must remain vertical at any angle. + let axis = |step: f32, lo: f32, hi: f32, v0: f32, v1: f32| { + if hi - lo <= v1 - v0 { + 0.0 + } else if step > 0.0 { + step.min((hi - v1).max(0.0)) + } else { + step.max((lo - v0).min(0.0)) + } + }; + let step = vec2( + axis(step.x, image.left(), image.right(), xf.rect.left(), xf.rect.right()), + axis(step.y, image.top(), image.bottom(), xf.rect.top(), xf.rect.bottom()), + ); + let d = xf.unmap_vec(step) / xf.zoom; + let moved = d.is_finite() && (d.x.abs() > 1e-3 || d.y.abs() > 1e-3); + if moved { + view.center[0] += d.x; + view.center[1] += d.y; + } + moved +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::pos2; + + // Existing unrotated cases express their requested movement in document pixels. + fn scroll(view: &mut View, doc_step: Vec2, size: Vec2, ppp: f32) -> bool { + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::ZERO, size), + zoom: view.zoom / ppp, + center: view.center, + flip: false, + rotation: view.rotation, + aspect: 1.0, + }; + super::scroll(view, doc_step * xf.zoom, xf) + } + + fn rect() -> Rect { + Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 350.0)) + } + + #[test] + fn inside_the_canvas_does_not_scroll() { + assert_eq!(edge_step(pos2(300.0, 200.0), rect(), 1.0 / 60.0), Vec2::ZERO); + assert_eq!(edge_step(pos2(100.0, 350.0), rect(), 1.0 / 60.0), Vec2::ZERO, "the edge itself is inside"); + } + + #[test] + fn past_an_edge_scrolls_toward_the_pointer() { + let dt = 1.0 / 60.0; + let right = edge_step(pos2(520.0, 200.0), rect(), dt); + assert!(right.x > 0.0 && right.y == 0.0, "{right:?}"); + let left = edge_step(pos2(80.0, 200.0), rect(), dt); + assert!(left.x < 0.0 && left.y == 0.0, "{left:?}"); + let corner = edge_step(pos2(90.0, 360.0), rect(), dt); + assert!(corner.x < 0.0 && corner.y > 0.0, "{corner:?}"); + } + + #[test] + fn further_out_is_faster_up_to_a_cap() { + let dt = 1.0 / 60.0; + let near = edge_step(pos2(505.0, 200.0), rect(), dt).x; + let far = edge_step(pos2(600.0, 200.0), rect(), dt).x; + let very_far = edge_step(pos2(50_000.0, 200.0), rect(), dt).x; + assert!(near < far, "{near} < {far}"); + assert!((very_far - MAX_SPEED * dt).abs() < 1e-3, "capped: {very_far}"); + } + + #[test] + fn hostile_input_does_not_scroll() { + let dt = 1.0 / 60.0; + assert_eq!(edge_step(pos2(f32::NAN, 200.0), rect(), dt), Vec2::ZERO); + assert_eq!(edge_step(pos2(f32::INFINITY, 200.0), rect(), dt), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), rect(), f32::NAN), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), rect(), -1.0), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), Rect::NOTHING, dt), Vec2::ZERO); + // A multi-second stall moves no more than one capped frame. + assert!(edge_step(pos2(600.0, 200.0), rect(), 10.0).x <= MAX_SPEED * MAX_DT + 1e-3); + } + + fn view(zoom: f32, center: [f32; 2]) -> View { + View { zoom, center, doc_size: [2000, 1000], ..Default::default() } + } + + #[test] + fn a_zoomed_view_scrolls_and_stops_at_the_limit() { + // 2000 × 1000 at 100 % in a 400 × 300 canvas: the centre can range over 200..1800 in x. + let size = vec2(400.0, 300.0); + let mut v = view(1.0, [1000.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1050.0, 500.0]); + let mut v = view(1.0, [1790.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1800.0, 500.0], "stops at the right limit"); + assert!(!scroll(&mut v, vec2(50.0, 0.0), size, 1.0), "and stays there"); + } + + #[test] + fn an_image_that_fits_does_not_scroll() { + let mut v = view(0.1, [1000.0, 500.0]); + assert!(!scroll(&mut v, vec2(50.0, 50.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1000.0, 500.0]); + // Wider than the view but not taller: only x scrolls. + let mut v = view(0.25, [1000.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 50.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1050.0, 500.0]); + } + + #[test] + fn an_overscrolled_view_is_not_pulled_back() { + // Already panned past the right limit (Overscroll on): scrolling further right does + // nothing, scrolling left moves normally. + let size = vec2(400.0, 300.0); + let mut v = view(1.0, [1900.0, 500.0]); + assert!(!scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1900.0, 500.0]); + assert!(scroll(&mut v, vec2(-50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1850.0, 500.0]); + } + + #[test] + fn non_finite_steps_are_ignored() { + let mut v = view(1.0, [1000.0, 500.0]); + assert!(!scroll(&mut v, vec2(f32::NAN, 0.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1000.0, 500.0]); + } + + #[test] + fn quarter_turn_limits_follow_the_displayed_image_with_hidpi_and_pixel_aspect() { + for rotation in [-90.0, 90.0] { + for flip in [false, true] { + let mut v = View { zoom: 2.0, center: [300.0, 600.0], doc_size: [600, 1200], rotation, ..Default::default() }; + let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0)), zoom: 1.0, center: v.center, flip, rotation, aspect: 2.0 }; + assert!(super::scroll(&mut v, vec2(0.0, 10_000.0), xf)); + // At 2:1 pixel aspect the document's 600-pixel width is 1200 screen points + // tall after rotation. The 400-point viewport exposes 200 document pixels. + let want = if rotation > 0.0 { 500.0 } else { 100.0 }; + assert!((v.center[0] - want).abs() < 0.01, "{rotation}, flip={flip}: {:?}", v.center); + assert!((v.center[1] - 600.0).abs() < 0.01); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 2.0, 2.0), "the next frame preserves the auto-scroll limit"); + let xf = ViewXform { center: v.center, ..xf }; + assert!(!super::scroll(&mut v, vec2(0.0, 20.0), xf)); + v.center = [300.0, 600.0]; + let xf = ViewXform { center: v.center, ..xf }; + assert!(super::scroll(&mut v, vec2(10_000.0, 0.0), xf)); + let want_y = if (rotation > 0.0) == flip { 800.0 } else { 400.0 }; + assert!((v.center[1] - want_y).abs() < 0.01 && (v.center[0] - 300.0).abs() < 0.01, "mirrored horizontal limit: {:?}", v.center); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 2.0, 2.0)); + } + } + } + + #[test] + fn oblique_rotation_keeps_a_vertical_scroll_vertical_and_a_fitting_axis_still() { + for flip in [false, true] { + let mut v = View { zoom: 1.0, center: [300.0, 300.0], doc_size: [600, 600], rotation: 45.0, ..Default::default() }; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(900.0, 400.0)), zoom: 1.0, center: v.center, flip, rotation: v.rotation, aspect: 1.0 }; + // A 600-pixel square at 45 degrees is about 849 points across: it fits horizontally + // but is clipped vertically. Gating document axes would produce a diagonal drift. + assert!(!super::scroll(&mut v, vec2(30.0, 0.0), xf)); + assert!(super::scroll(&mut v, vec2(0.0, 30.0), xf)); + let after = ViewXform { center: v.center, ..xf }; + let displacement = after.to_screen(300.0, 300.0) - xf.to_screen(300.0, 300.0); + assert!(displacement.x.abs() < 0.01 && (displacement.y + 30.0).abs() < 0.01, "{displacement:?}"); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 1.0, 1.0)); + } + } +} diff --git a/crates/ui-egui/src/canvas_display_memo_tests.rs b/crates/ui-egui/src/canvas_display_memo_tests.rs new file mode 100644 index 0000000000..ec105be015 --- /dev/null +++ b/crates/ui-egui/src/canvas_display_memo_tests.rs @@ -0,0 +1,46 @@ +//! `display_doc` builds a Duotone or live-adjust document once per snapshot (#4527), not on each +//! of its calls in a frame. + +use super::*; +use serde_json::json; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48, "mode": "gray"})).unwrap(); + app +} + +#[test] +fn duotone_display_document_is_built_once_per_snapshot() { + let mut app = app(); + app.run("image.mode.duotone", json!({"type": "duotone", "inks": [{"name": "Black"}, {"name": "Orange", "color": "#ff8000"}]})).unwrap(); + let (a, key) = display_doc(&mut app, 0); + assert_eq!(key, 1 << 41); + let (b, _) = display_doc(&mut app, 0); + assert!(std::sync::Arc::ptr_eq(&a, &b), "the second call reuses the first's document"); + assert_eq!(a.layers.len(), app.session.documents()[0].doc.layers.len() + 1, "with the ink display layer"); + app.run("edit.fill", json!({"color": "#808080"})).unwrap(); + let (c, _) = display_doc(&mut app, 0); + assert!(!std::sync::Arc::ptr_eq(&a, &c), "an edit builds it again"); + assert_eq!(c.layers.last().map(|l| l.name.as_str()), a.layers.last().map(|l| l.name.as_str())); +} + +#[test] +fn live_adjust_document_is_built_once_per_setting() { + let mut app = app(); + app.run("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + let id = app.session.documents()[0].active_layer.unwrap(); + app.live_adjust = Some((id, json!({"inBlack": 20}))); + let (a, k1) = display_doc(&mut app, 0); + let (b, k2) = display_doc(&mut app, 0); + assert_eq!(k1, k2); + assert!(std::sync::Arc::ptr_eq(&a, &b)); + app.live_adjust = Some((id, json!({"inBlack": 40}))); + let (c, k3) = display_doc(&mut app, 0); + assert_ne!(k1, k3); + assert!(!std::sync::Arc::ptr_eq(&a, &c), "a new setting builds it again"); + app.live_adjust = None; + let (d, k4) = display_doc(&mut app, 0); + assert_eq!(k4, 0); + assert!(std::sync::Arc::ptr_eq(&d, &app.session.documents()[0].doc), "no preview: the document itself"); +} diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index b6b86111e1..e1466f09ad 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -31,8 +31,30 @@ pub struct CanvasToolMenu { /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, + /// The misspelled word under the pointer when the type edit menu opened: its suggestions head + /// the menu. + #[serde(default)] + pub spelling: Option, } +/// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Spelling { + pub layer: u64, + /// The word's characters `start..end` in the layer's text. + pub start: usize, + pub end: usize, + pub word: String, + pub suggestions: Vec, +} + +/// Most suggestions the type edit menu lists. +const MAX_SUGGESTIONS: usize = 3; +/// The spelling rows' ids: `spelling.replace.` picks the n-th suggestion. +const SPELLING_REPLACE: &str = "spelling.replace."; +const SPELLING_NONE: &str = "spelling.none"; +const SPELLING_ADD: &str = "spelling.add"; + /// One menu row: label and command id. `None` is a separator. pub type Row = Option<(&'static str, &'static str)>; @@ -275,12 +297,12 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { /// Is `command` one of the open menu's rows, and enabled? pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { - rows(menu).iter().flatten().any(|(_, id)| *id == command) && entry_enabled(app, menu, command) + (rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command)) && entry_enabled(app, menu, command) } /// Is `command` the transform box's current mode (checked in the transform menu)? -/// Scale and Rotate are Free Transform's mode, so only Free Transform shows it checked. Warp is -/// not a box mode (it is its own session), and `for_command` would read it as Free. +/// Scale stays Free Transform's mode (only Free Transform is checked). Rotate is its own mode. +/// Warp is not a box mode (it is its own session), and `for_command` would read it as Free. pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { if menu.pasteboard { return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color); @@ -289,7 +311,7 @@ pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: return type_checked(app, command); } menu.transform - && matches!(command, "edit.freeTransform" | "edit.transform.skew" | "edit.transform.distort" | "edit.transform.perspective") + && matches!(command, "edit.freeTransform" | "edit.transform.rotate" | "edit.transform.skew" | "edit.transform.distort" | "edit.transform.perspective") && app.ui.transform.as_ref().is_some_and(|t| t.mode == crate::state::TransformMode::for_command(command)) } @@ -311,6 +333,49 @@ pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool { tool.is_type() && app.ui.text_edit.is_some() } +/// The misspelled word of the edited text under document point `doc`, with its suggestions. +fn spelling_at(app: &mut PhotocraftApp, doc: [f64; 2]) -> Option { + let layer = app.ui.text_edit.as_ref()?.layer; + if !doc.iter().all(|v| v.is_finite()) { + return None; + } + let at = crate::type_tool::offset_at(app, photocraft_doc::LayerId(layer), doc[0], doc[1]); + let found = app.session.execute("edit.checkSpelling", json!({"action": "list", "layer": layer, "suggestions": MAX_SUGGESTIONS})).ok()?; + let hit = found["misspellings"].as_array()?.iter().find(|m| { + let (a, b) = (m["start"].as_u64().unwrap_or(u64::MAX) as usize, m["end"].as_u64().unwrap_or(0) as usize); + a <= at && at <= b + })?; + Some(Spelling { + layer, + start: hit["start"].as_u64()? as usize, + end: hit["end"].as_u64()? as usize, + word: hit["word"].as_str()?.to_string(), + suggestions: hit["suggestions"].as_array()?.iter().filter_map(|s| s.as_str().map(str::to_string)).take(MAX_SUGGESTIONS).collect(), + }) +} + +/// The spelling rows heading the type edit menu (label, id): the suggestions, or a greyed note +/// when there are none, then Add to Dictionary. Empty away from a misspelled word. +pub fn spelling_rows(menu: &CanvasToolMenu) -> Vec<(String, String)> { + let Some(sp) = menu.spelling.as_ref().filter(|_| menu.text) else { return Vec::new() }; + let mut rows: Vec<(String, String)> = sp.suggestions.iter().enumerate().map(|(i, s)| (s.clone(), format!("{SPELLING_REPLACE}{i}"))).collect(); + if rows.is_empty() { + rows.push(("No Spelling Suggestions".into(), SPELLING_NONE.into())); + } + rows.push(("Add to Dictionary".into(), SPELLING_ADD.into())); + rows +} + +/// Is `command` one of the menu's spelling rows? +fn is_spelling(menu: &CanvasToolMenu, command: &str) -> bool { + spelling_rows(menu).iter().any(|(_, id)| id == command) +} + +/// Does a spelling row's label come from the UI (translated) rather than from the dictionary? +pub fn spelling_label_is_ui(id: &str) -> bool { + matches!(id, SPELLING_NONE | SPELLING_ADD) +} + /// Right-click over a Free Transform box: its modes. pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> bool { if !pos.iter().all(|v| v.is_finite()) { @@ -328,6 +393,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -346,10 +412,24 @@ pub fn engine_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})), _ if menu.pasteboard => ("prefs.set".into(), json!({})), _ if menu.text && matches!(command, FAUX_BOLD | FAUX_ITALIC) => ("type.setStyle".into(), json!({})), + _ if is_spelling(menu, command) => spelling_call(menu, command), _ => (command.into(), json!({})), } } +/// The `edit.checkSpelling` call a spelling row makes: replace the word with a suggestion, or add +/// it to the user dictionary. +fn spelling_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { + let Some(sp) = menu.spelling.as_ref() else { return ("edit.checkSpelling".into(), json!({})) }; + let replace = command.strip_prefix(SPELLING_REPLACE).and_then(|n| n.parse::().ok()).and_then(|n| sp.suggestions.get(n)); + match replace { + Some(to) => { + ("edit.checkSpelling".into(), json!({"action": "change", "layer": sp.layer, "start": sp.start, "end": sp.end, "word": sp.word, "replace": to})) + } + None => ("edit.checkSpelling".into(), json!({"action": "addToDictionary", "word": sp.word})), + } +} + /// Does a right-click with `tool` at document point `doc` open the pasteboard menu? Outside the /// image, for the tools whose right-click has no job there: not the painting tools (their Brush /// Preset picker or right-button erase), the Pen (its path menu) or text being edited. @@ -377,6 +457,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -406,6 +487,8 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) } if menu.text { return match command { + SPELLING_NONE => false, + c if is_spelling(menu, c) => app.ui.text_edit.is_some(), FAUX_BOLD | FAUX_ITALIC => app.ui.text_edit.is_some(), c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; @@ -449,6 +532,12 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) } pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) -> bool { + open_at(app, ctx, tool, pos, None) +} + +/// [`open`] for a right-click at document point `doc`: over a misspelled word of the text being +/// edited, the menu starts with its spelling suggestions. +pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2], doc: Option<[f64; 2]>) -> bool { let text = editing_text(app, tool); if !(applies(tool) || text) || !pos.iter().all(|v| v.is_finite()) { return false; @@ -458,7 +547,9 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some()); let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); - app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text }); + let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; + app.ui.canvas_tool_menu = + Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, spelling }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -481,6 +572,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -521,6 +613,8 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_pasteboard(app, &menu, command) } else if menu.anchor.is_some() { choose_view(app, ctx, &menu, command) + } else if menu.text && is_spelling(&menu, command) { + choose_spelling(app, &menu, command) } else if menu.text { choose_type(app, ctx, command) } else if menu.tool == Tool::Pen && !menu.transform { @@ -538,6 +632,19 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { } } +/// A spelling row: replace the word under the pointer (the caret lands after the replacement), or +/// add it to the user dictionary. +fn choose_spelling(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { + let (id, params) = engine_call(menu, command); + let result = app.run(&id, params)?; + if let Some(end) = result["end"].as_u64().map(|e| e as usize) + && let Some(e) = app.ui.text_edit.as_mut() + { + (e.caret, e.anchor) = (end, end); + } + Ok(()) +} + /// A type edit row. Undo, Cut, Copy, Paste and Select All run as their Edit and Select menu items, /// which act on the text while it is being edited (Paste asks the window for the clipboard text). fn choose_type(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> { @@ -668,6 +775,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { ui.spacing_mut().item_spacing.y = 0.0; ui.set_width(200.0); crate::widgets::menu_scroll(ui, |ui| { + let spelling = spelling_rows(&menu); + for (label, command) in &spelling { + let text = if spelling_label_is_ui(command) { tl!(label).to_string() } else { label.clone() }; + let item = egui::Button::new(text).min_size(egui::vec2(200.0, 20.0)); + if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() { + selected = Some(command.clone()); + } + } + if !spelling.is_empty() { + ui.separator(); + } for row in rows(&menu) { let Some((label, command)) = *row else { ui.separator(); @@ -675,14 +793,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { }; let item = egui::Button::selectable(mode_checked(app, &menu, command), tl!(label)).min_size(egui::vec2(200.0, 20.0)); if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() { - selected = Some(command); + selected = Some((*command).to_string()); } } }); }); }); if let Some(command) = selected { - choose(app, ctx, command); + choose(app, ctx, &command); } else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) { app.ui.canvas_tool_menu = None; } @@ -849,6 +967,37 @@ mod tests { assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now"); } + #[test] + fn type_edit_menu_lists_spelling_suggestions_for_the_word_under_the_pointer() { + let mut app = app(); + app.ui.tool = Tool::Type; + let id = app.run("type.create", json!({"text": "Teh cat", "size": 12, "x": 4, "y": 20})).unwrap()["layer"].as_u64().unwrap(); + let ctx = Context::default(); + crate::menus::invoke(&mut app, &ctx, "type.editText", json!({})).unwrap(); + // Away from the misspelling (past the end of the line): no spelling rows. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([600.0, 15.0]))); + assert!(spelling_rows(app.ui.canvas_tool_menu.as_ref().unwrap()).is_empty()); + // Over "Teh": its suggestions head the menu, then Add to Dictionary. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([5.0, 15.0]))); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + let rows = spelling_rows(&menu); + assert!(rows.iter().any(|(l, _)| l == "The"), "{rows:?}"); + assert_eq!(rows.last().map(|(_, id)| id.as_str()), Some(SPELLING_ADD)); + assert!(rows.len() <= MAX_SUGGESTIONS + 1, "at most {MAX_SUGGESTIONS} suggestions and Add to Dictionary: {rows:?}"); + assert!(available(&app, &menu, &rows[0].1)); + let pick = rows.iter().find(|(l, _)| l == "The").unwrap().1.clone(); + choose(&mut app, &ctx, &pick); + assert_eq!(text_of(&app, id).unwrap().text, "The cat"); + assert_eq!(app.ui.text_edit.as_ref().map(|e| (e.caret, e.anchor)), Some((3, 3)), "the caret lands after the replacement"); + // Add to Dictionary stops flagging a word. + app.run("type.insertText", json!({"layer": id, "text": " Zqx"})).unwrap(); + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([600.0, 15.0]))); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.spelling.as_ref().is_some_and(|s| s.word == "Zqx")); + choose(&mut app, &ctx, SPELLING_ADD); + assert!(app.session.prefs().user_dictionary.iter().any(|w| w == "Zqx")); + } + #[test] fn right_click_outside_the_image_picks_the_pasteboard_color() { let mut app = app(); @@ -1317,6 +1466,9 @@ mod tests { choose_open(&mut app, "layer.vectorMask.fromPath"); let st = app.session.active().unwrap(); assert!(st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.vector_mask.is_some())); + // The new vector mask's path becomes the active one (#4728); pick the Work Path again. + assert_eq!(app.ui.selected_path.as_deref(), Some("layer")); + app.ui.selected_path = Some("work".into()); choose_open(&mut app, "edit.defineCustomShape"); let id = app.ui.dialogs.last().map(|d| d.id).unwrap(); let fields = &app.ui.dialogs.last().unwrap().fields; diff --git a/crates/ui-egui/src/caps_lock_tests.rs b/crates/ui-egui/src/caps_lock_tests.rs index f9edc587f0..1e57c40920 100644 --- a/crates/ui-egui/src/caps_lock_tests.rs +++ b/crates/ui-egui/src/caps_lock_tests.rs @@ -3,7 +3,9 @@ //! tools (Brush, Pencil, Clone Stamp, Healing, Eraser, …): Move, Marquee, Crop and Zoom keep //! their own cursors. Tests drive the real canvas (`document_area`) and compare the painted //! cursor that caps lock produces with the one the Precise preference draws — they must match, -//! and the Standard preference (off) must not. +//! and the Standard preference (off) must not. The tools under Cursors › Other Cursors with a +//! cursor of their own (the pens, Eyedropper, Color Sampler, Paint Bucket) swap it for the +//! crosshair and back with caps lock (#4639). use egui::CursorIcon; use egui_kittest::Harness; @@ -113,3 +115,42 @@ fn caps_lock_leaves_non_painting_tools_cursors_alone() { caps(&mut h, false); } } + +fn other(h: &mut Harness<'_, PhotocraftApp>, value: &str) { + h.state_mut().run("prefs.set", json!({"path": "cursors.other", "value": value})).unwrap(); + h.run_steps(2); +} + +/// #4639: the pen tools show their own icon under Standard Other Cursors and the crosshair +/// under Precise, and caps lock swaps one for the other either way, as in Photoshop. The +/// Eyedropper, Color Sampler and Paint Bucket, which already followed the preference, swap too. +#[test] +fn caps_lock_swaps_the_other_cursors_between_their_icon_and_the_crosshair() { + for tool in [ + Tool::Pen, + Tool::FreeformPen, + Tool::CurvaturePen, + Tool::AddAnchorPoint, + Tool::DeleteAnchorPoint, + Tool::ConvertPoint, + Tool::Eyedropper, + Tool::ColorSampler, + Tool::PaintBucket, + ] { + let mut h = capslock_harness(); + h.state_mut().ui.tool = tool; + h.run_steps(2); + other(&mut h, "standard"); + let standard = fp(&h); + assert_eq!(standard.icon, CursorIcon::None, "{tool:?}: the standard cursor is the tool's own icon, drawn"); + other(&mut h, "precise"); + let precise = fp(&h); + assert_ne!(standard, precise, "{tool:?}: Standard and Precise differ"); + caps(&mut h, true); + assert_eq!(fp(&h), standard, "{tool:?}: caps lock turns the precise crosshair back into the icon"); + other(&mut h, "standard"); + assert_eq!(fp(&h), precise, "{tool:?}: caps lock turns the icon into the precise crosshair"); + caps(&mut h, false); + assert_eq!(fp(&h), standard, "{tool:?}: releasing caps lock restores the icon"); + } +} diff --git a/crates/ui-egui/src/channel_options_ui.rs b/crates/ui-egui/src/channel_options_ui.rs new file mode 100644 index 0000000000..c50244447e --- /dev/null +++ b/crates/ui-egui/src/channel_options_ui.rs @@ -0,0 +1,215 @@ +//! Channels panel › Channel Options… (double-click an alpha or spot channel's thumbnail, or the +//! Quick Mask row): name, Color Indicates Masked Areas / Selected Areas / Spot Color, the overlay +//! or ink colour, and opacity (solidity for spot channels). OK runs the engine's +//! `channel.options`, which agents can also call directly with params (#3761). + +use egui::{Align2, Sense, pos2, vec2}; +use photocraft_doc::ColorIndicates; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::theme::Tokens; + +pub const COMMAND: &str = "channel.options"; +const MARK: &str = "__channelOptions"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +fn hex(rgb: [f32; 3]) -> String { + crate::color_picker_ui::hex(rgb) +} + +/// The dialog's fields for `channel` (an alpha/spot channel index, or `"quickMask"`), or `None` +/// when there's no document or no such channel. +pub fn fields(app: &PhotocraftApp, channel: &Value) -> Option> { + let doc = &app.session.active()?.doc; + let kind = |i: ColorIndicates| if i == ColorIndicates::SelectedAreas { "selected" } else { "masked" }; + let (name, indicates, color, percent) = if channel.as_str() == Some("quickMask") { + let o = app.session.quick_mask_options; + (None, kind(o.indicates), o.color, o.opacity) + } else { + let ch = doc.channels.get(usize::try_from(channel.as_u64()?).ok()?)?; + match ch.spot { + Some((ink, solidity)) => (Some(ch.name.clone()), "spot", ink, solidity), + None => (Some(ch.name.clone()), kind(ch.indicates), ch.color, ch.opacity), + } + }; + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!(if name.is_some() { "Channel Options…" } else { "Quick Mask Options…" })); + f.insert("channel".into(), channel.clone()); + if let Some(n) = name { + f.insert("name".into(), json!(n)); + } + f.insert("indicates".into(), json!(indicates)); + f.insert("color".into(), json!(hex(color.to_rgb()))); + f.insert("opacity".into(), json!((percent.clamp(0.0, 1.0) * 100.0).round())); + Some(f) +} + +/// Opens Channel Options on `channel`, or `None` if it doesn't exist. +pub fn open(app: &mut PhotocraftApp, channel: &Value) -> Option { + let f = fields(app, channel)?; + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + +/// The `channel.options` params for the dialog's fields: spot channels take `solidity`, the +/// others `opacity`. +pub fn params(f: &Map) -> Value { + let spot = f.get("indicates").and_then(Value::as_str) == Some("spot"); + let mut p = Map::new(); + for key in ["channel", "name", "indicates", "color"] { + if let Some(v) = f.get(key) { + p.insert(key.into(), v.clone()); + } + } + if let Some(v) = f.get("opacity") { + p.insert(if spot { "solidity" } else { "opacity" }.into(), v.clone()); + } + Value::Object(p) +} + +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + app.run(COMMAND, params(f)) +} + +fn label(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::hover()); + ui.painter().text(pos2(r.right() - 6.0, r.center().y), Align2::RIGHT_CENTER, text, egui::FontId::proportional(12.0), t.text_dim); +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + ui.spacing_mut().item_spacing.y = 6.0; + let quick = f.get("channel").and_then(Value::as_str) == Some("quickMask"); + if let Some(mut name) = f.get("name").and_then(Value::as_str).map(str::to_string) { + ui.horizontal(|ui| { + label(ui, tl!("Name:")); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0)).changed() { + f.insert("name".into(), json!(name)); + } + }); + } + crate::widgets::section_label(ui, tl!("Color Indicates:")); + crate::widgets::hairline(ui); + let mut indicates = f.get("indicates").and_then(Value::as_str).unwrap_or("masked").to_string(); + let before = indicates.clone(); + ui.indent("channel-options-indicates", |ui| { + ui.radio_value(&mut indicates, "masked".to_string(), tl!("Masked Areas")); + ui.radio_value(&mut indicates, "selected".to_string(), tl!("Selected Areas")); + // Quick Mask can't be a spot channel. + if !quick { + ui.radio_value(&mut indicates, "spot".to_string(), tl!("Spot Color")); + } + }); + if indicates != before { + f.insert("indicates".into(), json!(indicates)); + } + let spot = indicates == "spot"; + ui.add_space(4.0); + crate::widgets::section_label(ui, tl!("Color")); + crate::widgets::hairline(ui); + ui.horizontal(|ui| { + label(ui, ""); + let current = f.get("color").and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex).unwrap_or([1.0, 0.0, 0.0]); + let c = current.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let what = if spot { tl!("Spot Color") } else { tl!("Channel Color") }; + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(c[0], c[1], c[2]), what).clicked() { + crate::color_picker_ui::request(f, &crate::color_picker_ui::title_for(what)); + } + ui.add_space(12.0); + ui.label(if spot { tl!("Solidity:") } else { tl!("Opacity:") }); + let mut pct = f.get("opacity").and_then(Value::as_f64).unwrap_or(50.0) as f32; + if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).changed() { + f.insert("opacity".into(), json!(pct.round())); + } + }); +} + +// --------------------------------------------------------------------------------------------- +// New Spot Channel…: Name and Ink Characteristics (Color, Solidity). OK runs `channel.newSpot` +// (from the selection, as Photoshop does) and remembers the ink and solidity for next time. + +pub const NEW_SPOT: &str = "channel.newSpot"; +const NEW_SPOT_MARK: &str = "__newSpot"; + +pub fn owns_new_spot(fields: &Map) -> bool { + fields.contains_key(NEW_SPOT_MARK) +} + +/// The New Spot Channel fields: the next "Spot Color N" name and the last ink and solidity +/// (red and 0% at first), or `None` without a document. +pub fn new_spot_fields(app: &PhotocraftApp) -> Option> { + let doc = &app.session.active()?.doc; + let saved = app.session.prefs().dialogs.get(NEW_SPOT).cloned().unwrap_or_default(); + let color = saved.get("color").and_then(Value::as_str).filter(|h| crate::color_picker_ui::parse_hex(h).is_some()).unwrap_or("#ff0000"); + let solidity = saved.get("solidity").and_then(Value::as_f64).filter(|v| (0.0..=100.0).contains(v)).unwrap_or(0.0); + let mut f = Map::new(); + f.insert(NEW_SPOT_MARK.into(), json!(true)); + f.insert("__command".into(), json!(NEW_SPOT)); + f.insert("__label".into(), json!("New Spot Channel…")); + f.insert("name".into(), json!(photocraft_engine::channel_cmds::next_alpha_name(doc, "Spot Color"))); + f.insert("color".into(), json!(color)); + f.insert("solidity".into(), json!(solidity)); + Some(f) +} + +/// Opens New Spot Channel, or `None` without a document. +pub fn open_new_spot(app: &mut PhotocraftApp) -> Option { + let f = new_spot_fields(app)?; + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + +pub fn new_spot_params(f: &Map) -> Value { + let mut p = Map::new(); + for key in ["name", "color", "solidity"] { + if let Some(v) = f.get(key) { + p.insert(key.into(), v.clone()); + } + } + Value::Object(p) +} + +pub fn confirm_new_spot(app: &mut PhotocraftApp, f: &Map) -> Result { + let p = new_spot_params(f); + let result = app.run(NEW_SPOT, p.clone()); + if result.is_ok() { + let remembered = json!({ "color": p.get("color"), "solidity": p.get("solidity") }); + app.session.prefs.edit(|prefs| prefs.dialogs.insert(NEW_SPOT.into(), remembered)); + } + result +} + +pub fn new_spot_body(ui: &mut egui::Ui, f: &mut Map) { + ui.spacing_mut().item_spacing.y = 6.0; + let mut name = f.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); + ui.horizontal(|ui| { + label(ui, tl!("Name:")); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0)).changed() { + f.insert("name".into(), json!(name)); + } + }); + crate::widgets::section_label(ui, tl!("Ink Characteristics")); + crate::widgets::hairline(ui); + ui.horizontal(|ui| { + label(ui, tl!("Color:")); + let current = f.get("color").and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex).unwrap_or([1.0, 0.0, 0.0]); + let c = current.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(c[0], c[1], c[2]), tl!("Spot Color")).clicked() { + crate::color_picker_ui::request(f, &crate::color_picker_ui::title_for(tl!("Spot Color"))); + } + ui.add_space(12.0); + ui.label(tl!("Solidity:")); + let mut pct = f.get("solidity").and_then(Value::as_f64).unwrap_or(0.0) as f32; + if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).changed() { + f.insert("solidity".into(), json!(pct.round())); + } + }); +} + +#[cfg(test)] +#[path = "channel_options_ui_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/channel_options_ui_tests.rs b/crates/ui-egui/src/channel_options_ui_tests.rs new file mode 100644 index 0000000000..9a4a3d4c37 --- /dev/null +++ b/crates/ui-egui/src/channel_options_ui_tests.rs @@ -0,0 +1,177 @@ +use serde_json::json; + +use super::*; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("select.rect", json!({"x": 2, "y": 2, "width": 8, "height": 8})).unwrap(); + app.run("select.saveSelection", json!({})).unwrap(); + app +} + +fn channel(app: &PhotocraftApp) -> photocraft_doc::AlphaChannel { + app.session.active().unwrap().doc.channels[0].clone() +} + +#[test] +fn saved_selection_becomes_a_spot_channel_with_its_ink_and_solidity() { + let mut app = app(); + assert!(channel(&app).spot.is_none()); + let id = open(&mut app, &json!(0)).unwrap(); + let f = &app.ui.dialogs.last().unwrap().fields; + assert!(owns(f)); + assert_eq!(f["indicates"], json!("masked")); + assert_eq!(f["name"], json!(channel(&app).name)); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("name".into(), json!("PMS 185")); + d.fields.insert("indicates".into(), json!("spot")); + d.fields.insert("color".into(), json!("#ff0000")); + d.fields.insert("opacity".into(), json!(40.0)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.ui.dialogs.is_empty()); + let ch = channel(&app); + assert_eq!(ch.name, "PMS 185"); + let (ink, solidity) = ch.spot.expect("a spot channel"); + assert_eq!(ink.to_rgb(), [1.0, 0.0, 0.0]); + assert!((solidity - 0.4).abs() < 1e-6); + + // Reopened, the dialog shows the spot settings; switching back makes it an alpha channel. + let id = open(&mut app, &json!(0)).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["indicates"].clone(), f["color"].clone(), f["opacity"].clone()), (json!("spot"), json!("#ff0000"), json!(40.0))); + app.ui.dialog_mut(id).unwrap().fields.insert("indicates".into(), json!("selected")); + crate::dialogs::confirm(&mut app, id).unwrap(); + let ch = channel(&app); + assert!(ch.spot.is_none()); + assert_eq!(ch.indicates, ColorIndicates::SelectedAreas); +} + +#[test] +fn cancel_leaves_the_channel_alone() { + let mut app = app(); + let before = app.session.active().unwrap().history.past_len(); + let id = open(&mut app, &json!(0)).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("indicates".into(), json!("spot")); + app.ui.close_dialog(id); + assert!(channel(&app).spot.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), before); +} + +#[test] +fn spot_params_send_solidity_and_alpha_params_send_opacity() { + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("channel".into(), json!(0)); + f.insert("indicates".into(), json!("spot")); + f.insert("opacity".into(), json!(25.0)); + assert_eq!(params(&f), json!({"channel": 0, "indicates": "spot", "solidity": 25.0})); + f.insert("indicates".into(), json!("masked")); + assert_eq!(params(&f), json!({"channel": 0, "indicates": "masked", "opacity": 25.0})); +} + +#[test] +fn menu_opens_the_dialog_on_the_targeted_channel_and_refuses_otherwise() { + let ctx = egui::Context::default(); + let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + assert!(crate::menus::invoke(&mut empty, &ctx, COMMAND, json!({})).is_err()); + + let mut app = app(); + // The composite is targeted: nothing to edit. + assert!(crate::menus::invoke(&mut app, &ctx, COMMAND, json!({})).is_err()); + assert!(app.ui.dialogs.is_empty()); + app.run("channel.target", json!({"channel": 0})).unwrap(); + let r = crate::menus::invoke(&mut app, &ctx, COMMAND, json!({})).unwrap(); + assert!(r["dialog"].is_u64()); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!(0)); + // Explicit params run the engine directly, no dialog. + app.ui.dialogs.clear(); + crate::menus::invoke(&mut app, &ctx, COMMAND, json!({"channel": 0, "indicates": "spot"})).unwrap(); + assert!(app.ui.dialogs.is_empty()); + assert!(channel(&app).spot.is_some()); +} + +#[test] +fn quick_mask_options_have_no_name_or_spot_and_bad_channels_open_nothing() { + let mut app = app(); + let f = fields(&app, &json!("quickMask")).unwrap(); + assert!(!f.contains_key("name")); + assert_eq!(f["__label"], json!("Quick Mask Options…")); + assert!(fields(&app, &json!(7)).is_none()); + assert!(fields(&app, &json!("nope")).is_none()); + assert!(open(&mut app, &json!(-1)).is_none()); + assert!(app.ui.dialogs.is_empty()); +} + +#[test] +fn dialog_body_renders_for_alpha_spot_and_quick_mask() { + let mut app = app(); + app.run("channel.options", json!({"channel": 0, "indicates": "spot"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for channel in [json!(0), json!("quickMask")] { + let mut f = fields(&app, &channel).unwrap(); + ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear(); + assert!(owns(&f)); + } +} + +#[test] +fn new_spot_channel_dialog_creates_the_ink_from_the_selection_and_remembers_it() { + let mut app = app(); + app.run("select.rect", json!({"x": 2, "y": 2, "width": 8, "height": 8})).unwrap(); + let id = open_new_spot(&mut app).unwrap(); + let f = &app.ui.dialogs.last().unwrap().fields; + assert!(owns_new_spot(f) && !owns(f)); + assert_eq!((f["name"].clone(), f["color"].clone(), f["solidity"].clone()), (json!("Spot Color 1"), json!("#ff0000"), json!(0.0))); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("name".into(), json!("PMS 300")); + d.fields.insert("color".into(), json!("#0050ff")); + d.fields.insert("solidity".into(), json!(60.0)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + let ch = doc.channels.last().unwrap(); + assert_eq!(ch.name, "PMS 300"); + let (ink, solidity) = ch.spot.expect("a spot channel"); + assert_eq!(ink.to_rgba8()[..3], [0x00, 0x50, 0xff]); + assert!((solidity - 0.6).abs() < 1e-6); + // Inked where the selection was, empty outside it. + let (inside, outside) = (ch.surface.rgba(4, 4)[0], ch.surface.rgba(14, 14)[0]); + assert!(inside > 0.99 && outside < 0.01, "{inside} {outside}"); + + // The next one gets a fresh name and the last ink and solidity. + let f = new_spot_fields(&app).unwrap(); + assert_eq!((f["name"].clone(), f["color"].clone(), f["solidity"].clone()), (json!("Spot Color 1"), json!("#0050ff"), json!(60.0))); +} + +#[test] +fn new_spot_channel_cancel_menu_routing_and_no_document() { + let ctx = egui::Context::default(); + let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + assert!(open_new_spot(&mut empty).is_none()); + assert!(crate::menus::invoke(&mut empty, &ctx, NEW_SPOT, json!({})).is_err()); + + let mut app = app(); + let n = app.session.active().unwrap().doc.channels.len(); + let r = crate::menus::invoke(&mut app, &ctx, NEW_SPOT, json!({})).unwrap(); + let id = r["dialog"].as_u64().unwrap(); + assert!(owns_new_spot(&app.ui.dialog_mut(id).unwrap().fields)); + app.ui.close_dialog(id); + assert_eq!(app.session.active().unwrap().doc.channels.len(), n, "Cancel adds nothing"); + // Explicit params run the engine directly, no dialog. + crate::menus::invoke(&mut app, &ctx, NEW_SPOT, json!({"name": "Direct"})).unwrap(); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(app.session.active().unwrap().doc.channels.last().unwrap().name, "Direct"); +} + +#[test] +fn new_spot_body_renders_and_bad_saved_prefs_fall_back() { + let mut app = app(); + app.session.prefs.edit(|p| p.dialogs.insert(NEW_SPOT.into(), json!({"color": "nope", "solidity": 900}))); + let mut f = new_spot_fields(&app).unwrap(); + assert_eq!((f["color"].clone(), f["solidity"].clone()), (json!("#ff0000"), json!(0.0))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.run_ui(Default::default(), |ui| new_spot_body(ui, &mut f)).textures_delta.clear(); + assert!(owns_new_spot(&f)); +} diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index d7a8b190f5..38116e58e1 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -18,6 +18,20 @@ use crate::PhotocraftApp; /// Largest overlay texture side; bigger documents show a downsampled overlay. const MAX_SIDE: u32 = 4096; +/// Limit the sampled float composite to 128 MiB across open documents. +const MAX_COMPOSITE_CACHE_PIXELS: usize = 8 * 1024 * 1024; + +struct SampledComposite { + revision: u64, + preview_key: u64, + factor: u32, + pixels: Vec<[f32; 4]>, +} + +struct CompositeSource<'a> { + cached: Option<&'a [[f32; 4]]>, + fresh: &'a mut Option>, +} pub struct Cache { revision: u64, @@ -29,6 +43,7 @@ pub struct Cache { /// reuses its allocation) but its pixels are stale. valid: bool, tex: egui::TextureHandle, + source: Option, } fn sample(s: &Surface, r: Rect, factor: u32) -> Vec { @@ -49,18 +64,22 @@ fn sample(s: &Surface, r: Rect, factor: u32) -> Vec { } /// Composite RGBA (over white, like Photoshop's channel views) at `factor` stride. +/// Render in bands instead of allocating a full-resolution float composite to sample a preview. fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { - let buf = photocraft_compose::render(doc, r); let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); - let bw = r.width(); let mut out = Vec::with_capacity((w * h) as usize); - for y in 0..h { - for x in 0..w { - let p = buf.px[((y * factor) * bw + x * factor) as usize]; - let a = p[3].clamp(0.0, 1.0); - out.push([p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0]); + let f = factor as i32; + let _ = photocraft_compose::render_bands(doc, r, 0, |band| -> Result<(), ()> { + let first = band.rect.y0 + (r.y0 - band.rect.y0).rem_euclid(f); + for y in (first..band.rect.y1).step_by(factor as usize) { + for x in (r.x0..r.x1).step_by(factor as usize) { + let p = band.get(x, y); + let a = p[3].clamp(0.0, 1.0); + out.push([p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0]); + } } - } + Ok(()) + }); out } @@ -68,6 +87,19 @@ fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { /// or `None` when the plain composite is all there is to show. `in_color` shows single colour /// channels tinted instead of grayscale (Photoshop's "Show Channels in Color"). pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, in_color: bool, active: Option) -> Option> { + let mut unused = None; + render_with_source(doc, v, r, factor, in_color, active, CompositeSource { cached: None, fresh: &mut unused }) +} + +fn render_with_source( + doc: &Document, + v: &ChannelView, + r: Rect, + factor: u32, + in_color: bool, + active: Option, + source: CompositeSource<'_>, +) -> Option> { if v.is_plain(doc) || r.is_empty() { return None; } @@ -116,8 +148,11 @@ pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, in_color: b let fmt = doc.pixel_format(); let mode = fmt.mode; let shown: Vec = (0..colors).filter(|k| v.color_visible(*k)).collect(); - composite(doc, r, factor) - .into_iter() + let computed = if source.cached.is_some_and(|px| px.len() == n) { None } else { Some(composite(doc, r, factor)) }; + let pixels = source.cached.filter(|px| px.len() == n).or(computed.as_deref())?; + let converted = pixels + .iter() + .copied() .map(|rgba| { let mut native = from_rgba(&fmt, rgba); if shown.len() == 1 && !in_color { @@ -137,7 +172,11 @@ pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, in_color: b let c = to_rgba(&fmt, &native); [c[0], c[1], c[2], 1.0] }) - .collect() + .collect(); + if n <= MAX_COMPOSITE_CACHE_PIXELS { + *source.fresh = computed; + } + converted } else { vec![[0.0; 4]; n] }; @@ -231,6 +270,13 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // Never exceed what the GPU accepts (egui panics on oversized textures). let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE); let factor = doc.size.width.max(doc.size.height).div_ceil(max_side).max(1); + // A live preview can mutate the same Arc in place. Revision and preview_key, not + // pointer identity, determine whether these flattened pixels are still current. + if let Some(c) = app.channel_views.get_mut(&id) + && c.source.as_ref().is_some_and(|s| s.factor != factor || s.revision != revision || s.preview_key != preview_key) + { + c.source = None; + } if let Some(c) = app.channel_views.get(&id) && c.key == key && c.factor == factor @@ -264,7 +310,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio return Some(c.tex.id()); } let b = doc.bounds(); - let px = render(&doc, &view, b, factor, in_color, active)?; + let cached = app.channel_views.get(&id).and_then(|c| c.source.as_ref()).map(|s| s.pixels.as_slice()); + let mut fresh = None; + let px = render_with_source(&doc, &view, b, factor, in_color, active, CompositeSource { cached, fresh: &mut fresh })?; + let new_source = fresh.is_some(); let size = [b.width().div_ceil(factor) as usize, b.height().div_ceil(factor) as usize]; let img = egui::ColorImage::new(size, px); match app.channel_views.get_mut(&id) { @@ -275,10 +324,21 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio c.key = key; c.factor = factor; c.valid = true; + if let Some(pixels) = fresh.take() { + c.source = Some(SampledComposite { revision, preview_key, factor, pixels }); + } } _ => { let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR); - app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex }); + let source = fresh.map(|pixels| SampledComposite { revision, preview_key, factor, pixels }); + app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex, source }); + } + } + if new_source { + for (other_id, c) in &mut app.channel_views { + if *other_id != id { + c.source = None; + } } } app.channel_views.get(&id).map(|c| c.tex.id()) @@ -297,6 +357,78 @@ mod tests { s } + #[test] + fn composite_bands_keep_the_downsample_grid() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 41, "height": 700})).unwrap(); + s.execute("edit.fill", json!({"color": "#398db2"})).unwrap(); + let doc = &s.active().unwrap().doc; + let rect = doc.bounds(); + let full = photocraft_compose::render(doc, rect); + let full_ref = &full; + for factor in [1, 2, 3, 5] { + let expected: Vec<_> = (0..rect.height()) + .step_by(factor as usize) + .flat_map(|y| { + (0..rect.width()).step_by(factor as usize).map(move |x| { + let p = full_ref.get(rect.x0 + x as i32, rect.y0 + y as i32); + let a = p[3].clamp(0.0, 1.0); + [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0] + }) + }) + .collect(); + assert_eq!(composite(doc, rect, factor), expected, "factor {factor}"); + } + } + + #[test] + fn channel_eyes_reuse_composite_until_pixels_change() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 32})).unwrap(); + app.run("edit.fill", json!({"color": "#4080c0"})).unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + app.run("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); + assert!(ensure(&mut app, &ctx, 0).is_some()); + let id = app.session.active().unwrap().doc.id.0; + let first = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); + let original_sample = first.pixels[0]; + let ptr = first.pixels.as_ptr(); + + app.run("channel.setVisible", json!({"channel": "green", "visible": false})).unwrap(); + assert!(ensure(&mut app, &ctx, 0).is_some()); + let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); + assert_eq!(ptr, reused.pixels.as_ptr(), "visibility does not recompose the pixels"); + + app.run("edit.fill", json!({"color": "#e02010"})).unwrap(); + assert!(ensure(&mut app, &ctx, 0).is_some()); + // A partial damage upload discards the source; a full redraw may replace it. + // In either case, cached samples must never refer to the pre-edit pixels. + if let Some(source) = app.channel_views.get(&id).unwrap().source.as_ref() { + assert_ne!(source.pixels[0], original_sample, "a full redraw replaces stale pixels"); + } + app.run("channel.setVisible", json!({"channel": "red", "visible": true})).unwrap(); + assert!(ensure(&mut app, &ctx, 0).is_some()); + let refreshed = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(refreshed.revision, st.revision); + assert_ne!(refreshed.pixels[0], original_sample, "new pixels must be recomposited"); + let expected = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + let mut unused = None; + let actual = render_with_source( + &st.doc, + &st.channel_view, + st.doc.bounds(), + 1, + false, + st.active_layer, + CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused }, + ) + .unwrap(); + assert_eq!(actual, expected, "reused pixels must preserve channel colours"); + assert!(unused.is_none(), "reuse must not create a second composite"); + } + #[test] fn plain_composite_draws_nothing() { let s = session(); diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index 4d9ee00a4e..f232384139 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -2,6 +2,8 @@ //! rows with thumbnails and eye toggles. Every action is an engine command (`channel.*`, //! `select.loadSelection`, `select.saveSelection`, …); the targeted channel and the eyes live in //! the engine's `DocState::channel_view`, so agents see the same state through `inspect`. +//! Rows can be dragged onto the footer's buttons, as in Photoshop (#4689): New duplicates the +//! channel, Delete deletes it, and Load Channel as Selection loads it. use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_doc::ColorIndicates; @@ -11,7 +13,7 @@ use serde_json::{Value, json}; use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, icons, widgets}; -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] enum Row { Composite, Color(usize), @@ -63,7 +65,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let thumbs = app.channel_thumbs(&ctx); // The active layer's mask (Photoshop lists it, in italics, below the colour channels). - let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()).cloned(); + let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()); let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?))); let mask_targeted = masked.is_some() && (app.ui.mask_target || view.target == ChannelTarget::LayerMask) @@ -122,7 +124,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }; let row_h = if t.pro { 36.0 } else { 40.0 }; - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()); + if resp.drag_started() { + ctx.data_mut(|d| d.insert_temp(drag_id(), (row, name.clone()))); + } let painter = ui.painter_at(rect.expand(1.0)); if selected { painter.rect_filled(rect, if t.pro { 0.0 } else { t.radius }, if t.pro { t.row_selected } else { t.hover }); @@ -212,8 +217,16 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.push(("channel.target".into(), json!({ "channel": target }))); } } + // Like Photoshop: double-clicking the thumbnail opens Channel Options (where an alpha + // channel becomes a spot channel, #3761), double-clicking the name renames it. + let on_thumb = resp.interact_pointer_pos().is_some_and(|p| p.x < cell.right() + 4.0); + if row == Row::QuickMask && resp.double_clicked() { + actions.push(("ui.channelOptions".into(), json!("quickMask"))); + } if let Row::Alpha(i) = row { - if resp.double_clicked() { + if resp.double_clicked() && on_thumb { + actions.push(("ui.channelOptions".into(), json!(i))); + } else if resp.double_clicked() { ctx.data_mut(|d| d.insert_temp(rename_id, name.clone())); } if let Some(mut text) = ctx.data(|d| d.get_temp::(rename_id)) { @@ -245,6 +258,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { item(ui, a, "Duplicate Channel", "channel.duplicate", json!({ "channel": i })); item(ui, a, "Delete Channel", "channel.delete", json!({ "channel": i })); item(ui, a, "Rename Channel…", "ui.renameChannel", json!(i)); + item(ui, a, "Channel Options…", "ui.channelOptions", json!(i)); ui.separator(); if ch.spot.is_some() { item(ui, a, "Merge Spot Channel", "channel.mergeSpot", json!({ "channel": i })); @@ -256,6 +270,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } Row::QuickMask => { item(ui, a, "Exit Quick Mask", "select.editInQuickMaskMode", json!({ "on": false })); + item(ui, a, "Quick Mask Options…", "ui.channelOptions", json!("quickMask")); indicates_items(ui, a, json!("quickMask"), doc.quick_mask.as_ref().map(|q| q.indicates).unwrap_or_default()); overlay_colors(ui, a, json!("quickMask")); } @@ -270,7 +285,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } ui.separator(); item(ui, a, "New Channel…", "channel.new", json!({})); - item(ui, a, "New Spot Channel…", "channel.newSpot", json!({})); + item(ui, a, "New Spot Channel…", "ui.newSpotChannel", json!({})); ui.separator(); item(ui, a, "Split Channels", "channel.split", json!({})); item(ui, a, "Merge Channels…", "channel.merge", json!({})); @@ -278,6 +293,19 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } }); + // A row being dragged: its name follows the pointer until it is dropped. + let dragged = ctx.data(|d| d.get_temp::<(Row, String)>(drag_id())); + if let Some((_, name)) = &dragged + && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) + { + crate::layer_transfer::ghost(&ctx, p, name); + } + let mask_layer = masked.as_ref().map(|l| l.id.0); + let drop = |ui: &egui::Ui, button: &egui::Response, on: Footer| { + let (row, name) = dragged.as_ref()?; + let action = drop_action(*row, name, mask_layer, on)?; + crate::panels::dropped_on(ui, button).then_some(action) + }; // Photoshop's order from the left: Load as selection, Save selection, New, Delete. widgets::panel_footer(ui, |ui| { let target_ref = match view.target { @@ -287,19 +315,29 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ChannelTarget::LayerMask => json!("mask"), ChannelTarget::VectorMask => json!("vectorMask"), }; - if icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")).clicked() { + let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")); + if trash.clicked() { actions.push(("channel.delete".into(), json!({}))); } - if icons::button(ui, "plus", 26.0, false, tl!("Create new channel")).clicked() { + actions.extend(drop(ui, &trash, Footer::Delete)); + let new = icons::button(ui, "plus", 26.0, false, tl!("Create new channel")); + if new.clicked() { actions.push(("channel.new".into(), json!({}))); } + actions.extend(drop(ui, &new, Footer::New)); if icons::button(ui, "square-dashed", 26.0, false, tl!("Save selection as channel")).clicked() { actions.push(("select.saveSelection".into(), json!({}))); } - if icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")).clicked() { + let load = icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")); + if load.clicked() { actions.push(("select.loadSelection".into(), json!({ "channel": target_ref }))); } + actions.extend(drop(ui, &load, Footer::Load)); }); + // The drag ends on any release, after the buttons have had a chance to take the drop. + if ctx.input(|i| i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::<(Row, String)>(drag_id())); + } ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn)); for (id, p) in actions { if id == "ui.renameChannel" { @@ -309,6 +347,14 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } continue; } + if id == "ui.newSpotChannel" { + crate::channel_options_ui::open_new_spot(app); + continue; + } + if id == "ui.channelOptions" { + crate::channel_options_ui::open(app, &p); + continue; + } let _ = app.run(&id, p); } if let Some(on) = mask_click { @@ -317,6 +363,35 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The footer buttons a row can be dropped on. +#[derive(Clone, Copy)] +enum Footer { + Delete, + New, + Load, +} + +/// What dropping `row` (named `name`) on a footer button does, as in Photoshop; `None` where +/// Photoshop does nothing either (colour channels can't be deleted here). `mask_layer` is the +/// layer whose mask the temporary mask row shows. +fn drop_action(row: Row, name: &str, mask_layer: Option, on: Footer) -> Option<(String, Value)> { + match (on, row) { + (Footer::Delete, Row::Alpha(i)) => Some(("channel.delete".into(), json!({ "channel": i }))), + // Asks "Apply mask to layer before removing?", like dropping the mask's Layers thumbnail. + (Footer::Delete, Row::LayerMask) => Some(crate::mask_trash_prompt::drop_action(mask_layer?, crate::mask_thumbs_ui::MaskKind::Pixel)), + (Footer::Delete, _) => None, + // The engine would name a mask's copy after the composite; Photoshop says "Layer 1 Mask copy". + (Footer::New, Row::LayerMask) => Some(("channel.duplicate".into(), json!({ "channel": "mask", "name": format!("{name} copy") }))), + (Footer::New, _) => Some(("channel.duplicate".into(), json!({ "channel": row.reference() }))), + (Footer::Load, Row::LayerMask) => Some(("select.loadSelection".into(), json!({ "channel": "mask", "layer": mask_layer? }))), + (Footer::Load, _) => Some(("select.loadSelection".into(), json!({ "channel": row.reference() }))), + } +} + +fn drag_id() -> egui::Id { + egui::Id::new("channel-row-drag") +} + fn thumbs_id() -> egui::Id { egui::Id::new("channel-thumb-rects") } @@ -425,4 +500,150 @@ mod tests { let thumbs_color = app.channel_thumbs(&ctx); assert_eq!(thumbs_gray.len(), thumbs_color.len()); } + + /// A selection-only step keeps the thumbnails; a pixel edit rebuilds them into the same + /// textures rather than new ones (#4518). + #[test] + fn channel_thumbs_skip_selection_steps_and_reuse_textures() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ctx = egui::Context::default(); + // Texture uploads one pass makes (new textures and updates both count). + let pass = |app: &mut PhotocraftApp| { + ctx.begin_pass(Default::default()); + let ids = app.channel_thumbs(&ctx); + let mut delta = ctx.end_pass().textures_delta; + let n = delta.set.len(); + delta.clear(); + (ids, n) + }; + let (first, uploads) = pass(&mut app); + assert!(uploads > 0); + app.run("select.rect", json!({"x": 4, "y": 4, "width": 20, "height": 20})).unwrap(); + assert_eq!(pass(&mut app), (first.clone(), 0), "a marquee changes no pixels"); + app.run("select.deselect", json!({})).unwrap(); + assert_eq!(pass(&mut app), (first.clone(), 0), "neither does Deselect"); + app.run("edit.fill", json!({"color": "#336699"})).unwrap(); + let (after, uploads) = pass(&mut app); + assert!(uploads > 0, "a fill rebuilds them"); + assert_eq!(after, first, "into the same textures"); + } + + /// The Channels tab (the Layers card's second tab) of a 64×48 white document with a layer + /// mask on "Masked" and one alpha channel. + fn channels_harness() -> (egui_kittest::Harness<'static, PhotocraftApp>, u64) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let masked = s.execute("layer.new.layer", json!({"name": "Masked"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerMask.revealAll", json!({"layer": masked})).unwrap(); + s.execute("channel.new", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new( + "ui.set", + json!({"dockWidth": 290.0, "dockTabs": {"layers": 1}, "dock": {"collapsed": ["color", "properties", "history", "navigator"]}}), + ); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(8); + (h, masked) + } + + /// The centre of the row or button labelled `label` (not its tooltip, which a hover leaves open). + fn at(h: &egui_kittest::Harness<'_, PhotocraftApp>, label: &str) -> egui::Pos2 { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + h.query_all_by_label(label) + .find(|n| matches!(n.accesskit_node().role(), Role::Button | Role::ListBoxOption | Role::Label) && n.rect().height() > 20.0) + .unwrap_or_else(|| panic!("no row or button {label}")) + .rect() + .center() + } + + /// A real press, drag and release. + fn drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { + use egui::{Modifiers, PointerButton}; + h.hover_at(from); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + } + + /// Drags the row or button labelled `from` onto the one labelled `to`. + fn drag_to(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: &str, to: &str) { + let (from, to) = (at(h, from), at(h, to)); + drag(h, from, to); + } + + fn alphas(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect() + } + + /// #4689: as in Photoshop, a channel dropped on New is duplicated, on Delete deleted, and on + /// Load Channel as Selection loaded. + #[test] + fn rows_dropped_on_the_footer_duplicate_delete_and_load() { + let (mut h, _) = channels_harness(); + assert_eq!(alphas(&h), ["Alpha 1"]); + let steps = h.state().session.active().unwrap().history.past_len(); + + drag_to(&mut h, "Alpha 1", "Create new channel"); + assert_eq!(alphas(&h), ["Alpha 1", "Alpha 1 copy"]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one history step"); + drag_to(&mut h, "Red", "Create new channel"); + assert_eq!(alphas(&h), ["Alpha 1", "Alpha 1 copy", "Red copy"]); + drag_to(&mut h, "Masked Mask", "Create new channel"); + assert_eq!(alphas(&h), ["Alpha 1", "Alpha 1 copy", "Red copy", "Masked Mask copy"]); + + drag_to(&mut h, "Alpha 1 copy", "Delete current channel"); + assert_eq!(alphas(&h), ["Alpha 1", "Red copy", "Masked Mask copy"]); + // Colour channels stay: Photoshop would turn the image into Multichannel. + drag_to(&mut h, "Green", "Delete current channel"); + assert_eq!(alphas(&h), ["Alpha 1", "Red copy", "Masked Mask copy"]); + assert_eq!(h.state().session.active().unwrap().doc.mode, photocraft_doc::ColorMode::Rgb); + + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + drag_to(&mut h, "Red copy", "Load channel as selection"); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the white channel selects everything"); + } + + /// A plain click on a row still targets it, and a drag released elsewhere does nothing. + #[test] + fn clicks_still_target_and_a_drop_elsewhere_does_nothing() { + let (mut h, _) = channels_harness(); + let p = at(&h, "Alpha 1"); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().channel_view.target, ChannelTarget::Alpha(0)); + + let steps = h.state().session.active().unwrap().history.past_len(); + let (from, to) = (at(&h, "Alpha 1"), at(&h, "Blue")); + drag(&mut h, from, to); + drag_to(&mut h, "Alpha 1", "Save selection as channel"); + assert_eq!(alphas(&h), ["Alpha 1"]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "nothing ran"); + assert!(h.ctx.data(|d| d.get_temp::<(Row, String)>(drag_id())).is_none(), "the drag ended"); + } + + /// The layer mask's row asks "Apply mask to layer before removing?", like its Layers thumbnail. + #[test] + fn the_layer_mask_row_on_the_trash_asks_first() { + let (mut h, masked) = channels_harness(); + drag_to(&mut h, "Masked Mask", "Delete current channel"); + assert!(h.state().ui.dialogs.iter().any(|d| crate::mask_trash_prompt::owns(&d.fields)), "the prompt is open"); + let st = h.state().session.active().unwrap(); + assert!(st.doc.layer(photocraft_doc::LayerId(masked)).unwrap().mask.is_some(), "nothing deleted yet"); + } } diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index e5aefd7b51..b20e913a7d 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -156,7 +156,7 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); let mut pct = app.ui.views[i].zoom * 100.0; let control_h = crate::panels::status_bar_heights(&t).1; - if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 64.0, control_h).changed() { + if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(64.0, control_h)).changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); app.ui.views[i].fit_pending = false; } @@ -339,7 +339,7 @@ mod tests { let mut gray = doc(); gray.mode = photocraft_doc::ColorMode::Grayscale; gray.icc_profile = Some(photocraft_engine::color_cmds::working_profile(gray.mode).to_bytes()); - assert_eq!(profile_name(&gray), "sGray (sRGB tone curve, Photocraft)"); + assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"); let mut custom = (*photocraft_engine::color_cmds::working_profile(photocraft_doc::ColorMode::Cmyk)).clone(); custom.description = "PhotoCraft Studio CMYK".into(); diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index 18084a6cc1..37c90c18e4 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -390,7 +390,9 @@ fn radio(ui: &mut egui::Ui, on: bool, label: &str) -> egui::Response { if on { ui.painter().circle_filled(r.center(), 3.0, t.accent); } - let l = ui.add(egui::Label::new(egui::RichText::new(tl!(&label)).color(t.text_dim)).sense(Sense::click())); + let l = ui + .add(egui::Label::new(egui::RichText::new(tl!(&label)).color(t.text_dim)).selectable(false).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand); resp.union(l) }) .inner diff --git a/crates/ui-egui/src/color_replacement_tests.rs b/crates/ui-egui/src/color_replacement_tests.rs index bfa5037df8..64f04f3c73 100644 --- a/crates/ui-egui/src/color_replacement_tests.rs +++ b/crates/ui-egui/src/color_replacement_tests.rs @@ -137,7 +137,8 @@ fn live_stroke_at(depth: u32) { tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 20.0 }, m); assert!(app.live_stroke.is_none()); // The preview at the last move is the committed stroke. - let level = if depth == 8 { 1.0 / 255.0 + 1e-6 } else { 0.0 }; + // 8 bits may round a level or two apart between the live working copy and the commit. + let level = if depth == 8 { 2.0 / 255.0 + 1e-6 } else { 0.0 }; for y in 0..40 { for x in 0..80 { let (got, want) = (shown.rgba(x, y), rgba(&app, x, y)); diff --git a/crates/ui-egui/src/color_tab_theme_tests.rs b/crates/ui-egui/src/color_tab_theme_tests.rs new file mode 100644 index 0000000000..b3566277e2 --- /dev/null +++ b/crates/ui-egui/src/color_tab_theme_tests.rs @@ -0,0 +1,55 @@ +//! The Color panel tab draws Photoshop's spectrum field in every theme (#4367): the light themes +//! used to show HSB sliders only. Those themes keep the sliders, under the field. + +use egui::{Rect, pos2}; +use egui_kittest::kittest::Queryable; + +use crate::PhotocraftApp; +use crate::dock::Group; +use crate::theme::{ThemeKind, Tokens}; + +fn left_top() -> egui::Id { + egui::Id::new("color-tab-test-origin") +} + +/// The Color tab of `kind`, alone in a 300 pt wide panel, starting from a red foreground. +fn color_tab(kind: ThemeKind) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + let tab = Group::Color.tabs(Tokens::for_kind(kind).pro).iter().position(|name| *name == "Color").unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(300.0, 400.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + let origin = ui.cursor().min; + ui.data_mut(|d| d.insert_temp(left_top(), Rect::from_min_size(origin, egui::Vec2::ZERO))); + crate::panels::dock_body(app, ui, Group::Color, tab); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + h +} + +#[test] +fn every_theme_shows_the_spectrum_field() { + for kind in ThemeKind::ALL { + let mut h = color_tab(kind); + let origin = h.ctx.data(|d| d.get_temp::(left_top())).unwrap().min; + // Left edge of the saturation/brightness field (after the 38 pt chips and the 8 pt gap), + // halfway down its 120 pt: no saturation, half brightness. + let p = pos2(origin.x + 38.0 + 8.0 + 1.0, origin.y + 60.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(2); + let [r, g, b, _] = h.state().session.tools.foreground; + assert!((r - g).abs() < 0.02 && (g - b).abs() < 0.02 && (0.3..0.7).contains(&r), "{kind:?}: the field picks a mid grey, got {r} {g} {b}"); + if !Tokens::for_kind(kind).pro { + assert!(h.query_by_label("Saturation").is_some(), "{kind:?}: the sliders stay under the field"); + } + } +} diff --git a/crates/ui-egui/src/comps_ui.rs b/crates/ui-egui/src/comps_ui.rs index d6dc3e8d82..30304f6c82 100644 --- a/crates/ui-egui/src/comps_ui.rs +++ b/crates/ui-egui/src/comps_ui.rs @@ -18,6 +18,12 @@ pub fn target(app: &PhotocraftApp) -> Option { app.ui.layer_comp_selected.filter(|id| doc.comp(*id).is_some()).or(doc.last_applied_comp.filter(|id| doc.comp(*id).is_some())) } +/// The row's warning triangle: Clear Layer Comp Warning for that comp only (Photoshop keeps +/// Clear All Layer Comp Warnings as a separate command). +fn clear_warning_action(comp: u32) -> (&'static str, Value) { + ("layerComp.updateWarnings", json!({"clear": true, "comp": comp})) +} + fn run(app: &mut PhotocraftApp, id: &str, p: Value) { if let Err(e) = app.run(id, p) { app.ui.status = e; @@ -124,7 +130,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { )); if resp.clicked() { toggle_hit = true; - action = Some(("layerComp.updateWarnings", json!({"clear": true}))); + action = Some(clear_warning_action(c.id)); } } name_x += 18.0; @@ -185,3 +191,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_engine::Session; + + #[test] + fn warning_icon_clears_only_its_own_comp() { + // Regression for #4197. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let layer = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let c1 = s.execute("layerComp.new", json!({"name": "One"})).unwrap()["comp"].as_u64().unwrap() as u32; + let c2 = s.execute("layerComp.new", json!({"name": "Two"})).unwrap()["comp"].as_u64().unwrap() as u32; + s.execute("layer.delete", json!({"layer": layer})).unwrap(); + let (id, p) = clear_warning_action(c1); + assert_eq!(p["comp"], c1); + s.execute(id, p).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(doc.comp(c1).unwrap().missing_layers(doc).is_empty()); + assert_eq!(doc.comp(c2).unwrap().missing_layers(doc).iter().map(|l| l.0).collect::>(), vec![layer]); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d0ef48f11..e797b27cc0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -46,12 +46,22 @@ pub type ControlResponse = Value; /// The control server's per-request reply deadline. pub const REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60); -/// The error both timeout sites return when a request's [`REPLY_DEADLINE`] passes: the -/// transport thread waiting for a reply, and the UI thread rejecting a request it is only -/// now draining. Built from the same constant, so the text cannot drift from the deadline. +/// The error the transport thread returns when a dispatched command gave no reply within +/// [`REPLY_DEADLINE`]. It warns that the command may still apply: re-sending blindly could apply +/// an edit twice (#3662). Built from the deadline constant, so the text cannot drift from it. pub fn timeout_error() -> String { format!( - "timeout: no reply in {}s — the command may still be running, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", + "timeout: no reply in {}s — the command may still be running and may still apply, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", + REPLY_DEADLINE.as_secs() + ) +} + +/// The error for a request still queued when its deadline passed, from the UI thread or the +/// transport (see [`Dispatch`]). It was dropped before dispatch, so re-sending it is safe (#3662). Same `timeout` prefix as +/// [`timeout_error`], which machines match on. +pub fn expired_error() -> String { + format!( + "timeout: not run — the request was still queued when its {}s deadline passed, so it was dropped before dispatch and is safe to re-send; the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", REPLY_DEADLINE.as_secs() ) } @@ -62,12 +72,44 @@ pub struct ControlRequest { pub reply: Sender, /// Requests still queued at this instant are rejected; already dispatched work continues. pub deadline: Option, + /// Shared with the transport, so that when it stops waiting it knows whether the command ran. + pub dispatch: Dispatch, } impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx, deadline: None }, rx) + (Self { method: method.into(), params, reply: tx, deadline: None, dispatch: Dispatch::default() }, rx) + } +} + +/// Whether a request was dispatched, settled once between the UI thread (about to run it) and +/// the transport (giving up on its reply). The transport's clock runs out no later than the UI +/// thread's, so without this it could only ever say the command may still apply (#3662). +#[derive(Clone, Default)] +pub struct Dispatch(std::sync::Arc); + +const QUEUED: u8 = 0; +const DISPATCHED: u8 = 1; +const ABANDONED: u8 = 2; + +impl Dispatch { + /// The UI thread claims the request to run it: false once the transport gave up on it. A + /// request the UI thread rejects as expired stays queued, which `give_up` reads as not run. + pub(crate) fn start(&self) -> bool { + self.settle(DISPATCHED) == DISPATCHED + } + + /// The transport stops waiting: the error to reply with, [`expired_error`] when the command + /// can no longer run and [`timeout_error`] when it was dispatched and may still apply. + pub fn give_up(&self) -> String { + if self.settle(ABANDONED) == DISPATCHED { timeout_error() } else { expired_error() } + } + + /// Moves a queued request to `to`; returns the state it ends in. + fn settle(&self, to: u8) -> u8 { + use std::sync::atomic::Ordering; + self.0.compare_exchange(QUEUED, to, Ordering::AcqRel, Ordering::Acquire).map_or_else(|state| state, |_| to) } } @@ -89,7 +131,7 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 34] = [ +pub const UI_SET_FIELDS: [&str; 35] = [ "tool", "panels", "dock", @@ -124,6 +166,7 @@ pub const UI_SET_FIELDS: [&str; 34] = [ "cropShield", "shapeStroke", "camTransformOnDrop", + "tileGrid", ]; /// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app @@ -323,6 +366,22 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest outcome } +/// The open canvas context menu's rows for `ui.state`: the spelling suggestions first (type edit +/// menu over a misspelled word), then the menu's own rows. +fn context_entries(app: &PhotocraftApp, menu: &crate::canvas_tool_menu::CanvasToolMenu) -> Vec { + use crate::canvas_tool_menu::{entry_enabled, rows, spelling_rows}; + let spelling = spelling_rows(menu); + let mut entries: Vec = spelling.iter().map(|(label, id)| json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)})).collect(); + if !spelling.is_empty() { + entries.push(json!({"separator": true})); + } + entries.extend(rows(menu).iter().map(|row| match row { + Some((label, id)) => json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)}), + None => json!({"separator": true}), + })); + entries +} + fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -450,6 +509,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let eyedropper_ring = bool_field(p, "eyedropperRing")?; // Content-Aware Move's Transform On Drop checkbox. let cam_transform_on_drop = bool_field(p, "camTransformOnDrop")?; + // Developer overlay of the active layer's storage tiles (tile_overlay.rs). + let tile_grid = bool_field(p, "tileGrid")?; // The Crop tool's overlay menu (#1919). let crop_overlay = match p.get("cropOverlay") { Some(v) => Some(v.as_str().and_then(crate::crop_overlay::CropOverlay::from_id).ok_or_else(|| { @@ -608,6 +669,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(on) = cam_transform_on_drop { app.ui.tool_options.cam_transform_on_drop = on; } + if let Some(on) = tile_grid { + app.ui.view.tile_grid = on; + } if let Some(k) = crop_overlay { app.ui.tool_options.crop_overlay = k; } @@ -979,7 +1043,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { if down { - crate::canvas_tool_menu::open(app, ctx, app.ui.tool, screen_point(app, x, y)); + crate::canvas_tool_menu::open_at(app, ctx, app.ui.tool, screen_point(app, x, y), Some([x, y])); } continue; } @@ -1211,10 +1275,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "pos": menu.pos, "tool": menu.tool, "pasteboard": menu.pasteboard, - "entries": crate::canvas_tool_menu::rows(menu).iter().map(|row| match row { - Some((label, id)) => json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)}), - None => json!({"separator": true}), - }).collect::>() + "entries": context_entries(app, menu) }) }), "panels": app.ui.panels, @@ -1565,11 +1626,54 @@ mod tests { retry.deadline = Some(Instant::now() + Duration::from_secs(60)); tx.send(retry).unwrap(); app.drain_control(&egui::Context::default()); - assert_eq!(expired_reply.try_recv().unwrap(), json!({"ok": false, "error": super::timeout_error()})); + let expired_error = expired_reply.try_recv().unwrap()["error"].as_str().unwrap().to_string(); assert_eq!(retry_reply.try_recv().unwrap()["ok"], true); let after = app.session.active().unwrap(); assert_eq!(after.doc.layers.len(), layers + 1, "only the live retry adds a layer"); assert_eq!(after.history.past_len(), steps + 1, "the expired edit adds no undo step"); + // #3662: a caller must be able to tell this reply, safe to re-send, from the + // transport's, sent while the command may still apply. + assert!(expired_error.starts_with("timeout"), "machines match on the prefix: {expired_error}"); + assert!(expired_error.contains("not run"), "the expired request says it never ran: {expired_error}"); + let transport_error = super::timeout_error(); + assert!(!transport_error.contains("not run"), "the transport can't know it didn't run: {transport_error}"); + assert!(transport_error.contains("may still apply"), "the transport warns against a blind re-send: {transport_error}"); + } + + #[test] + fn a_transport_that_gives_up_learns_whether_the_command_can_still_run() { + // #3662: the transport's clock runs out no later than the UI thread's, so it is the + // one that answers; it must still tell a request that will never run from one that may. + use std::time::{Duration, Instant}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let layers = app.session.active().unwrap().doc.layers.len(); + let (tx, rx) = std::sync::mpsc::channel(); + app = app.with_control(rx); + let ctx = egui::Context::default(); + let request = |deadline: Instant| { + let (mut req, reply) = ControlRequest::new("engine.execute", json!({"command": "layer.new.layer"})); + req.deadline = Some(deadline); + (req.dispatch.clone(), req, reply) + }; + + let (dispatch, queued, _) = request(Instant::now() + Duration::from_secs(60)); + tx.send(queued).unwrap(); + assert_eq!(dispatch.give_up(), super::expired_error(), "still queued: it will not run"); + app.drain_control(&ctx); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "a request given up on never runs"); + + let (dispatch, expired, _) = request(Instant::now() - Duration::from_secs(1)); + tx.send(expired).unwrap(); + app.drain_control(&ctx); + assert_eq!(dispatch.give_up(), super::expired_error(), "rejected by the UI thread: it did not run"); + + let (dispatch, live, reply) = request(Instant::now() + Duration::from_secs(60)); + tx.send(live).unwrap(); + app.drain_control(&ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1); + assert_eq!(dispatch.give_up(), super::timeout_error(), "dispatched: it may still apply"); } #[test] @@ -1773,6 +1877,25 @@ mod tests { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } + #[test] + fn ui_set_toggles_the_developer_tile_grid() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert!(!app.ui.view.tile_grid, "off by default"); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": true}))["ok"], true); + assert!(app.ui.view.tile_grid); + assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["view"]["tile_grid"], true); + // Not a View › Show item: Extras doesn't hide it, and Show Extras Options doesn't list it. + crate::menus::invoke(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras && app.ui.view.tile_grid); + assert!(serde_json::to_value(app.ui.view.show).unwrap().get("tile_grid").is_none()); + let bad = call(&mut app, &ctx, "ui.set", json!({"tileGrid": "yes"})); + assert_eq!(bad["ok"], false, "{bad}"); + assert!(app.ui.view.tile_grid, "a rejected value changes nothing"); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": false}))["ok"], true); + assert!(!app.ui.view.tile_grid); + } + #[test] fn ui_set_drives_the_eyedropper_options() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index df25c568ff..54ec3795d2 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -863,7 +863,8 @@ pub(crate) fn resize_ratio(app: &PhotocraftApp, rect: [f64; 4], mods: Modifiers) preset_ratio(app).or(if mods.shift { keep } else { None }) } -/// Cursor over the canvas at document point `p`: resize arrows on the edges, move inside. +/// Cursor over the canvas at document point `p`: resize arrows on the edges, move inside (the +/// crosshair inside the untouched default frame, where a drag draws a new one). pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { if app.ui.tool != Tool::Crop { return None; @@ -882,6 +883,8 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { Some(match h { // The arrow as the handle faces on screen: the frame's turn plus the view's. Hit::Handle(hx, hy) => resize_icon(hx, hy, deg + f64::from(crate::crop_mode::view_rotation(app))), + // Inside the untouched default frame a drag draws a new one (`pointer`), so nothing moves (#4461). + Hit::Inside if app.crop.default_frame => CursorIcon::Crosshair, Hit::Inside => CursorIcon::Move, // Photoshop's curved two-headed arrow (`draw_turn_cursor`, from `turn_cursor_dir`). Hit::Outside => CursorIcon::None, @@ -1787,6 +1790,20 @@ mod tests { assert_eq!(cursor(&app, [60.0, 40.0]), None); } + /// #4461: inside the untouched default frame a drag draws a new frame, so the pointer is the + /// drawing crosshair there, not the move cursor; once the frame is changed a drag moves it. + #[test] + fn cursor_inside_the_default_frame_draws() { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(app.crop.default_frame && app.ui.crop_rect == Some([0.0, 0.0, 200.0, 100.0])); + assert_eq!(cursor(&app, [100.0, 50.0]), Some(CursorIcon::Crosshair)); + assert_eq!(cursor(&app, [200.0, 50.0]), Some(CursorIcon::ResizeHorizontal), "its handles still resize"); + drag(&mut app, &[[200.0, 50.0], [150.0, 50.0]], NONE); + assert!(!app.crop.default_frame && app.ui.crop_rect == Some([0.0, 0.0, 150.0, 100.0])); + assert_eq!(cursor(&app, [100.0, 50.0]), Some(CursorIcon::Move)); + } + /// One key press through the app's keyboard handling (`shortcuts::handle`). fn key(app: &mut PhotocraftApp, key: egui::Key, modifiers: Modifiers) { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b5e0113784..7ae2730bd0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -99,9 +99,37 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l))) } +fn show_modal( + modal: egui::Modal, + ctx: &egui::Context, + id: egui::Id, + resizable: bool, + interactive: bool, + contents: impl FnOnce(&mut egui::Ui) -> T, +) -> egui::ModalResponse { + modal.show(ctx, |ui| { + // Disable the entire older modal, including its resize grip, not just its body. + if !interactive { + ui.disable(); + } + if resizable { + let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0)); + egui::Resize::default() + .id(id.with("resize")) + .default_size(egui::vec2(960.0, 900.0).min(max)) + .min_size(egui::vec2(560.0, 400.0)) + .max_size(max) + .show(ui, contents) + } else { + contents(ui) + } + }) +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::layer_style::color_picker::prune(app); crate::type_panels_ui::color_picker::prune(app); + crate::edit_history::track_dialogs(app, ctx); let dialogs = app.ui.dialogs.clone(); let top = dialogs.last().map(|d| d.id); let mut shown = Vec::new(); @@ -147,12 +175,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Tab / ⇧Tab walk the topmost dialog's text fields, not every widget (field_tab.rs). let tab = if top == Some(d.id) && !egui::Popup::is_any_open(ctx) { crate::field_tab::take_step(ctx) } else { 0 }; // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. - let modal = modal.show(ctx, |ui| { - if !interactive { - ui.disable(); - } + let web = fields.contains_key("__web"); + let web_popup_open = web && egui::Popup::is_any_open(ctx); + let modal = show_modal(modal, ctx, id, web, interactive, |ui| { sizing = ui.is_sizing_pass(); - ui.set_min_width(380.0); + let available = ui.available_size(); + // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title + // rule and the button row fill the width they are given, so it needs its own maximum too. + let compact = d.fields.contains_key("__compact"); + if !compact { + ui.set_min_width(380.0); + } let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); if let Some(w) = wide { ui.set_min_width(w.min(460.0)); @@ -171,10 +204,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { 700.0 } else if d.kind == DialogKind::LayerStyle || d.fields.contains_key("__export") || crate::color_picker_ui::owns(&d.fields) { 600.0 + } else if compact { + 260.0 } else { 440.0 })); - if let Some(w) = crate::file_ui::dialog_width(&d.fields) { + if web { + ui.set_max_width(available.x); + ui.set_min_size(available); + } else if let Some(w) = crate::file_ui::dialog_width(&d.fields) { ui.set_min_width(w); ui.set_max_width(w); } @@ -226,9 +264,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { about_tab_body(app, ui, chosen); } DialogKind::Command if crate::guide_layout_ui::owns(&fields) => outcome = crate::guide_layout_ui::body(app, ui, &mut fields, interactive), + DialogKind::Command if crate::brush_preset_dialog::owns(&fields) => { + outcome = crate::brush_preset_dialog::body(app, ui, &mut fields, interactive) + } DialogKind::Command if crate::fill_ui::owns(&fields) => crate::fill_ui::body(app, ui, &mut fields), DialogKind::Command if crate::pattern_fill_ui::owns(&fields) => crate::pattern_fill_ui::body(app, ui, &mut fields), DialogKind::Command if crate::stroke_ui::owns(&fields) => crate::stroke_ui::body(ui, &mut fields), + DialogKind::Command if crate::channel_options_ui::owns(&fields) => crate::channel_options_ui::body(ui, &mut fields), + DialogKind::Command if crate::channel_options_ui::owns_new_spot(&fields) => crate::channel_options_ui::new_spot_body(ui, &mut fields), DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields), DialogKind::Command if crate::actions::owns_insert_stop(&fields) => crate::actions::insert_stop_body(ui, &mut fields), @@ -236,6 +279,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { 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::stack_dialog::owns(&fields) => crate::stack_dialog::body(app, ui, &mut fields, d.id), 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), @@ -281,8 +325,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } crate::field_tab::end(ui.ctx(), tab); - if crate::guide_layout_ui::owns(&fields) { - // This dialog owns its right-side actions and validates Enter itself. + if crate::guide_layout_ui::owns(&fields) || crate::brush_preset_dialog::owns(&fields) { + // These dialogs own their right-side actions and validate Enter themselves. } else if crate::color_picker_ui::owns(&fields) { if interactive && outcome.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { outcome = Some(true); @@ -308,7 +352,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::file_ui::ok_label(&d.fields).unwrap_or(tl!("OK")) }; // Dialogs that need an input first (a preset name, a selection) grey OK out. - let can_ok = crate::crop_presets::can_confirm(app, &fields); + let can_ok = crate::crop_presets::can_confirm(app, &fields) && crate::stack_dialog::can_confirm(&fields); let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok); // Photoshop: holding Alt turns Cancel into Reset (the dialog stays open). // Dialogs opt in via `dialog_alt_reset::supported` (per marker). @@ -358,7 +402,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(ButtonRole::Cancel) => outcome = Some(false), Some(ButtonRole::Apply) => apply_requested = true, Some(_) => outcome = Some(true), - None if interactive && can_ok && ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true), + None if interactive + && can_ok + && (!web || (!web_popup_open && !egui::Popup::is_any_open(ui.ctx()))) + && ui.input(|i| i.key_pressed(egui::Key::Enter)) => + { + outcome = Some(true) + } None => {} } } @@ -370,7 +420,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // The picker has a larger frame margin; include that whole frame in the canvas hit guard. shown.push(if color_picker { modal.response.rect } else { modal.inner }); // Pin once laid out at its real size (the first frame is an invisible sizing pass). - if !sizing && (pinned.is_none() || drag != egui::Vec2::ZERO) { + if !sizing && (web || pinned.is_none() || drag != egui::Vec2::ZERO) { let screen = ctx.content_rect(); // Keep the whole dialog (and so its title bar) on screen. let room = (screen.size() - modal.response.rect.size()).max(egui::Vec2::ZERO); @@ -499,9 +549,12 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { r } DialogKind::Command if crate::guide_layout_ui::owns(&d.fields) => crate::guide_layout_ui::confirm(app, &d.fields), + DialogKind::Command if crate::brush_preset_dialog::owns(&d.fields) => crate::brush_preset_dialog::confirm(app, &d.fields), DialogKind::Command if crate::fill_ui::owns(&d.fields) => crate::fill_ui::confirm(app, &d.fields), DialogKind::Command if crate::pattern_fill_ui::owns(&d.fields) => crate::pattern_fill_ui::confirm(app, &d.fields), DialogKind::Command if crate::stroke_ui::owns(&d.fields) => crate::stroke_ui::confirm(app, &d.fields), + DialogKind::Command if crate::channel_options_ui::owns(&d.fields) => crate::channel_options_ui::confirm(app, &d.fields), + DialogKind::Command if crate::channel_options_ui::owns_new_spot(&d.fields) => crate::channel_options_ui::confirm_new_spot(app, &d.fields), DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields), DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), DialogKind::Command if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), @@ -509,6 +562,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { 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::stack_dialog::owns(&d.fields) => crate::stack_dialog::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), @@ -875,4 +929,23 @@ mod tests { h.run_steps(2); assert_eq!(radius(&h), Some(5.0), "a cancelled 9 is not remembered"); } + + /// #4673: File › Automate › Fit Image holds two numbers and a check box, so its window is as + /// wide as that content and its buttons, not the 380-point floor of the larger dialogs. + #[test] + fn fit_image_is_as_narrow_as_its_content() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let ctx = h.ctx.clone(); + let d = crate::menus::invoke(h.state_mut(), &ctx, "file.automate.fitImage", json!({})).unwrap()["dialog"].as_u64().unwrap(); + h.run_steps(4); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", d)))).expect("the Fit Image window"); + assert!(window.width() < 300.0, "Fit Image is {} points wide", window.width()); + for label in ["Fit Image", "Width", "Height", "OK", "Cancel"] { + assert!(window.contains_rect(h.get_by_label(label).rect()), "{label} inside the window"); + } + } } diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index 01deb0a070..b18361828d 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -134,12 +134,17 @@ pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool { || matches!(app.ui.tool, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) } -/// The paths Direct Selection edits, front first: the targeted vector mask or the active shape -/// layer's path, then the work path. -pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { +/// The active paths, front first: the targeted vector mask or the active shape layer's path, then +/// the work path. The Pen's Auto Add/Delete edits these (`anchor_tools::pen_auto`). +pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { let layer = crate::vector_ui::targeted_vector_mask(app).or_else(|| crate::vector_ui::active_shape_path(app)); let work = app.session.active().and_then(|st| st.doc.work_path.clone()); - let mut out: Vec<_> = layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect(); + layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect() +} + +/// The paths Direct Selection edits, front first: the active paths, then every visible shape layer. +pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { + let mut out = active_paths(app); if let Some(st) = app.session.active() { for (path, _, l) in st.doc.walk().iter().rev() { if !l.visible || !(1..=path.len()).all(|n| st.doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) { diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index b7ca6ebd60..bf70ae944f 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -249,7 +249,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // The answers in the platform's words; `dialog_buttons` puts them in its order. Windows (and // Linux) ask Yes / No / Cancel with Y, N and Esc, as Photoshop does there; macOS asks // Don't Save / Cancel / Save. Esc always cancels (the modal closes on it). - let cancel = (ButtonRole::Cancel, "Cancel", mac.then_some(Key::C), 84.0, Answer::Cancel); + // Yes/No already show Y/N. Cancel gets C on every platform (#4582). macOS still hides the + // letters and keeps the shortcut. + let cancel = (ButtonRole::Cancel, "Cancel", Some(Key::C), 84.0, Answer::Cancel); let buttons = if reverts { vec![(ButtonRole::Default, "Revert", Some(Key::R), 84.0, Answer::Discard), cancel] } else if mac { @@ -506,18 +508,14 @@ mod tests { fn windows_and_linux_ask_yes_no_cancel_with_the_default_first() { for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { let mut h = prompt_on(os); - let labels = ["(Y)es", "(N)o", "Cancel"]; + let labels = ["(Y)es", "(N)o", "(C)ancel"]; assert_eq!(drawn_order(&h, labels), labels, "{os:?}"); tab_walks(&mut h, labels); h.key_press(Key::N); h.run_steps(2); assert_eq!(docs_left(&h), Some(1), "N answered the first document"); - // Cancel has no letter here: C does nothing, Esc cancels. h.key_press(Key::C); h.run_steps(2); - assert_eq!(docs_left(&h), Some(1)); - h.key_press(Key::Escape); - h.run_steps(2); assert!(h.state().discard.is_none()); assert_eq!(h.state().session.documents().len(), 1, "Cancel keeps the unanswered document"); } @@ -584,6 +582,7 @@ mod tests { assert_eq!(button_label(true, "Don't Save", Some(Key::D)), tl!("Don't Save")); assert_eq!(button_label(true, "Cancel", Some(Key::C)), tl!("Cancel")); assert_eq!(button_label(false, "Don't Save", Some(Key::D)), "(D)on't Save"); + assert_eq!(button_label(false, "Cancel", Some(Key::C)), "(C)ancel"); } #[test] diff --git a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs index e4c83bda64..565fe01037 100644 --- a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs +++ b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs @@ -113,7 +113,7 @@ fn save_a_copy_to_a_targa_asks_and_keeps_the_document_unsaved() { crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); h.run_steps(3); assert_eq!(h.state().targa_options.as_ref().map(|p| (p.bits, p.copy)), Some((TgaBits::Bits32, true))); - assert!(targa_options_ui::park(h.state_mut(), "other.tga".into(), false, true).is_err(), "the waiting prompt is kept"); + assert!(targa_options_ui::park(h.state_mut(), "other.tga".into(), false, "file.saveAs").is_err(), "the waiting prompt is kept"); assert_eq!(h.state().targa_options.as_ref().map(|p| p.path.as_str()), Some("copy.tga")); h.key_press(Key::Enter); h.run_steps(3); @@ -140,8 +140,8 @@ fn targa_options_keeps_a_layered_save_as_a_copy() { assert_eq!(st.path, None); } -/// An action being recorded keeps a Targa Save As with its bits, answered in Targa Options or -/// scripted (#2032), and plays it back without asking; the dialog's Save a Copy isn't recorded. +/// An action being recorded keeps a Targa Save As or Save a Copy with its bits, answered in Targa +/// Options or scripted (#2032, #4373), and plays them back without asking. #[test] fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { let (mut h, writes) = targa_app(true, "copy.tga"); @@ -164,12 +164,22 @@ fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { &[("t.tga".to_string(), asked(Some(24))), ("copy.tga".to_string(), asked(Some(32))), ("s.tga".to_string(), asked(Some(32)))] ); let steps = &h.state().session.actions.list[0].steps; - assert_eq!(steps, &[("file.saveAs".into(), json!({"path": "t.tga", "tgaBits": 24})), ("file.saveAs".into(), json!({"path": "s.tga", "tgaBits": 32}))]); + assert_eq!( + steps, + &[ + ("file.saveAs".into(), json!({"path": "t.tga", "tgaBits": 24})), + ("file.saveACopy".into(), json!({"path": "copy.tga", "tgaBits": 32})), + ("file.saveAs".into(), json!({"path": "s.tga", "tgaBits": 32})) + ] + ); writes.borrow_mut().clear(); let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); assert!(played.get("failed").is_none(), "{played}"); assert!(h.state().targa_options.is_none(), "playback doesn't ask"); - assert_eq!(writes.borrow().as_slice(), &[("t.tga".to_string(), asked(Some(24))), ("s.tga".to_string(), asked(Some(32)))]); + assert_eq!( + writes.borrow().as_slice(), + &[("t.tga".to_string(), asked(Some(24))), ("copy.tga".to_string(), asked(Some(32))), ("s.tga".to_string(), asked(Some(32)))] + ); } /// Closing an unsaved document whose save goes to a Targa waits behind Targa Options, then closes. diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 0a0d7914b0..9f0966b5c3 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -172,11 +172,16 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } - if app.distort.puppet.is_some() { + if let Some(p) = app.distort.puppet.as_mut() { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; if enter(ctx) { crate::puppet_ui::commit(app); } else if esc(ctx) { app.distort.puppet = None; + } else if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + p.redo(); + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + p.undo(); } return true; } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 4bd043da66..0aab2769ea 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -601,6 +601,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + if tabs.get(sel) == Some(&"Brush Settings") { + crate::brush_panel::panel_menu(app, ui); + ui.separator(); + } if tabs.get(sel) == Some(&"Actions") { crate::actions::panel_menu(app, ui); ui.separator(); diff --git a/crates/ui-egui/src/edit_history.rs b/crates/ui-egui/src/edit_history.rs new file mode 100644 index 0000000000..2012f912c7 --- /dev/null +++ b/crates/ui-egui/src/edit_history.rs @@ -0,0 +1,241 @@ +//! Undo inside a dialog (#4697): ⌘Z / ⇧⌘Z step through the open dialog's own settings, as in +//! Photoshop, instead of reaching the document's History beneath it (which the dialog previews +//! and is about to change), or doing nothing. +//! +//! A dialog keeps an [`EditHistory`] of its settings and calls [`EditHistory::settle`] every frame +//! and after a control-channel edit. A change becomes one step once no edit is in progress, so a +//! slider drag is one step rather than one per frame. Liquify, Puppet Warp and Camera Raw keep +//! their own histories. + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::PhotocraftApp; +use crate::state::DialogKind; + +/// Most steps a dialog keeps. +const LIMIT: usize = 100; + +pub struct EditHistory { + undo: Vec, + redo: Vec, + /// The settings as of the latest step. + settled: T, +} + +impl EditHistory { + pub fn new(now: &T) -> Self { + Self { undo: Vec::new(), redo: Vec::new(), settled: now.clone() } + } + + /// Records a change since the latest step as a new step, unless `busy` (a drag in progress). + pub fn settle(&mut self, now: &T, busy: bool) { + if busy || *now == self.settled { + return; + } + if self.undo.len() >= LIMIT { + self.undo.remove(0); + } + self.undo.push(std::mem::replace(&mut self.settled, now.clone())); + self.redo.clear(); + } + + /// Steps `now` back (`Undo`) or forward (`Redo`); an unfinished edit is the latest step. + /// False when there is nowhere to go. + pub fn step(&mut self, now: &mut T, step: Step) -> bool { + self.settle(now, false); + let (from, to) = match step { + Step::Undo => (&mut self.undo, &mut self.redo), + Step::Redo => (&mut self.redo, &mut self.undo), + }; + let Some(next) = from.pop() else { return false }; + to.push(std::mem::replace(now, next.clone())); + self.settled = next; + true + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step { + Undo, + Redo, +} + +/// ⌘Z or ⇧⌘Z pressed this frame, consumed. +fn take_step(ctx: &egui::Context) -> Option { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + Some(Step::Redo) + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + Some(Step::Undo) + } else { + None + } +} + +/// A parameter dialog's settings: its fields without the private `__` keys (preview toggle, +/// command, label…). +fn settings(fields: &Map) -> Map { + fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect() +} + +/// The parameter dialogs' histories, by dialog id. +pub type DialogEdits = HashMap>>; + +/// Once a frame: records each open parameter dialog's settings and forgets closed dialogs. +pub fn track_dialogs(app: &mut PhotocraftApp, ctx: &egui::Context) { + let busy = ctx.input(|i| i.pointer.any_down()); + let dialogs = &app.ui.dialogs; + app.dialog_edits.retain(|id, _| dialogs.iter().any(|d| d.id == *id)); + for d in dialogs.iter().filter(|d| d.kind == DialogKind::Command) { + let now = settings(&d.fields); + app.dialog_edits.entry(d.id).or_insert_with(|| EditHistory::new(&now)).settle(&now, busy); + } +} + +/// ⌘Z / ⇧⌘Z while a dialog with its own history is in front: steps that dialog, and the keys +/// never reach the document's History beneath it. A focused text field keeps them for its typing. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + if ctx.text_edit_focused() { + return; + } + if let Some(top) = app.ui.dialogs.last() { + if top.kind != DialogKind::Command { + return; + } + let id = top.id; + let mut now = settings(&top.fields); + if let Some(step) = take_step(ctx) + && app.dialog_edits.entry(id).or_insert_with(|| EditHistory::new(&now)).step(&mut now, step) + && let Some(d) = app.ui.dialog_mut(id) + { + d.fields.retain(|k, _| k.starts_with("__")); + d.fields.extend(now); + } + } else if let Some(d) = app.distort.gallery.as_mut() { + if let Some(step) = take_step(ctx) { + d.step(step); + } + } else if let Some(d) = app.wide_angle.as_mut() { + if let Some(step) = take_step(ctx) { + d.step(step); + } + } else if let Some(s) = app.distort.vanishing.as_mut() + && let Some(step) = take_step(ctx) + { + s.step(step); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use serde_json::json; + + use super::*; + + /// One frame of the shell's key handling with ⌘Z (plus `modifiers`) pressed. + pub(crate) fn press_z(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: egui::Modifiers) { + let raw = egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 120, "height": 80})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#808080"})).unwrap(); + app + } + + fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() + } + + #[test] + fn a_drag_is_one_step_and_a_new_edit_drops_redo() { + let mut h = EditHistory::new(&0); + for v in 1..=5 { + h.settle(&v, true); + } + h.settle(&5, false); + h.settle(&7, false); + let mut now = 7; + assert!(h.step(&mut now, Step::Undo)); + assert_eq!(now, 5); + assert!(h.step(&mut now, Step::Undo)); + assert_eq!(now, 0, "the whole drag was one step"); + assert!(!h.step(&mut now, Step::Undo)); + assert!(h.step(&mut now, Step::Redo)); + assert_eq!(now, 5); + now = 9; + assert!(!h.step(&mut now, Step::Redo), "a new edit drops what was undone"); + assert!(h.step(&mut now, Step::Undo)); + assert_eq!(now, 5, "an unsettled edit is undone first"); + } + + #[test] + fn command_z_steps_a_parameter_dialog_and_leaves_its_private_keys() { + let ctx = egui::Context::default(); + let mut app = app(); + let steps = past(&app); + let id = crate::dialogs::open_command_dialog(&mut app, "filter.lensCorrection", "Lens Correction"); + track_dialogs(&mut app, &ctx); + let field = |app: &PhotocraftApp, k: &str| app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(k).cloned(); + let vertical = field(&app, "vertical"); + let private: Vec = app.ui.dialog_mut(id).unwrap().fields.keys().filter(|k| k.starts_with("__")).cloned().collect(); + assert!(!private.is_empty()); + app.ui.dialog_mut(id).unwrap().fields.insert("vertical".into(), json!(-50.0)); + track_dialogs(&mut app, &ctx); + app.ui.dialog_mut(id).unwrap().fields.insert("horizontal".into(), json!(30.0)); + track_dialogs(&mut app, &ctx); + press_z(&mut app, &ctx, egui::Modifiers::COMMAND); + assert_ne!(field(&app, "horizontal"), Some(json!(30.0))); + assert_eq!(field(&app, "vertical"), Some(json!(-50.0)), "one step at a time"); + press_z(&mut app, &ctx, egui::Modifiers::COMMAND); + assert_eq!(field(&app, "vertical"), vertical); + assert_eq!(past(&app), steps, "the document beneath is untouched"); + for k in &private { + assert!(field(&app, k).is_some(), "{k} survives"); + } + press_z(&mut app, &ctx, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(field(&app, "vertical"), Some(json!(-50.0))); + } + + #[test] + fn command_z_steps_the_filter_gallery_stack() { + let ctx = egui::Context::default(); + let mut app = app(); + let steps = past(&app); + crate::distort_ui::menu(&mut app, &ctx, "filter.filterGallery", &json!({})).unwrap().unwrap(); + let before = app.distort.gallery.as_ref().unwrap().effects.clone(); + crate::distort_ui::menu(&mut app, &ctx, "filter.filterGallery", &json!({"ui": {"add": true}})).unwrap().unwrap(); + assert_eq!(app.distort.gallery.as_ref().unwrap().effects.len(), before.len() + 1); + press_z(&mut app, &ctx, egui::Modifiers::COMMAND); + let d = app.distort.gallery.as_ref().unwrap(); + assert_eq!(d.effects, before); + assert!(d.selected < d.effects.len()); + assert_eq!(past(&app), steps); + } + + #[test] + fn command_z_steps_vanishing_point_planes() { + let ctx = egui::Context::default(); + let mut app = app(); + let steps = past(&app); + crate::distort_ui::menu(&mut app, &ctx, "filter.vanishingPoint", &json!({})).unwrap().unwrap(); + let plane = json!([{"corners": [[30, 20], [90, 20], [110, 70], [10, 70]]}]); + crate::distort_ui::menu(&mut app, &ctx, "filter.vanishingPoint", &json!({"ui": {"planes": plane}})).unwrap().unwrap(); + press_z(&mut app, &ctx, egui::Modifiers::COMMAND); + let s = app.distort.vanishing.as_ref().unwrap(); + assert!(s.planes.is_empty()); + assert_eq!(s.selected, None, "no selection left on a plane that is gone"); + assert_eq!(past(&app), steps); + press_z(&mut app, &ctx, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(app.distort.vanishing.as_ref().unwrap().planes.len(), 1); + } +} diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index cd520d5c30..8fa630049a 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -11,7 +11,47 @@ use crate::state::DialogKind; use crate::theme::Tokens; use crate::{ExportSettings, PhotocraftApp}; -const FORMATS: [(&str, &str); 5] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA")]; +const FORMATS: [(&str, &str); 6] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("ico", "ICO")]; + +/// Preferences ▸ remembered dialog settings: Export As writes the settings it was used with +/// last under its command id and seeds the next dialog from them, as Fill, Liquify and the +/// filter dialogs do. +pub(crate) const COMMAND: &str = "file.export.exportAs"; + +/// The dialog is seeded from the settings it was used with last (see [`COMMAND`]); a value that +/// no longer matches a control (a hand-edited preferences file) is left at its default. +fn seed_remembered(app: &PhotocraftApp, f: &mut Map) { + let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) else { return }; + if let Some(v) = saved.get("format").and_then(Value::as_str).filter(|v| FORMATS.iter().any(|(k, _)| k == v)) { + f.insert("format".into(), json!(v)); + } + if let Some(v) = saved.get("quality").and_then(Value::as_f64).filter(|v| (1.0..=100.0).contains(v)) { + f.insert("quality".into(), json!(v)); + } + if let Some(v) = saved.get("scale").and_then(Value::as_f64).filter(|v| (1.0..=1000.0).contains(v)) { + f.insert("scale".into(), json!(v)); + } + for key in ["lossless", "transparency"] { + if let Some(v) = saved.get(key).and_then(Value::as_bool) { + f.insert(key.into(), json!(v)); + } + } + if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "all"].contains(v)) { + f.insert("metadata".into(), json!(v)); + } + if let Some(v) = saved.get("tgaBits").and_then(Value::as_str).filter(|v| ["auto", "24", "32"].contains(v)) { + f.insert("tgaBits".into(), json!(v)); + } +} + +/// Record the settings for the next Export As (see [`seed_remembered`]). +fn remember(app: &mut PhotocraftApp, f: &Map) { + const KEYS: [&str; 7] = ["format", "quality", "lossless", "transparency", "scale", "metadata", "tgaBits"]; + let saved: Map = KEYS.iter().filter_map(|k| f.get(*k).map(|v| ((*k).to_string(), v.clone()))).collect(); + app.session.prefs.edit(|p| { + p.dialogs.insert(COMMAND.into(), Value::Object(saved)); + }); +} pub fn open(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; @@ -27,6 +67,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("tgaBits".into(), json!("auto")); f.insert("__w".into(), json!(st.doc.size.width)); f.insert("__h".into(), json!(st.doc.size.height)); + seed_remembered(app, &mut f); Ok(app.ui.open_dialog(DialogKind::Command, f)) } @@ -176,6 +217,9 @@ fn export_document(doc: &Document, f: &Map, max_side: Option // Both dimensions are sent: a scale can leave a 1 px width unchanged while halving the // height, and the engine would otherwise keep the aspect ratio from the width alone. let (w, h) = scaled_size(doc.size.width as f64, doc.size.height as f64, scale); + if s_fmt(f) == "ico" && (w > 256.0 || h > 256.0) && max_side.is_none() { + return Err("ICO export is limited to 256 × 256 pixels; reduce the export scale".into()); + } if (w - doc.size.width as f64).abs() >= 1.0 || (h - doc.size.height as f64).abs() >= 1.0 { s.execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})) .map_err(|e| e.to_string())?; @@ -240,7 +284,8 @@ fn settings(f: &Map) -> ExportSettings { /// Estimated size (bytes) from a ≤512 px proxy encode, scaled by pixel count. fn estimate(app: &PhotocraftApp, doc: &Document, f: &Map) -> Option { let export = app.services.export.as_ref()?; - let proxy = export_document(doc, f, Some(512)).ok()?; + let max = if s_fmt(f) == "ico" { 256 } else { 512 }; + let proxy = export_document(doc, f, Some(max)).ok()?; let (bytes, _) = export(&proxy, &format!("estimate.{}", s_fmt(f)), &settings(f)).ok()?; let scale = n(f, "scale", 100.0) / 100.0; let full = doc.size.width as f64 * scale * doc.size.height as f64 * scale; @@ -310,6 +355,9 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map 256.0 || h > 256.0) { + ui.label(egui::RichText::new(tl!("ICO is limited to 256 × 256 pixels")).color(t.warning)); + } }); ui.add_space(12.0); // Right: preview + estimated size. @@ -355,6 +403,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { + remember(app, f); if let Some(layers) = f.get("__layers").and_then(Value::as_array) { let ids: Vec<_> = layers.iter().filter_map(Value::as_u64).map(photocraft_doc::LayerId).collect(); return confirm_layers(app, f, &ids); @@ -450,8 +499,71 @@ fn quick_export(app: &mut PhotocraftApp, p: Value) -> Result { #[cfg(test)] mod tests { + #[test] + fn ico_export_is_available_and_rejects_oversize_output() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let mut f = Map::new(); + f.insert("format".into(), json!("ico")); + f.insert("scale".into(), json!(100)); + assert!(FORMATS.iter().any(|(ext, _)| *ext == "ico")); + assert!(export_document(&app.session.active().unwrap().doc, &f, None).err().is_some_and(|e| e.contains("256"))); + f.insert("scale".into(), json!(50)); + let resized = export_document(&app.session.active().unwrap().doc, &f, None).unwrap(); + assert_eq!((resized.size.width, resized.size.height), (150, 100)); + } + use super::*; + /// Export As reopens with the settings it was used with last (Preferences ▸ remembered + /// dialog settings), and a value no control accepts keeps its default. + #[test] + fn export_as_opens_with_the_settings_used_last() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.session.prefs.edit(|p| { + p.dialogs.insert( + COMMAND.into(), + json!({"format": "jpg", "quality": 88, "scale": 37, "transparency": false, "metadata": "all", "tgaBits": "24", "lossless": true}), + ); + }); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "jpg"); + assert_eq!(f["quality"], json!(88.0)); + assert_eq!(f["scale"], json!(37.0)); + assert_eq!(f["transparency"], false); + assert_eq!(f["metadata"], "all"); + assert_eq!(f["tgaBits"], "24"); + + app.session.prefs.edit(|p| { + p.dialogs.insert(COMMAND.into(), json!({"format": "nope", "scale": 0, "metadata": "sometimes"})); + }); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "png"); + assert_eq!(f["scale"], 100); + assert_eq!(f["metadata"], "none"); + } + + /// Only the dialog's own settings are remembered: not the destination, the document size or + /// the layer the dialog was opened with. + #[test] + fn remembering_keeps_only_the_dialog_settings() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut f = Map::new(); + f.insert("format".into(), json!("webp")); + f.insert("scale".into(), json!(50)); + f.insert("path".into(), json!("/tmp/x.webp")); + f.insert("__w".into(), json!(1200)); + f.insert("__layer".into(), json!(3)); + remember(&mut app, &f); + let saved = app.session.prefs().dialogs.get(COMMAND).cloned().unwrap(); + assert_eq!(saved["format"], "webp"); + assert_eq!(saved["scale"], 50); + assert!(saved.get("path").is_none() && saved.get("__w").is_none() && saved.get("__layer").is_none()); + } + #[test] fn export_document_scales_height_when_rounded_width_is_unchanged() { use photocraft_doc::{Color, ColorMode, SampleType, Size}; diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 30b650c9b3..486a13dee0 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -291,7 +291,7 @@ impl PhotocraftApp { } /// The folder an open dialog starts in: the directory of the most recent file (UI-217-3). -fn last_used_dir(recent: &[String]) -> Option { +pub(crate) fn last_used_dir(recent: &[String]) -> Option { let dir = std::path::Path::new(recent.first()?).parent()?; (!dir.as_os_str().is_empty()).then(|| dir.to_string_lossy().into_owned()) } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 202cc73888..8e19c67b48 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -726,3 +726,51 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() { got.sort_unstable(); assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %"); } + +/// #3806: File › Save on an OpenRaster file that can't hold the document (here an adjustment +/// layer) doesn't overwrite it: Save As opens on a PSD with a notice saying what the .ora would +/// drop, and the PSD becomes the document's file. A plain document still saves back to its .ora. +#[test] +fn openraster_save_never_overwrites_what_it_cannot_hold() { + let (mut app, open, written) = app(); + app.run("layer.new.layer", json!({})).unwrap(); + app.session.active_mut().unwrap().path = Some("/pics/art.ora".into()); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(*written.borrow(), ["/pics/art.ora"], "a plain document saves in place"); + app.run("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + let notices = app.ui.notices.len(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(written.borrow().len(), 1, "the .ora is not overwritten"); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "/pics/art.psd"), "Save As offers PSD"); + assert_eq!(app.ui.notices.len(), notices + 1); + assert!(app.ui.notices.last().unwrap().lines.iter().any(|l| l.contains("adjustment layers")), "{:?}", app.ui.notices.last().unwrap().lines); + assert!(app.session.active().unwrap().is_dirty(), "the edits are still unsaved"); + answer(&open, Some(FileDialogAnswer::SaveTo("/pics/art.psd".into()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(written.borrow().last().map(String::as_str), Some("/pics/art.psd")); + let st = app.session.active().unwrap(); + assert_eq!(st.path.as_deref(), Some("/pics/art.psd")); + assert!(!st.is_dirty()); +} + +/// Save As to OpenRaster of a document it can't hold writes a copy, as Photoshop does for formats +/// that can't keep everything: the document keeps its file and its unsaved state. An automation +/// save without a path won't overwrite such an .ora either. +#[test] +fn save_as_openraster_of_what_it_cannot_hold_is_a_copy() { + let (mut app, _open, written) = app(); + app.run("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + app.session.active_mut().unwrap().path = Some("/pics/art.psd".into()); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "/pics/copy.ora"})).unwrap(); + assert_eq!(*written.borrow(), ["/pics/copy.ora"]); + let st = app.session.active().unwrap(); + assert_eq!(st.path.as_deref(), Some("/pics/art.psd"), "the document keeps its file"); + assert!(st.is_dirty()); + app.session.active_mut().unwrap().path = Some("/pics/art.ora".into()); + let err = app.save_automation(None).unwrap_err(); + assert!(err.contains("OpenRaster"), "{err}"); + assert_eq!(written.borrow().len(), 1); +} diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 785d234d02..ac1ed2ba7f 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -33,6 +33,20 @@ pub enum DropTarget { Elsewhere, } +/// `warnings` from an import, plus one for each font family the active document's type uses that +/// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and +/// automation already report them; the type would otherwise silently render in a fallback face. +pub(crate) fn with_missing_fonts(app: &PhotocraftApp, mut warnings: Vec) -> Vec { + if let Some(st) = app.session.active() { + for w in photocraft_engine::type_extra_cmds::missing_font_warnings(&st.doc) { + if !warnings.contains(&w) { + warnings.push(w); + } + } + } + warnings +} + /// A dropped file's name ("dropped" when it has none). fn dropped_name(f: &egui::DroppedFileHandle) -> String { f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()) diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index d49ab58206..0177fc8381 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -776,3 +776,87 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { jobs_ui::finish_open_tab(&mut app, &late, &json!({"document": index})).unwrap(); assert_eq!(doc_names(&app), ["dropped.psd", "existing.psd", "f0.psd", "f1.psd", "f2.psd", "f3.psd", "late.psd"]); } + +/// #4373: Save a Copy is recorded in an action, like Save and Save As, and played back as a copy: +/// the document keeps its own file and unsaved changes. +#[test] +fn save_a_copy_is_recorded_and_replayed_as_a_copy() { + let (mut app, written) = app_with(vec![Some(FileDialogAnswer::SaveTo("/pics/copy.png".into()))]); + let ctx = egui::Context::default(); + app.open_file("/pics/work.pcraft", b"x").unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("actions.record", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + answer(&mut app); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[0].steps.last(), Some(&("file.saveACopy".to_string(), json!({"path": "/pics/copy.png"})))); + assert_eq!(crate::actions::label_of("file.saveACopy"), "Save a Copy"); + written.borrow_mut().clear(); + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(written.borrow().iter().map(|(p, _)| p.as_str()).collect::>(), ["/pics/copy.png"]); + let st = app.session.active().unwrap(); + assert_eq!(st.path.as_deref(), Some("/pics/work.pcraft"), "the document keeps its file"); + assert!(st.is_dirty(), "and its unsaved changes"); +} + +/// #4400: a new document saved under a name is listed in Open Recent (and on the Home screen), +/// as opening it is; a Save a Copy isn't, since the document doesn't live there. +#[test] +fn saving_a_new_document_lists_it_in_recent_files() { + let (mut app, _written) = app_with(vec![Some(FileDialogAnswer::SaveTo("/pics/new.psd".into())), Some(FileDialogAnswer::SaveTo("/pics/copy.png".into()))]); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + assert!(app.ui.recent_files.is_empty()); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.ui.recent_files, ["/pics/new.psd"]); + assert_eq!(app.session.prefs().file_handling.recent_files, ["/pics/new.psd"], "kept for the next launch"); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.ui.recent_files, ["/pics/new.psd"], "a copy isn't the document's file"); +} + +/// #4559: a file whose type uses a font that isn't installed opens with a notice naming it, as +/// Photoshop warns on open (the CLI and automation already did); the file still opens. Type in +/// an installed face adds nothing, and a font the importer already warned about isn't repeated. +#[test] +fn opening_type_in_a_missing_font_warns() { + fn doc_with_type(font: Option<&str>) -> Document { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + let mut p = json!({"text": "Hi", "x": 2, "y": 12, "size": 10}); + if let Some(f) = font { + p["font"] = json!(f); + } + s.execute("type.create", p).unwrap(); + (*s.active().unwrap().doc).clone() + } + const MISSING: &str = "NoSuchFamily4559"; + let warning = format!("font '{MISSING}' is not installed; text may render using a fallback face"); + let (with_missing, installed) = (doc_with_type(Some(MISSING)), doc_with_type(None)); + let already = warning.clone(); + let services = Services { + import: Some(Box::new(move |name: &str, _bytes: &[u8], _depth: usize| { + Ok(match name { + "installed.psd" => (installed.clone(), Vec::new()), + "warned.psd" => (with_missing.clone(), vec![already.clone()]), + _ => (with_missing.clone(), Vec::new()), + }) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let warnings = app.open_bytes("poster.psd", b"x").unwrap(); + assert_eq!(warnings, vec![warning.clone()]); + assert!(app.ui.status.contains(MISSING), "{}", app.ui.status); + assert_eq!(app.ui.notices.len(), 1); + assert_eq!(app.ui.notices[0].lines, vec![warning.clone()]); + assert_eq!(app.session.documents().len(), 1, "the file still opens"); + // Type in an installed face: no notice. + assert!(app.open_bytes("installed.psd", b"x").unwrap().is_empty()); + assert_eq!(app.ui.notices.len(), 1); + // The importer's own warning for the same font isn't doubled. + assert_eq!(app.open_bytes("warned.psd", b"x").unwrap(), vec![warning]); +} diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 310c9fea04..2423d2de30 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -22,6 +22,22 @@ fn default_dir(app: &PhotocraftApp) -> String { app.session.active().and_then(|d| d.path.as_deref()).and_then(|p| p.rfind(['/', '\\']).map(|i| p[..i].to_string())).unwrap_or_else(|| ".".into()) } +/// A packaged macOS app does not start in a useful writable working directory. A saved +/// document keeps its own folder; otherwise prefer the user's Documents folder. +fn droplet_default_dir(app: &PhotocraftApp) -> String { + let dir = default_dir(app); + if dir != "." { + return dir; + } + #[cfg(target_os = "macos")] + if let Some(home) = std::env::var_os("HOME") { + let home = std::path::PathBuf::from(home); + let documents = home.join("Documents"); + return if documents.is_dir() { documents } else { home }.to_string_lossy().into_owned(); + } + dir +} + fn doc_stem(app: &PhotocraftApp) -> String { app.session.active().map(|d| d.doc.name.rsplit_once('.').map_or(d.doc.name.clone(), |(a, _)| a.to_string())).unwrap_or_else(|| tl!("Untitled").into()) } @@ -71,6 +87,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V json!({"units": ["inches", "cm", "pixels"], "mode": ["rgb", "gray", "cmyk", "lab"]}), ), "file.automate.createDroplet" => { + let dir = droplet_default_dir(app); let Some(action) = crate::actions::selected_action(app) else { return Some(Err("record an action in the Actions panel first".into())); }; @@ -180,6 +197,9 @@ fn b(f: &Map, k: &str, d: bool) -> bool { // ---------- Save for Web ---------- +#[path = "file_ui/web_preview.rs"] +mod web_preview; + const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")]; const WEB_COMMAND: &str = "file.export.saveForWebLegacy"; @@ -189,11 +209,19 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { f.insert("__command".into(), json!(WEB_COMMAND)); f.insert("__label".into(), json!("Save for Web (Legacy)")); f.insert("__view".into(), json!("2up")); - // Remember encoding choices, but each document starts at its own full size. - let last = app.session.file_menu.last_web.clone().unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); + // Remember encoding choices, but each document starts at its own full size. This session's + // last export lives in `file_menu`; the last one before a restart comes from the + // preferences' remembered dialog settings (`web_cmds::remember_web_settings`). + let last = app + .session + .file_menu + .last_web + .clone() + .or_else(|| app.session.prefs().dialogs.get(WEB_COMMAND).cloned()) + .unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); if let Value::Object(m) = last { for (k, v) in m { - if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent") { + if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { f.insert(k, v); } } @@ -227,6 +255,10 @@ fn variants(p: &Value) -> Vec { let fmt = p["format"].as_str().unwrap_or("jpeg"); for k in [2.0, 4.0] { let mut v = p.clone(); + if let Some(m) = v.as_object_mut() { + m.remove("colorTable"); + m.remove("colorTableEdits"); + } if fmt == "jpeg" { v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0)); } else if fmt == "gif" || fmt == "png8" { @@ -241,51 +273,66 @@ fn variants(p: &Value) -> Vec { } /// The flattened preview image: pixels, width, height, full-size / proxy pixel ratio. -type WebProxy = (Arc>, u32, u32, f64); +type WebProxy = (Arc>, u32, u32, f64, egui::Vec2); type WebCache = (String, Arc, Option<(usize, Option, &'static str)>); /// Renders one preview pane: the original (`None`) or an optimised variant. -fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, full_px: f64) { +fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, f: &mut Map, pane: usize, frame: Option) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let rev = st.revision; - // The proxy: the export size, at most 640 px on the long side. + // A bounded proxy; navigation never recomposites or re-encodes it. let base = p.cloned().unwrap_or_else(|| json!({})); - let size_sig = format!("{}|{}|{}", base["percent"], base["width"], base["height"]); - let wkey = egui::Id::new(("web-proxy", doc.id.0, rev, size_sig.clone())); - let proxy: Option = ui.data(|d| d.get_temp(wkey)); - let (px, w, h, ratio) = match proxy { + let size_sig = format!("{}|{}|{}|{}", base["percent"], base["width"], base["height"], base["convertToSrgb"]); + let wkey = egui::Id::new(("web-proxy", doc.id.0, pane)); + let proxy_sig = format!("{rev}|{frame:?}|{size_sig}"); + let cached_proxy: Option<(String, WebProxy)> = ui.data(|d| d.get_temp(wkey)); + let proxy = cached_proxy.filter(|(sig, _)| sig == &proxy_sig).map(|(_, proxy)| proxy); + let (px, w, h, ratio, source_size) = match proxy { Some(v) => v, None => { - let pct = base["percent"].as_f64().unwrap_or(100.0).max(1.0) / 100.0; - let (fw, fh) = (f64::from(doc.size.width) * pct, f64::from(doc.size.height) * pct); - let k = (640.0 / fw.max(fh)).min(1.0); - let st = WebSettings::default(); + let (fw, fh) = match web_preview::dimensions(&doc, &base) { + Ok(size) => size, + Err(error) => { + ui.label(error); + return; + } + }; + let k = (2048.0 / fw.max(fh)).min(1.0); + let st = WebSettings::from_params(&base, "preview").unwrap_or_default(); + let Ok(doc) = web_frame_document(&doc, frame) else { return }; let Ok((wd, _, _)) = web_cmds::web_document(&doc, &json!({"width": (fw * k).round().max(1.0), "height": (fh * k).round().max(1.0), "resample": "bilinear"}), &st) else { return; }; let buf = photocraft_compose::flatten(&wd); - let v = (Arc::new(buf.px), wd.size.width, wd.size.height, (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0)); - ui.data_mut(|d| d.insert_temp(wkey, v.clone())); + let v = ( + Arc::new(buf.px), + wd.size.width, + wd.size.height, + (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0), + egui::vec2(fw as f32, fh as f32), + ); + ui.data_mut(|d| d.insert_temp(wkey, (proxy_sig, v.clone()))); v } }; - let sig = format!("{}{}", size_sig, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into())); - let key = egui::Id::new(("web-pane", doc.id.0, rev, sig.clone())); + let sig = format!("{}|{rev}|{frame:?}|{size_sig}|{}", doc.id.0, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into())); + let key = egui::Id::new(("web-pane", doc.id.0, pane)); let cached: Option = ui.data(|d| d.get_temp(key)); let (tex, info) = match cached.filter(|c| c.0 == sig) { Some((_, tex, info)) => (tex, info), None => { let (rgba, info) = match p { None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::>(), None), - Some(p) => match WebSettings::from_params(p, "preview") - .and_then(|st| web_cmds::optimize(&px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true)) - { - Ok(Optimized { preview, bytes, colors, ext, .. }) => (preview, Some(((bytes.len() as f64 * ratio) as usize, colors, ext))), - Err(_) => (vec![0; (w * h * 4) as usize], None), + Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{frame:?}|{size_sig}", doc.id.0), p, &px, w, h) { + Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))), + Err(error) => { + ui.label(error); + (vec![0; (w * h * 4) as usize], None) + } }, }; let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); @@ -295,14 +342,15 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< } }; ui.vertical(|ui| { - let (r, _) = ui.allocate_exact_size(size - egui::vec2(0.0, 34.0), egui::Sense::hover()); + ui.set_width(size.x); + let (r, response) = ui.allocate_exact_size((size - egui::vec2(0.0, 42.0)).max(egui::vec2(1.0, 1.0)), egui::Sense::drag()); + let response = response.on_hover_cursor(egui::CursorIcon::Grab); + if let Some(rects) = f.get_mut("__previewRects").and_then(Value::as_array_mut) { + rects.push(json!([r.left(), r.top(), r.right(), r.bottom()])); + } crate::widgets::checker(ui.painter(), r, 8.0); - let ts = tex.size_vec2(); - let k = (r.width() / ts.x).min(r.height() / ts.y).min(1.0); - let ir = egui::Rect::from_center_size(r.center(), ts * k); - ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); - ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - let _ = full_px; + web_preview::paint(ui, r, &response, &tex, source_size, f); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); let (l1, l2) = match (p, info) { (None, _) => ("Original".to_string(), format!("{} · {} × {} px", doc.name, doc.size.width, doc.size.height)), (Some(_), Some((bytes, colors, ext))) => { @@ -315,11 +363,33 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< } (Some(_), None) => ("—".into(), String::new()), }; - ui.label(egui::RichText::new(l1).color(t.text).size(11.5)); - ui.label(egui::RichText::new(l2).color(t.text_dim).size(11.0)); + let l1 = if ratio > 1.001 { format!("{l1} · Proxy preview") } else { l1 }; + ui.add(egui::Label::new(egui::RichText::new(l1).color(t.text).size(11.5)).truncate()); + ui.add(egui::Label::new(egui::RichText::new(l2).color(t.text_dim).size(11.0)).truncate()); }); } +/// Print's Printer menu: the default printer (value ""), then every printer `file.printers` +/// listed. A saved name the spooler no longer lists stays selectable rather than vanishing. +/// CUPS queue names stand in for spaces with `_`; the labels show the spaces. +fn printer_options(list: Option<&Value>, current: &str) -> Vec<(String, String)> { + let printers: Vec<(&str, bool)> = list + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("default").and_then(Value::as_bool).unwrap_or(false)))).collect()) + .unwrap_or_default(); + let pretty = |n: &str| n.replace('_', " "); + let default = match printers.iter().find(|p| p.1) { + Some((n, _)) => format!("{} ({})", tl!("Default printer"), pretty(n)), + None => tl!("Default printer").to_string(), + }; + let mut opts = vec![(String::new(), default)]; + opts.extend(printers.iter().map(|(n, _)| (n.to_string(), pretty(n)))); + if !current.is_empty() && !printers.iter().any(|p| p.0 == current) { + opts.push((current.to_string(), pretty(current))); + } + opts +} + fn dropdown_str(ui: &mut egui::Ui, id: &str, f: &mut Map, key: &str, opts: &[(&str, &str)], w: f32) { let mut v = s(f, key, opts.first().map_or("", |o| o.0)); let o: Vec<(String, &str)> = opts.iter().map(|(k, l)| (k.to_string(), *l)).collect(); @@ -344,6 +414,70 @@ fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, label: &str, }); } +/// Advance a preview clock by whole elapsed frames, retaining fractional time. +/// The caller resets `last` on Play so paused wall time never advances the preview. +fn advance_preview(frame: usize, duration: usize, fps: f32, last: f64, now: f64) -> (usize, f64) { + let duration = duration.max(1); + let frame = frame.min(duration - 1); + if !now.is_finite() { + return (frame, last); + } + if !last.is_finite() || now < last { + return (frame, now); + } + let fps = if fps.is_finite() && fps > 0.0 { f64::from(fps) } else { 30.0 }; + let elapsed_frames = (now - last) * fps; + if !elapsed_frames.is_finite() { + return (frame, now); + } + let steps = elapsed_frames.floor(); + if steps < 1.0 { + return (frame, last); + } + let delta = (steps % duration as f64) as usize % duration; + let remaining = duration - frame; + let next = if delta >= remaining { delta - remaining } else { frame + delta }; + (next, now - (elapsed_frames - steps) / fps) +} + +fn web_playback(ui: &mut egui::Ui, f: &mut Map, doc: &photocraft_doc::Document) -> Option { + let mut timeline = doc.timeline.clone()?; + timeline.clamp(); + if timeline.duration < 2 { + return None; + } + let now = ui.input(|i| i.time); + let mut frame = f.get("__web_frame").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).unwrap_or(timeline.current).min(timeline.duration - 1); + let mut last = n(f, "__web_time", now); + let mut playing = b(f, "__web_playing", false); + if playing { + (frame, last) = advance_preview(frame, timeline.duration, timeline.fps, last, now); + } + ui.horizontal(|ui| { + if crate::icons::button(ui, if playing { "pause" } else { "play" }, 22.0, playing, if playing { tl!("Pause") } else { tl!("Play") }).clicked() { + playing = !playing; + last = now; + } + ui.label(format!("{} / {}", frame + 1, timeline.duration)); + }); + f.insert("__web_frame".into(), json!(frame)); + f.insert("__web_time".into(), json!(last)); + f.insert("__web_playing".into(), json!(playing)); + if playing { + let remaining = (1.0 / f64::from(timeline.fps) - (now - last)).clamp(0.0, 1.0); + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(remaining)); + } + Some(frame) +} + +/// Seek a preview copy, preserving any edits to the source's currently displayed frame. +fn web_frame_document(doc: &photocraft_doc::Document, frame: Option) -> Result { + let Some(frame) = frame else { return Ok(doc.clone()) }; + let mut preview = photocraft_engine::Session::new(); + preview.add_document(doc.clone(), None); + preview.execute("timeline.setFrame", json!({"frame": frame}))?; + Ok((*preview.active().ok_or(photocraft_engine::EngineError::NoDocument)?.doc).clone()) +} fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; @@ -357,158 +491,186 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map web_pane(app, ui, area, None, full_px), - "optimized" => web_pane(app, ui, area, Some(&p), full_px), + "original" => web_pane(app, ui, area, None, f, 0, frame), + "optimized" => web_pane(app, ui, area, Some(&p), f, 1, frame), "2up" => { ui.horizontal(|ui| { - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, full_px); - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), full_px); + web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, f, 0, frame); + web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), f, 1, frame); }); } _ => { let v = variants(&p); - let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 2.0); + let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 4.0); ui.horizontal(|ui| { - web_pane(app, ui, cell, None, full_px); - web_pane(app, ui, cell, Some(&v[0]), full_px); + web_pane(app, ui, cell, None, f, 0, frame); + web_pane(app, ui, cell, Some(&v[0]), f, 1, frame); }); ui.horizontal(|ui| { - web_pane(app, ui, cell, Some(&v[1]), full_px); - web_pane(app, ui, cell, Some(&v[2]), full_px); + web_pane(app, ui, cell, Some(&v[1]), f, 2, frame); + web_pane(app, ui, cell, Some(&v[2]), f, 3, frame); }); } } }); ui.add_space(10.0); - // Settings. + // Settings scroll independently on smaller windows. ui.vertical(|ui| { ui.set_width(250.0); - ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); - let mut preset = s(f, "__preset", ""); - let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")]; - opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p))); - if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() { - if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) { - f.extend(m); - } - f.insert("__preset".into(), json!(preset)); - } - ui.add_space(4.0); - dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0); - ui.add_space(4.0); - let fmt = s(f, "format", "jpeg"); - match fmt.as_str() { - "gif" | "png8" => { - dropdown_str( - ui, - "web-palette", - f, - "palette", - &[ - ("perceptual", tl!("Perceptual")), - ("selective", tl!("Selective")), - ("adaptive", tl!("Adaptive")), - ("restrictive", tl!("Restrictive (Web)")), - ("exact", tl!("Exact")), - ], - 150.0, - ); - number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0); - dropdown_str( - ui, - "web-dither", - f, - "dither", - &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))], - 150.0, - ); - number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0); - check(ui, f, "transparency", tl!("Transparency"), true); - check(ui, f, "interlaced", tl!("Interlaced"), false); - number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0); - } - "jpeg" => { - number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); - check(ui, f, "progressive", tl!("Progressive"), false); - check(ui, f, "optimized", tl!("Optimized"), true); - check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); - } - "wbmp" => { - dropdown_str( - ui, - "web-dither", - f, - "dither", - &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))], - 150.0, - ); - } - _ => { - check(ui, f, "transparency", tl!("Transparency"), true); - check(ui, f, "interlaced", tl!("Interlaced"), false); - } - } - if fmt != "png24" || !b(f, "transparency", true) { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); - let mut m = s(f, "matte", "#ffffff"); - if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() { - f.insert("matte".into(), json!(m)); + egui::ScrollArea::vertical().id_salt("web-settings").max_height(area.y).show(ui, |ui| { + ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); + let mut preset = s(f, "__preset", ""); + let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")]; + opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p))); + if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() { + if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) { + f.extend(m); } - }); - } - ui.add_space(6.0); - check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim)); - dropdown_str( - ui, - "web-meta", - f, - "metadata", - &[("none", tl!("None")), ("copyright", tl!("Copyright")), ("copyrightAndContact", tl!("Copyright and Contact Info")), ("all", tl!("All"))], - 160.0, - ); - }); - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); - number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0); - let pct = n(f, "percent", 100.0) / 100.0; - ui.label( - egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round())) - .color(t.text_dim) - .size(11.5), - ); - let nslices = photocraft_doc::slices::resolve(&doc).len(); - if !doc.slices.is_empty() { + f.insert("__preset".into(), json!(preset)); + } + ui.add_space(4.0); + dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0); + ui.add_space(4.0); + let fmt = s(f, "format", "jpeg"); + match fmt.as_str() { + "gif" | "png8" => { + dropdown_str( + ui, + "web-palette", + f, + "palette", + &[ + ("perceptual", tl!("Perceptual")), + ("selective", tl!("Selective")), + ("adaptive", tl!("Adaptive")), + ("restrictive", tl!("Restrictive (Web)")), + ("exact", tl!("Exact")), + ], + 150.0, + ); + number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0); + dropdown_str( + ui, + "web-dither", + f, + "dither", + &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))], + 150.0, + ); + number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0); + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "interlaced", tl!("Interlaced"), false); + number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0); + } + "jpeg" => { + number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); + check(ui, f, "progressive", tl!("Progressive"), false); + check(ui, f, "optimized", tl!("Optimized"), true); + check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); + } + "wbmp" => { + dropdown_str( + ui, + "web-dither", + f, + "dither", + &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))], + 150.0, + ); + } + _ => { + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "interlaced", tl!("Interlaced"), false); + } + } + if fmt != "png24" || !b(f, "transparency", true) { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); + let mut m = s(f, "matte", "#ffffff"); + if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() { + f.insert("matte".into(), json!(m)); + } + }); + } ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim)); - check(ui, f, "html", tl!("Save HTML and Images"), true); - dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0); - } + check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim)); + dropdown_str( + ui, + "web-meta", + f, + "metadata", + &[ + ("none", tl!("None")), + ("copyright", tl!("Copyright")), + ("copyrightAndContact", tl!("Copyright and Contact Info")), + ("all", tl!("All")), + ], + 160.0, + ); + }); + if matches!(fmt.as_str(), "gif" | "png8") { + web_preview::table(ui, f); + } + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); + number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0); + let pct = n(f, "percent", 100.0) / 100.0; + ui.label( + egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round())) + .color(t.text_dim) + .size(11.5), + ); + let nslices = photocraft_doc::slices::resolve(&doc).len(); + if !doc.slices.is_empty() { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim)); + check(ui, f, "html", tl!("Save HTML and Images"), true); + dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0); + } + }); }); }); } fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - let mut p = params(f); // Check while the control/input policy is still installed, before an asynchronous picker // can outlive the request. A browser download uses the interactive platform writer. - if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) { - authorize(WEB_COMMAND, &p).map_err(|e| e.to_string())?; - } - if app.automation_input - && let Some(authorize) = app.services.automation_command.as_ref() - { - authorize(WEB_COMMAND, &p)?; + let authorize = |p: &Value| -> Result<(), String> { + if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) { + authorize(WEB_COMMAND, p).map_err(|e| e.to_string())?; + } + if app.automation_input + && let Some(authorize) = app.services.automation_command.as_ref() + { + authorize(WEB_COMMAND, p)?; + } + Ok(()) + }; + let mut p = params(f); + authorize(&p)?; + // Confirmation may follow a field edit without an intervening frame (Enter or automation). + let mut fields = f.clone(); + web_preview::prepare_table(app, &mut fields)?; + let prepared = params(&fields); + if prepared != p { + // The policy must also approve the actual palette-bearing export parameters. + authorize(&prepared)?; + p = prepared; } if p.get("path").is_some() || p.get("dir").is_some() { return save_for_web(app, p); @@ -565,6 +727,9 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { let mut f = Map::new(); f.insert("__print".into(), json!(true)); f.insert("__label".into(), json!("PhotoCraft Print Settings")); + // Asked once per opening: the Printer menu lists what the spooler knows now. + let printers = app.session.execute("file.printers", json!({})).unwrap_or_default(); + f.insert("__printers".into(), printers["printers"].clone()); if let Some(Value::Object(m)) = app.session.file_menu.last_print.clone() { f.extend(m); } @@ -607,6 +772,10 @@ fn unit_pt(units: &str) -> f64 { /// A number field in `units` for a value kept in points; true when edited. fn length_field(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str) -> bool { + length_field_prec(ui, label, pt, units, 0) +} + +fn length_field_prec(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str, decimals: u32) -> bool { let t = Tokens::get(ui.ctx()); let k = unit_pt(units); let mut v = (*pt / k) as f32; @@ -614,7 +783,7 @@ fn length_field(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str) -> bo ui.horizontal(|ui| { ui.label(egui::RichText::new(label).color(t.text_dim)); let before = v; - crate::widgets::value_field(ui, &mut v, 0.0..=10000.0, units, 70.0); + crate::widgets::value_field_prec(ui, &mut v, 0.0..=10000.0, units, 70.0, decimals); if (v - before).abs() > 1e-4 && v.is_finite() { *pt = f64::from(v) * k; changed = true; @@ -623,6 +792,53 @@ fn length_field(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str) -> bo changed } +/// Unrotated paper dimensions in points, for either the current preset or a custom array. +fn print_paper_size(value: Option<&Value>) -> (f64, f64) { + let default = (612.0, 792.0); + match value { + Some(Value::Array(a)) => (a.first().and_then(Value::as_f64).unwrap_or(default.0), a.get(1).and_then(Value::as_f64).unwrap_or(default.1)), + Some(Value::String(name)) => photocraft_engine::print_cmds::PAPERS.iter().find(|p| p.0.eq_ignore_ascii_case(name)).map_or(default, |p| (p.1, p.2)), + _ => default, + } +} + +fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { + let previous = f.get("paper").cloned(); + let mut selected = if previous.as_ref().is_some_and(Value::is_array) { "custom".to_string() } else { s(f, "paper", "letter") }; + ui.horizontal(|ui| { + let mut papers: Vec<_> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0.to_string(), p.0)).collect(); + papers.push(("custom".to_string(), tl!("Custom"))); + if crate::widgets::dropdown(ui, "print-paper", &mut selected, &papers, 110.0) { + if selected == "custom" { + let (w, h) = print_paper_size(f.get("__customPaper").or(previous.as_ref())); + f.insert("paper".into(), json!([w, h])); + } else { + if let Some(paper @ Value::Array(_)) = previous { + f.insert("__customPaper".into(), paper); + } + f.insert("paper".into(), json!(selected)); + } + } + dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0); + }); + if f.get("paper").is_some_and(Value::is_array) { + let (mut w, mut h) = print_paper_size(f.get("paper")); + let units = s(f, "units", "cm"); + let changed = ui + .horizontal(|ui| { + let wc = length_field_prec(ui, tl!("Paper Width"), &mut w, &units, 2); + let hc = length_field_prec(ui, tl!("Paper Height"), &mut h, &units, 2); + wc || hc + }) + .inner; + if changed { + // Keep points in the command, and let the engine apply orientation and validation. + f.insert("paper".into(), json!([w, h])); + f.insert("__customPaper".into(), json!([w, h])); + } + } +} + fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; @@ -705,16 +921,14 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = opts.iter().map(|(k, l)| (k.clone(), l.as_str())).collect(); + if crate::widgets::dropdown(ui, "print-printer", &mut pr, &o, 240.0) { f.insert("printer".into(), json!(pr)); } }); number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0); - ui.horizontal(|ui| { - let papers: Vec<(&str, &str)> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0, p.0)).collect(); - dropdown_str(ui, "print-paper", f, "paper", &papers, 110.0); - dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0); - }); + print_paper_controls(ui, f); head(ui, tl!("Color Management")); dropdown_str( ui, @@ -867,6 +1081,20 @@ mod tests { (app, egui::Context::default()) } + #[test] + fn droplet_folder_follows_the_document_or_uses_a_safe_fallback() { + let (mut app, _) = app(); + let fallback = droplet_default_dir(&app); + #[cfg(target_os = "macos")] + if std::env::var_os("HOME").is_some() { + assert!(std::path::Path::new(&fallback).is_absolute()); + } + #[cfg(not(target_os = "macos"))] + assert_eq!(fallback, "."); + app.session.active_mut().unwrap().path = Some("/tmp/photocraft/drawing.pcraft".into()); + assert_eq!(droplet_default_dir(&app), "/tmp/photocraft"); + } + #[test] fn save_for_web_dialog_runs_the_command() { let (mut app, ctx) = app(); @@ -887,6 +1115,20 @@ mod tests { assert!(!d.fields.contains_key("path")); } + /// A fresh session's dialog seeds from the preferences: the last export before a restart is + /// remembered there (`web_cmds::remember_web_settings`), as `file_menu` is not saved. + #[test] + fn the_save_for_web_dialog_seeds_from_the_remembered_preferences() { + let (mut app, _) = app(); + app.session.prefs.edit(|p| { + p.dialogs.insert(WEB_COMMAND.into(), json!({"format": "png8", "colors": 64})); + }); + let id = open_web(&mut app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "png8"); + assert_eq!(f["colors"], 64); + } + #[test] fn save_for_web_starts_at_the_current_document_size() { let (mut app, _) = app(); @@ -964,6 +1206,118 @@ mod tests { } } + #[test] + fn save_for_web_authorizes_generated_palette_before_opening_a_picker() { + fn deny_palette(_: &str, p: &Value) -> photocraft_engine::Result<()> { + if p.get("colorTable").is_some() { Err(photocraft_engine::EngineError::Other("palette denied".into())) } else { Ok(()) } + } + for synthetic_input in [false, true] { + let (mut app, _) = app(); + let id = open_web(&mut app); + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + fields.insert("format".into(), json!("gif")); + assert!(!fields.contains_key("colorTable")); + if synthetic_input { + app.automation_input = true; + app.services.automation_command = Some(Box::new(|cmd, p| deny_palette(cmd, p).map_err(|e| e.to_string()))); + } else { + app.session.authorize = Some(deny_palette); + } + app.services.file_dialog = Some(Box::new(|_, _, _| panic!("denied palette must not show a picker"))); + app.services.write = Some(Box::new(|_, _| panic!("denied palette must not write"))); + assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "palette denied"); + assert!(!app.file_dialog_open()); + } + } + + #[test] + fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() { + let (mut app, _) = app(); + let id = open_web(&mut app); + let revision = app.session.active().unwrap().revision; + app.ui.dialog_mut(id).unwrap().fields.insert("__view".into(), json!("optimized")); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + let pane = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let r = &h.state().ui.dialogs[0].fields["__previewRects"][0]; + egui::Rect::from_min_max( + egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32), + egui::pos2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32), + ) + }; + let before = pane(&h); + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::new("pc-dialog-rects"))).unwrap(); + let corner = rects[0].max - egui::vec2(14.0, 14.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + let end = corner + egui::vec2(180.0, 100.0); + h.event(egui::Event::PointerMoved(end)); + h.step(); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + let after = pane(&h); + assert!(after.width() > before.width() + 100.0, "before {before:?}, after {after:?}, corner {corner:?}"); + assert!(after.height() > before.height() + 50.0); + // Zoom in enough that the small fixture can pan, then drag the real pane. + h.state_mut().ui.dialogs[0].fields.insert("__zoom".into(), json!(16.0)); + h.run_steps(3); + let at = pane(&h).center(); + h.event(egui::Event::PointerMoved(at)); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + let end = at + egui::vec2(35.0, 20.0); + h.event(egui::Event::PointerMoved(end)); + h.step(); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert_ne!(h.state().ui.dialogs[0].fields["__center"], json!([0.5, 0.5])); + assert_eq!(h.state().session.active().unwrap().revision, revision); + // Enter while editing a swatch must not export and close the parent dialog. + h.state_mut().ui.dialogs[0].fields.insert("format".into(), json!("gif")); + h.run_steps(4); + use egui_kittest::kittest::Queryable; + h.get_by_role(egui::accesskit::Role::ColorWell).click(); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx)); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1); + } + + #[test] + fn palette_draft_survives_navigation_and_resets_when_colors_change() { + let (app, _) = app(); + let mut f = serde_json::from_value::>(json!({"format": "gif", "colors": 16})).unwrap(); + web_preview::prepare_table(&app, &mut f).unwrap(); + let table = f["colorTable"].clone(); + f.insert("colorTableEdits".into(), json!({"0": "#12ab34"})); + f.insert("__zoom".into(), json!(2.0)); + f.insert("__center".into(), json!([0.25, 0.75])); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert_eq!(f["colorTable"], table); + assert_eq!(f["colorTableEdits"]["0"], "#12ab34"); + f.insert("metadata".into(), json!("none")); + f.insert("interlaced".into(), json!(true)); + f.insert("dither".into(), json!("pattern")); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert_eq!(f["colorTableEdits"]["0"], "#12ab34", "non-palette settings must retain edits"); + f.insert("colors".into(), json!(8)); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert!(!f.contains_key("colorTableEdits")); + assert!(f["colorTable"].as_array().unwrap().len() <= 8); + } + #[test] fn print_dialog_and_toggles() { let (mut app, ctx) = app(); @@ -986,6 +1340,26 @@ mod tests { } } + #[test] + fn printer_menu_lists_the_spooler_printers_after_the_default() { + let list = json!([{"name": "HP_OfficeJet_3830_series", "default": true}, {"name": "DNP_DP_DS620", "default": false}]); + let o = printer_options(Some(&list), ""); + let keys: Vec<&str> = o.iter().map(|p| p.0.as_str()).collect(); + assert_eq!(keys, ["", "HP_OfficeJet_3830_series", "DNP_DP_DS620"]); + assert!(o[0].1.ends_with("(HP OfficeJet 3830 series)"), "{}", o[0].1); + assert_eq!(o[2].1, "DNP DP DS620"); + // A remembered printer that's gone stays selectable; no list still offers the default. + let o = printer_options(Some(&list), "Old_Queue"); + assert_eq!(o.last().unwrap().0, "Old_Queue"); + assert_eq!(printer_options(None, "").len(), 1); + // The dialog asks the engine, and the list never reaches file.print's params. + let (mut app, ctx) = app(); + let id = crate::menus::invoke(&mut app, &ctx, "file.print", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert!(f.get("__printers").is_some_and(Value::is_array)); + assert!(params(f).get("__printers").is_none()); + } + #[test] fn print_dialog_does_not_inherit_scripted_send_override() { for send in [false, true] { @@ -1000,3 +1374,232 @@ mod tests { } } } + +#[cfg(test)] +mod web_playback_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + fn animated_app(depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24, "depth": depth})).unwrap(); + app.run("timeline.create", json!({"duration": 3, "fps": 2})).unwrap(); + app.run("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + for (frame, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() { + app.run("timeline.setFrame", json!({"frame":frame})).unwrap(); + app.run("edit.fill", json!({"color":color})).unwrap(); + } + app.run("timeline.setFrame", json!({"frame":0})).unwrap(); + app + } + + fn harness(app: PhotocraftApp, fields: Map) -> Harness<'static, (PhotocraftApp, Map)> { + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_step_dt(0.1).build_ui_state( + |ui, state: &mut (PhotocraftApp, Map)| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + web_body(&mut state.0, ui, &mut state.1); + }, + (app, fields), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h + } + + #[test] + fn animated_save_for_web_offers_playback() { + let mut app = animated_app(8); + let id = open_web(&mut app); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("__view".into(), json!("original")); + fields.insert("format".into(), json!("gif")); + let mut h = harness(app, fields); + assert!(h.query_by_label("Play").is_some(), "an animated document needs a preview playback control"); + let original = h.state().0.session.active().unwrap().doc.clone(); + let revision = h.state().0.session.active().unwrap().revision; + let history = h.state().0.session.active().unwrap().history.past_len(); + h.get_by_label("Play").click(); + h.run_steps(2); + assert_eq!(h.state().1["__web_playing"], true); + h.run_steps(6); + assert_ne!(h.state().1["__web_frame"], 0); + h.get_by_label("Pause").click(); + h.run_steps(2); + assert_eq!(h.state().1["__web_playing"], false); + let paused = h.state().1["__web_frame"].clone(); + h.run_steps(20); + assert_eq!(h.state().1["__web_frame"], paused); + h.get_by_label("Play").click(); + h.run_steps(2); + assert_eq!(h.state().1["__web_playing"], true); + assert_eq!(h.state().1["__web_frame"], paused, "resuming must not count time spent paused"); + h.run_steps(6); + assert_ne!(h.state().1["__web_frame"], paused, "resuming must advance the preview again"); + let st = h.state().0.session.active().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &original)); + assert_eq!(st.revision, revision); + assert_eq!(st.history.past_len(), history); + assert!(params(&h.state().1).as_object().unwrap().keys().all(|k| !k.starts_with("__"))); + crate::dialogs::cancel(&mut h.state_mut().0, id).unwrap(); + let next = open_web(&mut h.state_mut().0); + let fields = &h.state_mut().0.ui.dialog_mut(next).unwrap().fields; + assert!(!fields.contains_key("__web_playing")); + assert!(!fields.contains_key("__web_frame")); + } + + #[test] + fn frame_previews_preserve_source_and_match_colors_across_depths() { + for depth in [8, 16, 32] { + let app = animated_app(depth); + let source = app.session.active().unwrap().doc.clone(); + for (frame, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].iter().enumerate() { + let preview = web_frame_document(&source, Some(frame)).unwrap(); + let (proxy, _, _) = web_cmds::web_document(&preview, &json!({"width":16,"height":12}), &WebSettings::default()).unwrap(); + let pixels = photocraft_compose::flatten(&proxy); + for (actual, expected) in pixels.px[0].iter().zip(expected) { + assert!((actual - expected).abs() < 0.002, "depth={depth} frame={frame}"); + } + } + assert_eq!(source.timeline.as_ref().unwrap().current, 0); + assert_eq!(photocraft_compose::flatten(&source).px[0], [1.0, 0.0, 0.0, 1.0]); + } + } + + #[test] + fn four_up_caches_follow_the_local_frame() { + let mut app = animated_app(8); + let doc_id = app.session.active().unwrap().doc.id.0; + let id = open_web(&mut app); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("__view".into(), json!("4up")); + fields.insert("format".into(), json!("gif")); + let mut h = harness(app, fields); + for frame in [1, 2, 0] { + h.state_mut().1.insert("__web_frame".into(), json!(frame)); + h.run_steps(2); + for pane in 0usize..4 { + let proxy: (String, WebProxy) = h.ctx.data(|d| d.get_temp(egui::Id::new(("web-proxy", doc_id, pane)))).unwrap(); + assert!(proxy.1.0[0][frame] > 0.99, "pane {pane} must show frame {frame}"); + let cache: WebCache = h.ctx.data(|d| d.get_temp(egui::Id::new(("web-pane", doc_id, pane)))).unwrap(); + assert!(cache.0.contains(&format!("Some({frame})"))); + } + } + } + #[test] + fn playback_refreshes_indexed_frames_without_resetting_palette_or_navigation() { + let mut app = animated_app(8); + let doc_id = app.session.active().unwrap().doc.id.0; + let id = open_web(&mut app); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("__view".into(), json!("optimized")); + fields.insert("format".into(), json!("gif")); + fields.insert("__zoom".into(), json!(2.0)); + let mut h = harness(app, fields); + // Set the custom palette after the dialog has initialized its format defaults. + let expected_palette = json!(["#ff0000", "#00ff00", "#0000ff"]); + h.state_mut().1.insert("colorTable".into(), expected_palette.clone()); + h.run_steps(2); + assert_eq!(h.state().1["colorTable"], expected_palette); + let key = egui::Id::new(("web-pane", doc_id, 1usize)).with("indexed-source"); + let encoded = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(String, Arc)>(key)).unwrap().1; + let first = encoded(&h.ctx); + h.state_mut().1.insert("colorTableEdits".into(), json!({"0":"#ffff00"})); + h.run_steps(2); + assert!(Arc::ptr_eq(&first, &encoded(&h.ctx)), "palette edits reuse the current frame's indices"); + let palette = h.state().1["colorTable"].clone(); + let center = h.state().1["__center"].clone(); + let mut previous = first; + for (frame, rgba) in [(1, [0, 255, 0, 255]), (2, [0, 0, 255, 255]), (0, [255, 0, 0, 255])] { + h.state_mut().1.insert("__web_frame".into(), json!(frame)); + h.run_steps(2); + let next = encoded(&h.ctx); + assert!(!Arc::ptr_eq(&previous, &next), "a changed frame needs fresh indexed pixels"); + assert_eq!(&next.preview[..4], &rgba); + assert_eq!(h.state().1["colorTable"], palette); + assert_eq!(h.state().1["colorTableEdits"], json!({"0":"#ffff00"})); + assert_eq!(h.state().1["__zoom"], 2.0); + assert_eq!(h.state().1["__center"], center); + assert_eq!(h.state().0.session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0); + previous = next; + } + } + + #[test] + fn still_documents_have_no_playback_controls() { + for duration in [None, Some(1)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":32,"height":24})).unwrap(); + if let Some(duration) = duration { + app.run("timeline.create", json!({"duration":duration,"fps":2})).unwrap(); + } + let id = open_web(&mut app); + let fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + let h = harness(app, fields); + assert!(h.query_by_label("Play").is_none()); + assert!(!h.state().1.contains_key("__web_playing")); + } + } + + #[test] + fn preview_keeps_current_frame_edits_without_storing_into_source() { + let mut app = animated_app(8); + app.run("edit.fill", json!({"color":"#ffff00"})).unwrap(); + let source = app.session.active().unwrap().doc.clone(); + let edited = web_frame_document(&source, Some(0)).unwrap(); + let other = web_frame_document(&source, Some(1)).unwrap(); + assert_eq!(photocraft_compose::flatten(&edited).px[0], [1.0, 1.0, 0.0, 1.0]); + assert_eq!(photocraft_compose::flatten(&other).px[0], [0.0, 1.0, 0.0, 1.0]); + assert!(Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(source.timeline.as_ref().unwrap().current, 0); + } + + #[test] + fn optimized_gif_preview_matches_each_frame_at_every_depth() { + let settings = WebSettings::from_params(&json!({"format":"gif","colors":16}), "preview").unwrap(); + for depth in [8, 16, 32] { + let app = animated_app(depth); + for (frame, expected) in [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]].iter().enumerate() { + let doc = web_frame_document(&app.session.active().unwrap().doc, Some(frame)).unwrap(); + let (proxy, _, _) = web_cmds::web_document(&doc, &json!({"width":16,"height":12}), &settings).unwrap(); + let pixels = photocraft_compose::flatten(&proxy); + let optimized = web_cmds::optimize(&pixels.px, 16, photocraft_geom::Rect::new(0, 0, 16, 12), &settings, None, None, 72.0, true).unwrap(); + assert_eq!(&optimized.preview[..4], expected, "depth={depth} frame={frame}"); + } + } + } +} + +#[cfg(test)] +mod web_clock_tests { + use super::*; + #[test] + fn frames_follow_elapsed_time_and_keep_fraction() { + assert_eq!(advance_preview(0, 3, 2.0, 0.0, 0.49), (0, 0.0)); + assert_eq!(advance_preview(0, 3, 2.0, 0.0, 0.75), (1, 0.5)); + assert_eq!(advance_preview(1, 3, 2.0, 0.5, 1.0), (2, 1.0)); + } + #[test] + fn slow_frames_catch_up_and_loop() { + assert_eq!(advance_preview(2, 3, 2.0, 0.0, 1.0), (1, 1.0)); + let (frame, last) = advance_preview(0, 3, 2.0, 0.0, 6.2); + assert_eq!(frame, 0); + assert!((last - 6.0).abs() < 1e-10); + } + #[test] + fn resumed_clock_excludes_pause_time() { + assert_eq!(advance_preview(1, 3, 2.0, 20.0, 20.25), (1, 20.0)); + assert_eq!(advance_preview(1, 3, 2.0, 20.0, 20.5), (2, 20.5)); + } + #[test] + fn fractional_frame_rates_work() { + assert_eq!(advance_preview(0, 3, 0.5, 0.0, 2.0), (1, 2.0)); + } + #[test] + fn malformed_or_changed_timeline_is_bounded() { + assert_eq!(advance_preview(usize::MAX, 0, f32::NAN, 0.0, 1.0), (0, 1.0)); + assert_eq!(advance_preview(usize::MAX - 2, usize::MAX, 2.0, 0.0, 1.0), (0, 1.0)); + assert_eq!(advance_preview(2, 3, 2.0, 10.0, 9.0), (2, 9.0)); + } +} diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs new file mode 100644 index 0000000000..a9ee7a933c --- /dev/null +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -0,0 +1,323 @@ +//! Save for Web navigation and indexed palette UI. Draft state is serialized in dialog fields. +use super::*; + +/// Export dimensions, using the same percent/width/height precedence as web_document. +pub fn dimensions(doc: &photocraft_doc::Document, p: &Value) -> Result<(f64, f64), String> { + let (w0, h0) = (f64::from(doc.size.width).max(1.0), f64::from(doc.size.height).max(1.0)); + let positive = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let (w, h) = match (positive("percent"), positive("width"), positive("height")) { + (Some(pct), _, _) => (w0 * pct / 100.0, h0 * pct / 100.0), + (_, Some(w), Some(h)) => (w, h), + (_, Some(w), None) => (w, h0 * w / w0), + (_, None, Some(h)) => (w0 * h / h0, h), + _ => (w0, h0), + }; + if !w.is_finite() || !h.is_finite() || w > 300_000.0 || h > 300_000.0 { + return Err("export dimensions exceed the 300,000 px per-side limit".into()); + } + Ok((w.round().max(1.0), h.round().max(1.0))) +} + +pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result<(), String> { + if !matches!(s(f, "format", "jpeg").as_str(), "gif" | "png8") { + return Ok(()); + } + let st = app.session.active().ok_or("no document")?; + let mut source = params(f); + let Some(m) = source.as_object_mut() else { return Err("invalid web settings".into()) }; + // Only palette-affecting options invalidate index-based edits. Metadata, interlacing, + // dithering and output paths must not discard the user's replacement colours. + m.retain(|k, _| { + matches!( + k.as_str(), + "preset" | "format" | "palette" | "colors" | "transparency" | "matte" | "webSnap" | "convertToSrgb" | "width" | "height" | "percent" | "resample" + ) + }); + let signature = format!("{}|{}|{}", st.doc.id.0, st.revision, source); + if f.get("__tableSignature").and_then(Value::as_str) == Some(&signature) { + return Ok(()); + } + // A regenerated palette invalidates index-based edits. Explicit tables supplied through + // ui.dialog.set remain usable until a palette/size setting changes. + if f.contains_key("__tableSignature") { + f.remove("colorTable"); + f.remove("colorTableEdits"); + f.remove("__selectedColor"); + } + if !f.contains_key("colorTable") { + let settings = WebSettings::from_params(&source, "preview").map_err(|e| e.to_string())?; + // Bound palette preparation as well as the displayed image. The fixed table is then + // passed unchanged to full-resolution export, so the swatches are the file's colours. + let (w, h) = dimensions(&st.doc, &source)?; + let scale = (2048.0 / w.max(h)).min(1.0); + let mut proxy = source.clone(); + if let Some(m) = proxy.as_object_mut() { + m.remove("percent"); + m.insert("width".into(), json!((w * scale).round().max(1.0))); + m.insert("height".into(), json!((h * scale).round().max(1.0))); + m.insert("resample".into(), json!("bilinear")); + } + let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?; + f.insert("__paletteApproximate".into(), json!(scale < 1.0)); + let buf = photocraft_compose::flatten(&doc); + let mut optimized = + web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, doc.resolution_dpi, false).map_err(|e| e.to_string())?; + // Tiny transparent features may disappear in the proxy; keep room for their index. + if settings.transparency { + optimized.color_table.truncate(255); + } + f.insert("colorTable".into(), json!(optimized.color_table.iter().map(|c| web_cmds::rgb_hex(*c)).collect::>())); + f.insert("__transparentIndex".into(), json!(optimized.transparent_index)); + } + f.insert("__tableSignature".into(), json!(signature)); + Ok(()) +} + +pub fn table(ui: &mut egui::Ui, f: &mut Map) { + let p = params(f); + let Ok(settings) = WebSettings::from_params(&p, "preview") else { return }; + let Some(table) = settings.color_table else { return }; + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Color Table…").trim_end_matches('…')).font(crate::theme::semibold(12.0))); + ui.scope(|ui| { + ui.spacing_mut().interact_size = egui::vec2(14.0, 14.0); + ui.spacing_mut().item_spacing = egui::vec2(1.0, 1.0); + egui::Grid::new("web-color-table").spacing(egui::vec2(1.0, 1.0)).show(ui, |ui| { + for (i, source) in table.iter().enumerate() { + let mut color = settings.color_table_edits.get(&i).copied().unwrap_or(*source); + let response = ui.push_id(i, |ui| crate::widgets::color_edit_button_srgb(ui, &mut color)).inner; + response.clone().on_hover_text(format!("Index {i}: {}", web_cmds::rgb_hex(color))); + if response.clicked() { + f.insert("__selectedColor".into(), json!(i)); + } + if f.get("__selectedColor").and_then(Value::as_u64) == Some(i as u64) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(response.rect, t.radius_sm, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + if response.changed() { + let edits = f.entry("colorTableEdits").or_insert_with(|| json!({})); + if let Some(edits) = edits.as_object_mut() { + edits.insert(i.to_string(), json!(web_cmds::rgb_hex(color))); + } + } + if i % 16 == 15 { + ui.end_row(); + } + } + if f.get("__transparentIndex").and_then(Value::as_u64).is_some() { + let (rect, response) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + crate::widgets::checker(ui.painter(), rect, 4.0); + response.on_hover_text(tl!("Transparency")); + } + }); + }); + ui.horizontal(|ui| { + ui.weak(format!("{}: {}", tl!("Colors"), table.len())); + if ui.small_button(tl!("Reset")).clicked() { + f.remove("colorTableEdits"); + } + }); +} + +type EncodedPreview = (Vec, usize, Option, &'static str); + +/// Palette replacements reuse the cached index buffer; picker drags do not re-quantize or +/// re-encode the proxy. The GIF payload length is unchanged by RGB table replacements. +pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, px: &[[f32; 4]], w: u32, h: u32) -> Result { + let settings = WebSettings::from_params(p, "preview").map_err(|e| e.to_string())?; + let mut base = p.clone(); + if let Some(m) = base.as_object_mut() { + m.remove("colorTableEdits"); + } + let signature = format!("{signature}|{base}"); + let key = key.with("indexed-source"); + let cached: Option<(String, Arc)> = ui.data(|d| d.get_temp(key)); + let encoded = if let Some((_, encoded)) = cached.filter(|(sig, _)| sig == &signature) { + encoded + } else { + let settings = WebSettings::from_params(&base, "preview").map_err(|e| e.to_string())?; + let encoded = Arc::new( + web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, 72.0, true) + .map_err(|e| e.to_string())?, + ); + ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone()))); + encoded + }; + let rgba = if settings.color_table_edits.is_empty() || !settings.format.indexed() { + encoded.preview.clone() + } else { + encoded + .indices + .iter() + .flat_map(|index| { + let index = usize::from(*index); + let color = settings.color_table_edits.get(&index).copied().or_else(|| encoded.color_table.get(index).copied()).unwrap_or([0; 3]); + [color[0], color[1], color[2], if Some(index) == encoded.transparent_index { 0 } else { 255 }] + }) + .collect() + }; + Ok((rgba, encoded.bytes.len(), encoded.colors, encoded.ext)) +} + +#[derive(Clone, Copy, Debug)] +struct Navigation { + zoom: Option, + center: egui::Vec2, +} +impl Navigation { + fn read(f: &Map) -> Self { + let zoom = f.get("__zoom").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0).map(|z| (z as f32).clamp(0.0001, 32.0)); + let center = f + .get("__center") + .and_then(Value::as_array) + .and_then(|a| Some(egui::vec2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))) + .filter(|c| c.is_finite()) + .unwrap_or(egui::vec2(0.5, 0.5)); + Self { zoom, center } + } + fn write(self, f: &mut Map) { + f.insert("__zoom".into(), json!(self.zoom)); + f.insert("__center".into(), json!([self.center.x, self.center.y])); + } + fn scale(self, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) -> f32 { + self.zoom.map_or((viewport.x / source.x).min(viewport.y / source.y), |z| z / ppp) + } + fn clamp(&mut self, viewport: egui::Vec2, image: egui::Vec2) { + let half = (viewport / image * 0.5).min(egui::vec2(0.5, 0.5)); + self.center = self.center.clamp(half, egui::Vec2::splat(1.0) - half); + } + fn zoom_at(&mut self, factor: f32, at: egui::Vec2, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) { + if !factor.is_finite() || factor <= 0.0 { + return; + } + let old = self.scale(viewport, source, ppp); + self.zoom = Some((old * ppp * factor).clamp(0.0001, 32.0)); + let new = self.scale(viewport, source, ppp); + self.center += at / source * (1.0 / old - 1.0 / new); + self.clamp(viewport, source * new); + } +} + +pub fn controls(ui: &mut egui::Ui, f: &mut Map) { + let mut nav = Navigation::read(f); + let effective = n(f, "__effectiveZoom", 1.0) as f32; + ui.horizontal(|ui| { + if ui.small_button(tl!("Fit")).clicked() { + nav.zoom = None; + nav.center = egui::vec2(0.5, 0.5); + } + if ui.small_button("−").clicked() { + nav.zoom = Some((nav.zoom.unwrap_or(effective) / 1.25).max(0.0001)); + } + if ui.small_button("+").clicked() { + nav.zoom = Some((nav.zoom.unwrap_or(effective) * 1.25).min(32.0)); + } + let mut percent = nav.zoom.unwrap_or(effective) * 100.0; + if ui.add(egui::DragValue::new(&mut percent).range(0.01..=3200.0).suffix("%").speed(10.0)).changed() { + nav.zoom = Some(percent / 100.0); + } + if ui.small_button(tl!("100%")).clicked() { + nav.zoom = Some(1.0); + } + ui.add(egui::Label::new(egui::RichText::new(tl!("Drag to pan · Scroll to zoom · Double-click to fit")).weak()).truncate()); + }); + nav.write(f); +} + +pub fn paint(ui: &mut egui::Ui, rect: egui::Rect, response: &egui::Response, tex: &egui::TextureHandle, source: egui::Vec2, f: &mut Map) { + let mut nav = Navigation::read(f); + let ppp = ui.ctx().pixels_per_point(); + if response.double_clicked() { + nav.zoom = None; + nav.center = egui::Vec2::splat(0.5); + } + let mut scale = nav.scale(rect.size(), source, ppp); + if response.dragged() { + nav.zoom = Some(scale * ppp); + nav.center -= response.drag_delta() / (source * scale); + } + if response.hovered() { + let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta())); + let factor = if pinch != 1.0 { pinch } else { (scroll * 0.005).exp() }; + if factor != 1.0 { + let at = response.hover_pos().unwrap_or(rect.center()) - rect.center(); + nav.zoom_at(factor, at, rect.size(), source, ppp); + scale = nav.scale(rect.size(), source, ppp); + ui.ctx().request_repaint(); + } + } + nav.clamp(rect.size(), source * scale); + let image = egui::Rect::from_min_size(rect.center() - nav.center * source * scale, source * scale); + ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).image( + tex.id(), + image, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + // Fit follows resizing; explicit zoom/pan shares the same normalized centre in every pane. + if nav.zoom.is_some() { + f.insert("__effectiveZoom".into(), json!(nav.zoom)); + } else { + f.insert("__effectiveZoom".into(), json!(scale * ppp)); + } + nav.write(f); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn anchored_zoom_keeps_pixel_under_pointer_and_pan_is_bounded() { + let mut nav = Navigation { zoom: Some(1.0), center: egui::Vec2::splat(0.5) }; + let viewport = egui::vec2(200.0, 100.0); + let source = egui::vec2(800.0, 400.0); + let at = egui::vec2(30.0, 20.0); + let before = nav.center + at / source; + nav.zoom_at(2.0, at, viewport, source, 1.0); + let after = nav.center + at / (source * 2.0); + assert!((before - after).length() < 0.00001); + nav.center = egui::vec2(-5.0, 50.0); + nav.clamp(viewport, source * 2.0); + assert_eq!(nav.center, egui::vec2(0.0625, 0.9375)); + nav.zoom = None; + assert_eq!(nav.scale(viewport, source, 1.0), 0.25); + assert_eq!(nav.scale(viewport * 2.0, source, 1.0), 0.5); + } + + #[test] + fn palette_picker_reuses_indices_and_updates_preview_pixels() { + let key = egui::Id::new("test-web-preview"); + let p = json!({"format": "gif", "colorTable": ["#ff0000", "#0000ff"], "dither": "none"}); + let mut h = egui_kittest::Harness::builder().build_ui_state( + move |ui, state: &mut (Value, Vec)| { + state.1 = optimized(ui, key, "fixture", &state.0, &[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]], 2, 1).unwrap().0; + }, + (p, Vec::new()), + ); + let cached = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(String, Arc)>(key.with("indexed-source"))).unwrap().1; + let before = cached(&h.ctx); + assert_eq!(h.state().1, [255, 0, 0, 255, 0, 0, 255, 255]); + h.state_mut().0["colorTableEdits"] = json!({"0": "#00ff00"}); + h.run_steps(3); + assert!(Arc::ptr_eq(&before, &cached(&h.ctx)), "picker drag must not re-encode"); + assert_eq!(h.state().1, [0, 255, 0, 255, 0, 0, 255, 255]); + } + + #[test] + fn absurd_dimensions_and_navigation_fields_are_safe() { + let doc = + photocraft_doc::Document::new("fixture", photocraft_geom::Size::new(100, 50), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + assert!(dimensions(&doc, &json!({"percent": 1e308})).is_err()); + assert!(dimensions(&doc, &json!({"width": 1e300})).is_err()); + let f = serde_json::from_value::>(json!({"__zoom": 1e300, "__center": [1e300, -1e300]})).unwrap(); + let nav = Navigation::read(&f); + assert_eq!(nav.zoom, Some(32.0)); + assert_eq!(nav.center, egui::Vec2::splat(0.5)); + let mut nav = nav; + for factor in [f32::NAN, f32::INFINITY, 0.0, -1.0] { + nav.zoom_at(factor, egui::Vec2::ZERO, egui::vec2(200.0, 100.0), egui::vec2(400.0, 200.0), 1.0); + assert_eq!(nav.zoom, Some(32.0)); + assert!(nav.center.is_finite()); + } + } +} diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 8f588fbc99..f118798aab 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -196,6 +196,10 @@ fn is_pixel_param_of(command: &str, key: &str) -> bool { /// interval) and Tree, whose branches are built from 6 px segments. On a reduced proxy those /// features come out k times too large (or a different tree grows), so these preview at full /// resolution: what the preview shows is what OK applies, as in Photoshop (#2063). +/// +/// Sharpen, Sharpen More, Sharpen Edges, Find Edges, Emboss and Despeckle use fixed one-pixel +/// kernels with no size parameter, so a proxy changes their look: edges average away, making +/// the sharpening and edge detection invisible. These must preview at full resolution (#3929). const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.distort.ripple", "filter.stylize.diffuse", @@ -212,6 +216,12 @@ const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.stylize.wind", "filter.stylize.extrude", "filter.render.tree", + "filter.sharpen.sharpen", + "filter.sharpen.sharpenMore", + "filter.sharpen.sharpenEdges", + "filter.stylize.findEdges", + "filter.stylize.emboss", + "filter.noise.despeckle", ]; /// Whether `command` with `params` previews at full resolution: [`FULL_RESOLUTION_PREVIEW`], every @@ -289,6 +299,7 @@ pub fn has_dialog(command: &str) -> bool { | "layer.layerStyle.globalLight" | "image.mode.colorTable" | "edit.definePattern" + | "select.convertToShape" )) && photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty()) } @@ -343,6 +354,11 @@ fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option // Identity kernel: 1 in the centre. Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::>()), Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)), + // Replace Color starts from the foreground colour and can sample a new one off the canvas. + Kind::Color(None) if command == REPLACE_COLOR => { + let fg = app.session.tools.foreground; + json!(crate::color_picker_ui::hex([fg[0], fg[1], fg[2]])) + } Kind::Color(None) => return None, // Colour inputs come from the current swatches, so the proxy preview matches the result. Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground), @@ -542,6 +558,38 @@ fn uses_angle_dial(command: &str, key: &str) -> bool { key == "angle" && ANGLE_DIAL.contains(&command) } +/// Replace Color (#4143): the dialog's "From" colour, sampled from the image with a canvas +/// eyedropper like Photoshop's. While armed, the next click on the image writes the merged +/// composite colour into the `color` field (the same sampler the Color Picker uses). +const REPLACE_COLOR: &str = "image.adjustments.replaceColor"; +const COLOR_EYEDROPPER: &str = "__colorEyedropper"; + +fn color_eyedropper_owns(f: &Map) -> bool { + f.get("__filter").and_then(Value::as_bool) == Some(true) + && f.get("__command").and_then(Value::as_str) == Some(REPLACE_COLOR) + && f.get(COLOR_EYEDROPPER).and_then(Value::as_bool) == Some(true) +} + +/// Whether the top dialog is a Replace Color with its canvas eyedropper armed. +pub(crate) fn color_eyedropper_armed(app: &PhotocraftApp) -> bool { + app.ui.dialogs.last().is_some_and(|d| color_eyedropper_owns(&d.fields)) +} + +/// Sample the merged image at document point (x, y) into the armed dialog's "From" colour. +/// Returns true when the click was consumed, even off-image or over transparency, where nothing +/// changes and the eyedropper stays armed (like the Curves picker). +pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { + let Some(id) = app.ui.dialogs.last().filter(|d| color_eyedropper_owns(&d.fields)).map(|d| d.id) else { + return false; + }; + if let Some(rgb) = crate::canvas::composite_color(app, x, y) + && let Some(d) = app.ui.dialog_mut(id) + { + d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); + } + true +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -684,11 +732,18 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let bytes = rgb.map(q); let name = label(&p.key); let mut clicked = false; + let eyedropper = cmd == REPLACE_COLOR && p.key == "color"; ui.horizontal(|ui| { ui.label(egui::RichText::new(&name).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // PhotoCraft's Color Picker, as everywhere else (#2144). clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), &name).clicked(); + if eyedropper { + let armed = f.get(COLOR_EYEDROPPER).and_then(Value::as_bool).unwrap_or(false); + if crate::icons::button(ui, "pipette", 24.0, armed, "Pick Color From Image").clicked() { + f.insert(COLOR_EYEDROPPER.into(), json!(!armed)); + } + } ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); }); }); @@ -1520,6 +1575,12 @@ mod tests { ("filter.stylize.wind", json!({"method": "blast"})), ("filter.stylize.extrude", json!({})), ("filter.render.tree", json!({"baseTreeType": 1})), + ("filter.sharpen.sharpen", json!({})), + ("filter.sharpen.sharpenMore", json!({})), + ("filter.sharpen.sharpenEdges", json!({})), + ("filter.stylize.findEdges", json!({})), + ("filter.stylize.emboss", json!({})), + ("filter.noise.despeckle", json!({})), ("filter.gallery.graphicPen", json!({})), ("filter.gallery.stainedGlass", json!({"cellSize": 10})), ("image.mode.indexedColor", json!({"colors": 16})), @@ -1911,4 +1972,50 @@ mod tests { let fields = &app.ui.dialogs.last().unwrap().fields; assert_eq!(fields["direction"], json!("cw"), "Explicit default matching first option is preserved"); } + + /// #4143: Replace Color shows the From colour (defaulting to the foreground) and its canvas + /// eyedropper samples the merged image into the dialog. + #[test] + fn replace_color_eyedropper_samples_the_image() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#00ff00"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + let id = open(&mut app, REPLACE_COLOR).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#ff0000"), "the dialog starts from the foreground colour"); + assert!(!color_eyedropper_armed(&app)); + app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); + assert!(color_eyedropper_armed(&app)); + assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + // Off-image: the click is consumed, nothing changes and the eyedropper stays armed. + assert!(color_eyedropper_sample(&mut app, -1.0, 4.0)); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + assert!(color_eyedropper_armed(&app)); + // Disarmed, the dialog never claims the click. + app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(false)); + assert!(!color_eyedropper_armed(&app)); + assert!(!color_eyedropper_sample(&mut app, 4.0, 4.0)); + } + + /// #4143: the eyedropper button arms and disarms the canvas picker. + #[test] + fn replace_color_dialog_arms_the_eyedropper_button() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + h.get_by_label("Pick Color From Image").click(); + h.run_steps(2); + assert!(color_eyedropper_armed(h.state())); + h.get_by_label("Pick Color From Image").click(); + h.run_steps(2); + assert!(!color_eyedropper_armed(h.state())); + } } diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index f2b8f89561..de9116ef5d 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -101,10 +101,7 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu let width = ui.available_width().max(140.0); let (rect, response) = ui.allocate_exact_size(vec2(width, PREVIEW_H), Sense::click_and_drag()); let (x0, y0, sw, sh) = source_rect(cx, cy, zoom, rect.size(), doc); - let uv = Rect::from_min_max( - pos2((x0 / doc.x).clamp(0.0, 1.0), (y0 / doc.y).clamp(0.0, 1.0)), - pos2(((x0 + sw) / doc.x).clamp(0.0, 1.0), ((y0 + sh) / doc.y).clamp(0.0, 1.0)), - ); + let (dest, uv) = placement(rect, (x0, y0, sw, sh), zoom, doc); // Press-and-hold shows the unfiltered image, as Photoshop's preview does. let holding = response.is_pointer_button_down_on(); @@ -112,8 +109,8 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu let painter = ui.painter_at(rect); painter.rect_filled(rect, t.radius_sm, t.canvas); if let Some((tex, _)) = shown { - crate::widgets::checker(&painter, rect, 8.0); - painter.image(tex.id(), rect, uv, Color32::WHITE); + crate::widgets::checker(&painter, dest, 8.0); + painter.image(tex.id(), dest, uv, Color32::WHITE); } painter.rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); @@ -190,6 +187,21 @@ fn origin(centre: f32, size: f32, total: f32) -> f32 { if size >= total { (total - size) / 2.0 } else { (centre - size / 2.0).clamp(0.0, total - size) } } +/// Where the image is drawn in `rect` and which part of the texture fills it, for the `source` +/// rectangle `(x0, y0, width, height)` in document pixels shown at `zoom`. Only the part of the +/// source that lies on the image is drawn, at its own place in the region: stretching the clamped +/// texture over the whole region made the pixels non-square whenever the region overhung the image +/// on one axis (#4640). +fn placement(rect: Rect, source: (f32, f32, f32, f32), zoom: f32, doc: Vec2) -> (Rect, Rect) { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { DEFAULT_ZOOM }; + let (x0, y0, sw, sh) = source; + let (vx0, vy0) = (x0.max(0.0), y0.max(0.0)); + let (vx1, vy1) = ((x0 + sw).min(doc.x).max(vx0), (y0 + sh).min(doc.y).max(vy0)); + let at = |x: f32, y: f32| rect.min + vec2((x - x0) * zoom, (y - y0) * zoom); + let uv = Rect::from_min_max(pos2(vx0 / doc.x, vy0 / doc.y), pos2(vx1 / doc.x, vy1 / doc.y)); + (Rect::from_min_max(at(vx0, vy0), at(vx1, vy1)), uv) +} + /// The document pixel under a pointer at `local` (relative to the region's top-left) in a view whose /// source rectangle starts at `(x0, y0)` and is shown at `zoom`. fn point_to_source(local: Vec2, x0: f32, y0: f32, zoom: f32, doc: Vec2) -> (f32, f32) { @@ -425,6 +437,24 @@ mod tests { assert!(x0 / doc.x < 0.0 && (x0 + sw) / doc.x > 1.0); } + #[test] + fn the_image_keeps_square_pixels_when_the_region_overhangs_it() { + // #4640: a 370×240 document in a 650×150 region at 100% is wider than the image but shorter + // than it, and at 50% it fits inside on both axes. Either way one document pixel must cover + // the same number of points across and down, and the image is centred, not stretched. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(650.0, 150.0)); + let doc = vec2(370.0, 240.0); + for zoom in [1.0, 0.5] { + let (dest, uv) = placement(rect, source_rect(185.0, 120.0, zoom, rect.size(), doc), zoom, doc); + let across = dest.width() / (uv.width() * doc.x); + let down = dest.height() / (uv.height() * doc.y); + assert!((across - zoom).abs() < 1e-4 && (down - zoom).abs() < 1e-4, "zoom {zoom}: {across} × {down} points per pixel"); + assert!((dest.center().x - rect.center().x).abs() < 1e-3, "zoom {zoom}: centred across"); + assert!(rect.contains_rect(dest), "zoom {zoom}: drawn inside the region"); + assert!(Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)).contains_rect(uv), "zoom {zoom}: {uv:?} stays on the texture"); + } + } + #[test] fn a_click_maps_to_the_document_point_under_it() { let doc = vec2(1000.0, 500.0); diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 1568abf655..74c334d682 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -49,9 +49,12 @@ pub(crate) fn sample(preview: FontPreview) -> Option<(f32, [f32; 2])> { Some((size, box_for(size))) } -/// Family, display scale, the font database generations (so a family installed or fetched -/// (served fonts) after its first miss gets a sample once it is available) and the point size. -type Key = (String, u32, (u64, u64), u32); +/// Family, display scale, the shared font database's generation (so a family installed or +/// fetched (served fonts) after its first miss gets a sample once it is available) and the point +/// size. Not the served catalog's generation: a listed family has no sample until its fonts are +/// registered, which moves the database's generation, and keying on the catalog re-rendered +/// every sample whenever it changed. +type Key = (String, u32, u64, u32); #[derive(Clone)] struct Samples { latin: TextureHandle, @@ -111,7 +114,7 @@ fn images(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32, si fn key(ctx: &egui::Context, family: &str, size: f32) -> Key { let scale = ctx.pixels_per_point().ceil().clamp(1.0, 4.0) as u32; - (family.to_owned(), scale, (photocraft_text::fonts::generation(), photocraft_text::served::generation()), size as u32) + (family.to_owned(), scale, photocraft_text::fonts::shared_generation(), size as u32) } /// Width a row reserves for its samples: wider once a painted row showed the family covers diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index ed1597deb1..83928e29b4 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -21,6 +21,7 @@ use photocraft_raster::{Interrupt, Surface}; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; +use crate::edit_history::{EditHistory, Step}; use crate::filter_dialog::{Kind, parse_spec}; use crate::theme::Tokens; use crate::widgets; @@ -91,6 +92,7 @@ pub struct GalleryDialog { thumb_src: Surface, thumbs: Vec>, pub preview_ms: f64, + history: EditHistory>, } impl GalleryDialog { @@ -145,6 +147,12 @@ impl GalleryDialog { } } + pub(crate) fn step(&mut self, step: Step) { + if self.history.step(&mut self.effects, step) { + self.selected = self.selected.min(self.effects.len().saturating_sub(1)); + } + } + fn move_to(&mut self, from: usize, to: usize) { if from < self.effects.len() && to < self.effects.len() && from != to { let e = self.effects.remove(from); @@ -295,6 +303,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { let thumb_src = sample(&src, Rect::new(cx - tw / 2, cy - th / 2, cx - tw / 2 + tw, cy - th / 2 + th), k); let sel = effects.len() - 1; let open_cats = std::array::from_fn(|i| effects[sel].filter.category() == GALLERY_CATEGORIES[i]); + let history = EditHistory::new(&effects); app.distort.gallery = Some(GalleryDialog { layer, layer_name, @@ -316,6 +325,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { thumb_src, thumbs: vec![None; GalleryFilter::ALL.len()], preview_ms: 0.0, + history, }); Ok(()) } @@ -370,6 +380,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if let Some(Value::Object(m)) = ui.get("params") { d.cur().params.extend(m.clone()); } + d.history.settle(&d.effects, false); match ui.get("zoom") { Some(Value::String(s)) if s == "fit" => d.zoom = 0.0, // The gallery's own preview zoom, not the canvas (`zoom_levels` doesn't apply): its − / + @@ -545,6 +556,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let background = app.background_jobs; egui::Area::new(egui::Id::new("gallery-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.distort.gallery.as_mut() else { return }; + d.history.settle(&d.effects, ctx.input(|i| i.pointer.any_down())); let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); let painter = ui.painter().clone(); painter.rect_filled(full, 0.0, t.chrome); @@ -609,7 +621,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let r = ui.horizontal(|ui| { let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); crate::icons::paint(ui, ir, icon, 14.0, t.icon); - ui.add(egui::Label::new(egui::RichText::new(tl!(*cat)).color(t.text)).sense(Sense::click())) + ui.add(egui::Label::new(egui::RichText::new(tl!(*cat)).color(t.text)).selectable(false).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand) }); if r.inner.clicked() { d.open[ci] = !d.open[ci]; diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index 5fd87393d3..5590c13f25 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -13,6 +13,7 @@ use std::sync::Arc; +use egui::emath::GuiRounding; use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_compose::gradient_fill as gf; use photocraft_doc::{Document, Fill, GradientStyle, Layer, LayerContent, LayerId}; @@ -508,6 +509,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform /// A ramp of RGBA stops over a checkerboard in `r`. fn paint_ramp(p: &egui::Painter, r: Rect, sample: impl Fn(f32) -> [f32; 4]) { + // The tessellator snaps the checker's rects to pixels but not the mesh: snap both alike, or a + // half-pixel offset leaves a row of checker showing under the ramp (#4577). + let r = r.round_to_pixels(p.pixels_per_point()); widgets::checker(p, r, 5.0); let n = 64; let mut mesh = egui::Mesh::default(); @@ -1390,4 +1394,42 @@ mod tests { assert!(active_gradient(&app).is_none(), "the canvas widget skips a hidden layer"); assert!(selected_gradient(&app).is_some(), "the editor still edits it"); } + + /// #4577: the tessellator snaps the checkerboard's rects to whole pixels but leaves the ramp's + /// mesh where it was laid out, so at a half-pixel offset the ramp stopped a pixel short of the + /// checker and a row of it showed under the gradient. The ramp must cover the checker exactly. + #[test] + fn the_ramp_covers_its_checkerboard_at_half_pixel_offsets() { + let ctx = egui::Context::default(); + for ppp in [1.0, 2.0] { + ctx.set_pixels_per_point(ppp); + let r = Rect::from_min_size(pos2(10.0 + 0.5 / ppp, 7.0 + 0.5 / ppp), vec2(96.0, 20.0)); + // The new scale applies from the next frame. + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let mut painted_at = 0.0; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + painted_at = ui.ctx().pixels_per_point(); + paint_ramp(ui.painter(), r, |u| [u, u, u, 1.0]); + }); + out.textures_delta.clear(); + assert_eq!(painted_at, ppp); + let checker = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Rect(rs) if rs.fill == Color32::from_gray(250) => Some(rs.rect), + _ => None, + }) + .expect("the checkerboard's base rect"); + let ramp = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(m) => Some(m.calc_bounds()), + _ => None, + }) + .expect("the ramp mesh"); + assert_eq!(ramp, checker.round_to_pixels(ppp), "{ppp}x: the ramp covers every pixel of the checker"); + } + } } diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index c182247bfd..3f2845cae8 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -147,9 +147,10 @@ fn held(i: &InputState, sc: &KeyboardShortcut) -> bool { } /// The temporary tool whose key is held now, the most specific binding first (⌘Space before -/// Space). Never while a text field has the keyboard. +/// Space). Never while a text field has the keyboard, or while editing type on the canvas +/// (Space must insert a space, not grab the Hand tool). pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option { - if ctx.text_edit_focused() { + if ctx.text_edit_focused() || app.ui.text_edit.is_some() { return None; } let count = |sc: &KeyboardShortcut| { @@ -194,7 +195,7 @@ pub fn erase_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { /// Is the reposition key (the Hand key, any modifiers) down? pub fn reposition_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { - !ctx.text_edit_focused() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) + !ctx.text_edit_focused() && app.ui.text_edit.is_none() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) } /// Tools whose in-progress drag the reposition key moves. diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index 45e806c64d..cfe89d4642 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -72,9 +72,15 @@ fn key(h: &mut Harness<'static, PhotocraftApp>, k: Key, pressed: bool, m: Modifi h.run_steps(1); } +/// A quick press and release in one frame (a tool letter held across frames springs, #2672). fn tap(h: &mut Harness<'static, PhotocraftApp>, k: Key, m: Modifiers) { - key(h, k, true, m); - key(h, k, false, m); + let m = platform(m); + h.input_mut().events.extend([ + Event::ModifiersChanged(m), + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ]); + h.run_steps(1); h.event(Event::ModifiersChanged(Modifiers::NONE)); h.run_steps(1); } @@ -174,6 +180,28 @@ fn space_is_a_temporary_hand_and_gives_the_tool_back() { assert!(!st.session.active().unwrap().history.can_undo(), "nothing was painted"); } +#[test] +fn space_while_editing_type_is_not_the_hand_tool() { + let mut h = harness(Tool::Type); + let id = h.state_mut().run("type.create", json!({"text": "ئا", "size": 20, "x": 100, "y": 100})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 2, + anchor: 2, + session: "t".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + key(&mut h, Key::Space, true, Modifiers::NONE); + assert!(h.state().ui.text_edit.is_some(), "type session stays open"); + assert_eq!(held_tool(h.state(), &h.ctx), None, "Space types a space while editing"); + assert!(!reposition_held(h.state(), &h.ctx)); + key(&mut h, Key::Space, false, Modifiers::NONE); +} + #[test] fn temporary_zoom_in_and_out_restore_the_tool() { let mut h = harness(Tool::RectMarquee); diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 0177bcb56d..c33e7a21e9 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ -PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish and -Hungarian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full -license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. +PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish, +Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. +The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. @@ -28,3 +28,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Hungarian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Serbian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 36fc380769..cea55a1694 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -19,7 +19,17 @@ # (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin letters; the trailing "…" of # dialog commands is kept. +# Channel Options and New Spot Channel (#3761) + Channel Color Barva kanálu + Color Indicates: Barva označuje: + Solidity: Plnost: + Ink Characteristics Vlastnosti inkoustu + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Šířka papíru + Paper Height Výška papíru Columns Sloupce Rows Řádky Number Počet @@ -77,6 +87,8 @@ Save Uložit Save As… Uložit jako… Save a Copy… Uložit kopii… + {name} can't keep everything in this document {name} nedokáže uchovat vše z tohoto dokumentu + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Zvolte formát, který vše zachová, například Photoshop (PSD), nebo uložte kopii. Revert Vrátit k uložené verzi Export Export Quick Export as PNG Rychlý export do PNG @@ -114,6 +126,13 @@ Script Events Manager… Správce událostí skriptů… Import Import Browse… Procházet… + Source Files Zdrojové soubory + Use: Použít: + Files Soubory + Add Open Files Přidat otevřené soubory + Sort By Name Seřadit podle názvu + Attempt to Automatically Align Source Images Pokusit se automaticky zarovnat zdrojové obrazy + Create Smart Object after Loading Layers Po načtení vrstev vytvořit chytrý objekt Reveal in Finder Zobrazit ve Finderu Show in Explorer Zobrazit v Průzkumníkovi Show in Folder Zobrazit ve složce @@ -138,6 +157,8 @@ Search… Hledat… Check Spelling… Kontrola pravopisu… Find and Replace Text… Najít a nahradit text… + Add to Dictionary Přidat do slovníku + No Spelling Suggestions Žádné návrhy pravopisu Fill… Vyplnit… Stroke… Tah… Content-Aware Fill… Výplň s ohledem na obsah… @@ -152,6 +173,8 @@ Transform Again on a Copy Duplikovat a opakovat transformaci Scale Měřítko Rotate Otočit + Set horizontal skew Nastavit vodorovné zkosení + Set vertical skew Nastavit svislé zkosení Skew Zkosit Distort Zkreslit Perspective Perspektiva @@ -733,6 +756,10 @@ Breeze Dark Theme Tmavý motiv Breeze Breeze light Breeze světlý Breeze Light Theme Světlý motiv Breeze + Soft dark Jemný tmavý + Soft light Jemný světlý + Soft Dark Theme Jemné tmavé téma + Soft Light Theme Jemné světlé téma Next Theme Další motiv Actions Akce Brush Settings Nastavení štětce @@ -840,6 +867,9 @@ Appearance Vzhled Appearance (Layer Style) Vzhled (styl vrstvy) Apply Layer Mask Použít masku vrstvy + Add Mask to Selection Přidat masku do výběru + Subtract Mask from Selection Odečíst masku od výběru + Intersect Mask with Selection Průnik masky s výběrem Apply Per Tip Použít pro každý hrot Apply next selected layer comp Použít další vybranou kompozici vrstev Apply previous selected layer comp Použít předchozí vybranou kompozici vrstev @@ -860,6 +890,12 @@ Baseline shift Posun účaří Basic Základní Before (Y) Před (Y) + Save Settings… Uložit nastavení… + Load Settings… Načíst nastavení… + Save these settings as a Camera Raw .xmp file Uložit toto nastavení jako soubor Camera Raw .xmp + Load settings from a Camera Raw .xmp file Načíst nastavení ze souboru Camera Raw .xmp + Loaded Camera Raw settings from {file} Nastavení Camera Raw načteno ze souboru {file} + Loaded Camera Raw settings from {file} ({n} not supported) Nastavení Camera Raw načteno ze souboru {file} (nepodporováno: {n}) Begin recording Zahájit záznam Bend: Ohyb: Bicubic Bikubická @@ -1162,6 +1198,9 @@ Ignore All Ignorovat vše Image Size Velikost obrazu Import Brushes… Importovat štětce… + Gradient Preset Options Možnosti předvoleb přechodu + Import .GRD Importovat .GRD + Choose a gradient file (.grd). Vyberte soubor přechodů (.grd). Import brushes (.abr) Importovat štětce (.abr) Import Presets Importovat přednastavení Imported Importováno @@ -2102,6 +2141,11 @@ Rename Brush… Přejmenovat štětec… Delete Brush Odstranit štětec New Brush Preset… Nová předvolba štětce… + Capture Brush Size in Preset Zachytit velikost štětce v předvolbě + Include Tool Settings Zahrnout nastavení nástroje + Include Color Zahrnout barvu + Enter a name for the brush preset Zadejte název předvolby štětce + A brush preset with this name already exists Předvolba štětce s tímto názvem již existuje Brush Preset Options Možnosti přednastavení štětců Group name Název skupiny Brush name Název štětce @@ -2323,6 +2367,7 @@ cameraRaw Color Barvy Click to pick · Esc to cancel Kliknutí vybere · Esc zruší Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení Scroll to zoom Rolováním přibližujte + Drag to pan · Scroll to zoom · Double-click to fit Tažením posouvejte · Rolováním přibližujte · Dvojklikem přizpůsobte Show/Hide All Panels Zobrazit/skrýt všechny panely Show/Hide Panels Zobrazit/skrýt panely Could not open Nelze otevřít @@ -3625,6 +3670,7 @@ curves Smooth Vyhladit gradients přechody @plural Imported {n} brush from {file}|Imported {n} brushes from {file} Importován {n} štětec ze souboru {file}|Importovány {n} štětce ze souboru {file}|Importováno {n} štětců ze souboru {file} @plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Importován {n} přechod ze souboru {file}|Importovány {n} přechody ze souboru {file}|Importováno {n} přechodů ze souboru {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Importován {n} vzorek ze souboru {file}|Importovány {n} vzorky ze souboru {file}|Importováno {n} vzorků ze souboru {file} Data Set: {name} ({index}/{total}) Datová sada: {name} ({index}/{total}) (none) (žádná) navigator Zoom Zvětšení @@ -3657,9 +3703,14 @@ navigator Zoom Zvětšení Light Blue Světle modrá Light Red Světle červená Medium Blue Středně modrá + Object Finder Hledání objektů + No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + Clear Brush Controls Obnovit ovládací prvky stopy + Reset All Locked Settings Obnovit všechna zamknutá nastavení + Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Načíst akce… @@ -3681,3 +3732,5 @@ navigator Zoom Zvětšení Action playback stopped Přehrávání akce zastaveno Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí + Auto Add/Delete Automaticky přidat/odstranit + ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dc366b4553..b23510e12e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -15,7 +15,19 @@ # way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). +# Channel Options and New Spot Channel (#3761) + Channel Color Kanalfarbe + Color Indicates: Farbe kennzeichnet: + Solidity: Solidität: + Masked Areas Maskierte Bereiche + Selected Areas Ausgewählte Bereiche + Ink Characteristics Farbeigenschaften + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papierbreite + Paper Height Papierhöhe Columns Spalten Rows Zeilen Number Anzahl @@ -73,6 +85,8 @@ Save Speichern Save As… Speichern unter… Save a Copy… Kopie speichern… + {name} can't keep everything in this document {name} kann nicht alles in diesem Dokument erhalten + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Wählen Sie ein Format, das alles erhält, etwa Photoshop (PSD), oder speichern Sie eine Kopie. Revert Zurücksetzen Export Exportieren Quick Export as PNG Schnellexport als PNG @@ -110,6 +124,14 @@ Script Events Manager… Skriptereignis-Manager… Import Importieren Browse… Durchsuchen… + Source Files Quelldateien + Use: Verwenden: + Files Dateien + Folder Ordner + Add Open Files Geöffnete Dateien hinzufügen + Sort By Name Nach Namen sortieren + Attempt to Automatically Align Source Images Quellbilder nach Möglichkeit automatisch ausrichten + Create Smart Object after Loading Layers Nach dem Laden der Ebenen Smart-Objekt erstellen Reveal in Finder Im Finder anzeigen Show in Explorer Im Explorer anzeigen Show in Folder Im Ordner anzeigen @@ -134,6 +156,8 @@ Search… Suchen… Check Spelling… Rechtschreibung prüfen… Find and Replace Text… Text suchen und ersetzen… + Add to Dictionary Zum Wörterbuch hinzufügen + No Spelling Suggestions Keine Rechtschreibvorschläge Fill… Füllen… Stroke… Nachziehen… Content-Aware Fill… Inhaltsbasiert füllen… @@ -148,6 +172,8 @@ Transform Again on a Copy Erneut auf Kopie transformieren Scale Skalieren Rotate Drehen + Set horizontal skew Horizontale Neigung festlegen + Set vertical skew Vertikale Neigung festlegen Skew Neigen Distort Verzerren Perspective Perspektive @@ -719,6 +745,10 @@ Breeze Dark Theme Breeze-Design (Dunkel) Breeze light Breeze hell Breeze Light Theme Breeze-Design (Hell) + Soft dark Weich dunkel + Soft light Weich hell + Soft Dark Theme Weiches Design (Dunkel) + Soft Light Theme Weiches Design (Hell) Next Theme Nächstes Design Actions Aktionen Brush Settings Pinseleinstellungen @@ -817,6 +847,9 @@ Appearance Erscheinungsbild Appearance (Layer Style) Erscheinungsbild (Ebenenstil) Apply Layer Mask Ebenenmaske anwenden + Add Mask to Selection Maske zur Auswahl hinzufügen + Subtract Mask from Selection Maske von Auswahl subtrahieren + Intersect Mask with Selection Schnittmenge von Maske und Auswahl Apply Per Tip Pro Pinselspitze anwenden Apply next selected layer comp Nächste ausgewählte Ebenenkomposition anwenden Apply previous selected layer comp Vorherige ausgewählte Ebenenkomposition anwenden @@ -837,6 +870,12 @@ Baseline shift Grundlinienverschiebung Basic Grundeinstellungen Before (Y) Davor (Y) + Save Settings… Einstellungen speichern… + Load Settings… Einstellungen laden… + Save these settings as a Camera Raw .xmp file Diese Einstellungen als Camera-Raw-.xmp-Datei speichern + Load settings from a Camera Raw .xmp file Einstellungen aus einer Camera-Raw-.xmp-Datei laden + Loaded Camera Raw settings from {file} Camera-Raw-Einstellungen aus {file} geladen + Loaded Camera Raw settings from {file} ({n} not supported) Camera-Raw-Einstellungen aus {file} geladen ({n} nicht unterstützt) Begin recording Aufnahme starten Bend: Biegung: Bicubic Bikubisch @@ -1142,6 +1181,9 @@ Ignore All Alle ignorieren Image Size Bildgröße Import Brushes… Pinsel importieren… + Gradient Preset Options Verlaufsvorgabenoptionen + Import .GRD .GRD importieren + Choose a gradient file (.grd). Wählen Sie eine Verlaufsdatei (.grd). Import brushes (.abr) Pinsel (.abr) importieren Import Presets Vorgaben importieren Imported Importiert @@ -2069,6 +2111,11 @@ Rename Brush… Pinsel umbenennen… Delete Brush Pinsel löschen New Brush Preset… Neue Pinselvorgabe… + Capture Brush Size in Preset Pinselgröße in Vorgabe erfassen + Include Tool Settings Werkzeugeinstellungen einschließen + Include Color Farbe einschließen + Enter a name for the brush preset Geben Sie einen Namen für die Pinselvorgabe ein + A brush preset with this name already exists Eine Pinselvorgabe mit diesem Namen ist bereits vorhanden Brush Preset Options Pinselvorgaben-Optionen Group name Gruppenname Brush name Pinselname @@ -2360,6 +2407,7 @@ Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen Scroll to zoom Zum Zoomen scrollen + Drag to pan · Scroll to zoom · Double-click to fit Ziehen zum Verschieben · Scrollen zum Zoomen · Doppelklick zum Einpassen Show/Hide All Panels Alle Bedienfelder ein-/ausblenden Show/Hide Panels Bedienfelder ein-/ausblenden Could not open Öffnen nicht möglich @@ -2689,9 +2737,14 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau + Object Finder Objektsucher + No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + Clear Brush Controls Pinselsteuerungen löschen + Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen + Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Aktionen laden… @@ -2713,3 +2766,5 @@ curves Smooth Glätten Action playback stopped Aktionswiedergabe angehalten Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog @plural {n} action|{n} actions {n} Aktion|{n} Aktionen + Auto Add/Delete Autom. hinzuf./löschen + ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3f693d543f..1dd9ba3652 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3,7 +3,17 @@ # Original translations based on general Greek graphics/computing terminology. # Covers every source label the i18n coverage check lists. +# Channel Options and New Spot Channel (#3761) + Channel Color Χρώμα καναλιού + Color Indicates: Το χρώμα υποδεικνύει: + Solidity: Συμπαγότητα: + Ink Characteristics Χαρακτηριστικά μελανιού + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Πλάτος χαρτιού + Paper Height Ύψος χαρτιού Rows Γραμμές Number Πλήθος Top: Επάνω: @@ -54,6 +64,8 @@ Save Αποθήκευση Save As… Αποθήκευση ως… Save a Copy… Αποθήκευση αντιγράφου… + {name} can't keep everything in this document Το {name} δεν μπορεί να κρατήσει όλο το περιεχόμενο αυτού του εγγράφου + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Επιλέξτε μια μορφή που τα κρατά όλα, όπως Photoshop (PSD), ή αποθηκεύστε ένα αντίγραφο. Revert Επαναφορά αποθηκευμένης έκδοσης Export Εξαγωγή Quick Export as PNG Γρήγορη εξαγωγή σε PNG @@ -91,6 +103,13 @@ Script Events Manager… Διαχείριση συμβάντων δεσμών ενεργειών… Import Εισαγωγή Browse… Περιήγηση… + Source Files Αρχεία προέλευσης + Use: Χρήση: + Files Αρχεία + Add Open Files Προσθήκη ανοιχτών αρχείων + Sort By Name Ταξινόμηση κατά όνομα + Attempt to Automatically Align Source Images Απόπειρα αυτόματης στοίχισης εικόνων προέλευσης + Create Smart Object after Loading Layers Δημιουργία έξυπνου αντικειμένου μετά τη φόρτωση επιπέδων Reveal in Finder Εμφάνιση στο Finder Show in Explorer Εμφάνιση στην Εξερεύνηση αρχείων Show in Folder Εμφάνιση στον φάκελο @@ -115,6 +134,8 @@ Search… Αναζήτηση… Check Spelling… Ορθογραφικός έλεγχος… Find and Replace Text… Εύρεση και αντικατάσταση κειμένου… + Add to Dictionary Προσθήκη στο λεξικό + No Spelling Suggestions Δεν υπάρχουν ορθογραφικές προτάσεις Fill… Γέμισμα… Stroke… Περίγραμμα… Preferences… Προτιμήσεις… @@ -164,6 +185,8 @@ Transform Μετασχηματισμός Free Transform Ελεύθερος μετασχηματισμός Rotate Περιστροφή + Set horizontal skew Ορισμός οριζόντιας λοξής παραμόρφωσης + Set vertical skew Ορισμός κατακόρυφης λοξής παραμόρφωσης Flip Horizontal Οριζόντια αναστροφή Flip Vertical Κατακόρυφη αναστροφή Crop Περικοπή @@ -247,6 +270,9 @@ Add Layer Mask Προσθήκη μάσκας επιπέδου Always use pressure for opacity Πάντα χρήση πίεσης για την αδιαφάνεια Apply Layer Mask Εφαρμογή μάσκας επιπέδου + Add Mask to Selection Προσθήκη μάσκας στην επιλογή + Subtract Mask from Selection Αφαίρεση μάσκας από την επιλογή + Intersect Mask with Selection Τομή μάσκας με την επιλογή Brush Preset picker Επιλογή προεπιλογής πινέλου Brush Reconstruct Options Επιλογές πινέλου επαναφοράς Brush Tool Options Επιλογές πινέλου (Brush Tool) @@ -273,6 +299,9 @@ Filter for shape layers Φιλτράρισμα επιπέδων σχημάτων Filter for type layers Φιλτράρισμα επιπέδων κειμένου Import Brushes… Εισαγωγή πινέλων… + Gradient Preset Options Επιλογές προεπιλογών ντεγκραντέ + Import .GRD Εισαγωγή .GRD + Choose a gradient file (.grd). Επιλέξτε αρχείο ντεγκραντέ (.grd). Import brushes (.abr) Εισαγωγή πινέλων (.abr) Mask All Απόκρυψη όλων με μάσκα Mask Options Επιλογές μάσκας @@ -1462,6 +1491,11 @@ Developing {name} Επεξεργασία του {name} Camera Raw ({name}) Επεξεργασία RAW ({name}) New Brush Preset… Νέα προεπιλογή πινέλου… + Capture Brush Size in Preset Αποθήκευση μεγέθους πινέλου στην προεπιλογή + Include Tool Settings Συμπερίληψη ρυθμίσεων εργαλείου + Include Color Συμπερίληψη χρώματος + Enter a name for the brush preset Εισαγάγετε όνομα για την προεπιλογή πινέλου + A brush preset with this name already exists Υπάρχει ήδη προεπιλογή πινέλου με αυτό το όνομα Brush Preset Options Επιλογές προεπιλογής πινέλου Show font names in english Εμφάνιση ονομάτων γραμματοσειρών στα αγγλικά Show menu colors Εμφάνιση χρωμάτων μενού @@ -1896,6 +1930,12 @@ Aqua Κυανό Balance Ισορροπία Before (Y) Πριν (Y) + Save Settings… Αποθήκευση ρυθμίσεων… + Load Settings… Φόρτωση ρυθμίσεων… + Save these settings as a Camera Raw .xmp file Αποθήκευση αυτών των ρυθμίσεων ως αρχείο Camera Raw .xmp + Load settings from a Camera Raw .xmp file Φόρτωση ρυθμίσεων από αρχείο Camera Raw .xmp + Loaded Camera Raw settings from {file} Φορτώθηκαν ρυθμίσεις Camera Raw από το {file} + Loaded Camera Raw settings from {file} ({n} not supported) Φορτώθηκαν ρυθμίσεις Camera Raw από το {file} ({n} δεν υποστηρίζονται) Blending Ανάμειξη Camera Raw Filter ({layer}) Φίλτρο Camera Raw ({layer}) Camera Raw isn't open Το Camera Raw δεν είναι ανοιχτό @@ -2121,6 +2161,10 @@ Breeze Dark Theme Σκούρο θέμα Breeze Breeze light Breeze φωτεινό Breeze Light Theme Φωτεινό θέμα Breeze + Soft dark Απαλό σκούρο + Soft light Απαλό ανοιχτό + Soft Dark Theme Απαλό σκούρο θέμα + Soft Light Theme Απαλό ανοιχτό θέμα Clear Canvas Guides Απαλοιφή οδηγών καμβά Clear Override Απαλοιφή παράκαμψης Clear Samples Απαλοιφή δειγμάτων @@ -3503,6 +3547,7 @@ fontWeight Thin Πολύ λεπτά Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση Scroll to zoom Κύλιση για μεγέθυνση + Drag to pan · Scroll to zoom · Double-click to fit Σύρετε για μετακίνηση · Κυλήστε για μεγέθυνση · Διπλό κλικ για προσαρμογή # Saved New Document presets (#1516) Preset Name Όνομα προεπιλογής @@ -3747,9 +3792,14 @@ curves Smooth Εξομάλυνση Light Red Ανοιχτό κόκκινο Medium Blue Μεσαίο μπλε Light Gray Ανοιχτό γκρι + Object Finder Εύρεση αντικειμένων + No object found there — drag a rectangle around it Δεν βρέθηκε αντικείμενο εκεί — σύρετε ένα ορθογώνιο γύρω του Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Clear Brush Controls Απαλοιφή στοιχείων ελέγχου πινέλου + Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων + Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Φόρτωση ενεργειών… @@ -3771,3 +3821,5 @@ curves Smooth Εξομάλυνση Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες + Auto Add/Delete Αυτόματη προσθήκη/διαγραφή + ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f001c478b..5d1ff71146 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -12,7 +12,19 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Channel Options and New Spot Channel (#3761) + Channel Color Color del canal + Color Indicates: El color indica: + Solidity: Solidez: + Masked Areas Áreas enmascaradas + Selected Areas Áreas seleccionadas + Ink Characteristics Características de la tinta + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Ancho del papel + Paper Height Altura del papel Columns Columnas Rows Filas Number Número @@ -70,6 +82,8 @@ Save Guardar Save As… Guardar como… Save a Copy… Guardar una copia… + {name} can't keep everything in this document {name} no puede conservar todo lo que tiene este documento + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Elija un formato que lo conserve todo, como Photoshop (PSD), o guarde una copia. Revert Volver Export Exportar Quick Export as PNG Exportación rápida como PNG @@ -107,6 +121,14 @@ Script Events Manager… Administrador de eventos de secuencias de comandos… Import Importar Browse… Examinar… + Source Files Archivos de origen + Use: Usar: + Files Archivos + Folder Carpeta + Add Open Files Añadir archivos abiertos + Sort By Name Ordenar por nombre + Attempt to Automatically Align Source Images Intentar alinear automáticamente las imágenes de origen + Create Smart Object after Loading Layers Crear objeto inteligente tras cargar las capas Reveal in Finder Mostrar en Finder Show in Explorer Mostrar en el Explorador Show in Folder Mostrar en la carpeta @@ -131,6 +153,8 @@ Search… Buscar… Check Spelling… Revisar ortografía… Find and Replace Text… Buscar y reemplazar texto… + Add to Dictionary Añadir al diccionario + No Spelling Suggestions Sin sugerencias ortográficas Fill… Rellenar… Stroke… Contornear… Content-Aware Fill… Relleno según el contenido… @@ -145,6 +169,8 @@ Transform Again on a Copy Transformar de nuevo una copia Scale Escalar Rotate Rotar + Set horizontal skew Definir sesgado horizontal + Set vertical skew Definir sesgado vertical Skew Sesgar Distort Distorsionar Perspective Perspectiva @@ -726,6 +752,10 @@ Breeze Dark Theme Tema Breeze oscuro Breeze light Breeze claro Breeze Light Theme Tema Breeze claro + Soft dark Suave oscuro + Soft light Suave claro + Soft Dark Theme Tema suave oscuro + Soft Light Theme Tema suave claro Next Theme Tema siguiente Actions Acciones Brush Settings Ajustes del pincel @@ -826,6 +856,9 @@ Appearance Apariencia Appearance (Layer Style) Apariencia (estilo de capa) Apply Layer Mask Aplicar máscara de capa + Add Mask to Selection Añadir máscara a la selección + Subtract Mask from Selection Restar máscara de la selección + Intersect Mask with Selection Intersecar máscara con la selección Apply Per Tip Aplicar por punta Apply next selected layer comp Aplicar la composición de capa seleccionada siguiente Apply previous selected layer comp Aplicar la composición de capa seleccionada anterior @@ -846,6 +879,12 @@ Baseline shift Desplazamiento de línea de base Basic Básico Before (Y) Antes (Y) + Save Settings… Guardar ajustes… + Load Settings… Cargar ajustes… + Save these settings as a Camera Raw .xmp file Guardar estos ajustes como archivo .xmp de Camera Raw + Load settings from a Camera Raw .xmp file Cargar ajustes desde un archivo .xmp de Camera Raw + Loaded Camera Raw settings from {file} Ajustes de Camera Raw cargados desde {file} + Loaded Camera Raw settings from {file} ({n} not supported) Ajustes de Camera Raw cargados desde {file} ({n} no compatibles) Begin recording Iniciar grabación Bend: Curvatura: Bicubic Bicúbica @@ -1148,6 +1187,9 @@ Ignore All Ignorar todo Image Size Tamaño de imagen Import Brushes… Importar pinceles… + Gradient Preset Options Opciones de ajustes preestablecidos de degradado + Import .GRD Importar .GRD + Choose a gradient file (.grd). Seleccione un archivo de degradados (.grd). Import brushes (.abr) Importar pinceles (.abr) Import Presets Importar ajustes preestablecidos Imported Importado @@ -2086,6 +2128,11 @@ Rename Brush… Cambiar el nombre del pincel… Delete Brush Eliminar pincel New Brush Preset… Nuevo ajuste preestablecido de pincel… + Capture Brush Size in Preset Capturar tamaño del pincel en el ajuste preestablecido + Include Tool Settings Incluir ajustes de herramienta + Include Color Incluir color + Enter a name for the brush preset Escriba un nombre para el ajuste preestablecido de pincel + A brush preset with this name already exists Ya existe un ajuste preestablecido de pincel con este nombre Brush Preset Options Opciones de ajustes preestablecidos de pincel Group name Nombre del grupo Brush name Nombre del pincel @@ -2391,6 +2438,7 @@ cameraRaw Amount Cantidad Click to pick · Esc to cancel Clic para elegir · Esc para cancelar Pick a screen pixel or press Esc to cancel Elija un píxel de la pantalla o pulse Esc para cancelar Scroll to zoom Desplace para ampliar + Drag to pan · Scroll to zoom · Double-click to fit Arrastre para desplazar · Desplace para ampliar · Doble clic para ajustar Show/Hide All Panels Mostrar/ocultar todos los paneles Show/Hide Panels Mostrar/ocultar paneles Could not open No se pudo abrir @@ -2730,9 +2778,14 @@ curves Smooth Suavizar Light Red Rojo claro Medium Blue Azul medio Light Gray Gris claro + Object Finder Buscador de objetos + No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) + Clear Brush Controls Borrar controles de pincel + Reset All Locked Settings Restaurar todos los ajustes bloqueados + Copy Texture to Other Tools Copiar textura en otras herramientas # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Cargar acciones… @@ -2754,3 +2807,5 @@ curves Smooth Suavizar Action playback stopped Reproducción de la acción detenida Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo @plural {n} action|{n} actions {n} acción|{n} acciones + Auto Add/Delete Añadir/eliminar automáticamente + ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4dd927500c..6974616118 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -19,7 +19,17 @@ # ArtCraft, OpenType, Lab ...) stay as they are; RGB/CMYK read RVB/CMJN; the trailing "…" of # dialog commands is kept. +# Channel Options and New Spot Channel (#3761) + Channel Color Couleur de la couche + Color Indicates: La couleur indique : + Solidity: Solidité : + Ink Characteristics Caractéristiques de l’encre + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Largeur du papier + Paper Height Hauteur du papier Rows Lignes Number Nombre Margin Marge @@ -75,6 +85,8 @@ Save Enregistrer Save As… Enregistrer sous… Save a Copy… Enregistrer une copie… + {name} can't keep everything in this document {name} ne peut pas conserver tout le contenu de ce document + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Choisissez un format qui conserve tout, comme Photoshop (PSD), ou enregistrez une copie. Revert Revenir à la version enregistrée Export Exporter Quick Export as PNG Exportation rapide au format PNG @@ -112,6 +124,14 @@ Script Events Manager… Gestionnaire d'événements de script… Import Importer Browse… Parcourir… + Source Files Fichiers source + Use: Utiliser : + Files Fichiers + Folder Dossier + Add Open Files Ajouter les fichiers ouverts + Sort By Name Trier par nom + Attempt to Automatically Align Source Images Tenter d'aligner automatiquement les images source + Create Smart Object after Loading Layers Créer un objet intelligent après le chargement des calques Reveal in Finder Afficher dans le Finder Show in Explorer Afficher dans l'Explorateur Show in Folder Afficher dans le dossier @@ -136,6 +156,8 @@ Search… Rechercher… Check Spelling… Vérifier l'orthographe… Find and Replace Text… Rechercher et remplacer du texte… + Add to Dictionary Ajouter au dictionnaire + No Spelling Suggestions Aucune suggestion orthographique Fill… Remplir… Stroke… Contourner… Content-Aware Fill… Remplissage d'après le contenu… @@ -148,6 +170,8 @@ Again Répéter Scale Mise à l'échelle Rotate Rotation + Set horizontal skew Définir l'inclinaison horizontale + Set vertical skew Définir l'inclinaison verticale Skew Inclinaison Distort Déformation Perspective Perspective @@ -716,6 +740,10 @@ Breeze Dark Theme Thème Breeze sombre Breeze light Breeze clair Breeze Light Theme Thème Breeze clair + Soft dark Doux sombre + Soft light Doux clair + Soft Dark Theme Thème doux sombre + Soft Light Theme Thème doux clair Next Theme Thème suivant Actions Actions Brush Settings Paramètres du pinceau @@ -814,6 +842,9 @@ Appearance Apparence Appearance (Layer Style) Apparence (style de calque) Apply Layer Mask Appliquer le masque de calque + Add Mask to Selection Ajouter le masque à la sélection + Subtract Mask from Selection Soustraire le masque de la sélection + Intersect Mask with Selection Intersection du masque et de la sélection Apply Per Tip Appliquer par pointe Apply next selected layer comp Appliquer la composition de calques sélectionnée suivante Apply previous selected layer comp Appliquer la composition de calques sélectionnée précédente @@ -834,6 +865,12 @@ Baseline shift Décalage de la ligne de base Basic De base Before (Y) Avant (Y) + Save Settings… Enregistrer les paramètres… + Load Settings… Charger les paramètres… + Save these settings as a Camera Raw .xmp file Enregistrer ces paramètres dans un fichier .xmp Camera Raw + Load settings from a Camera Raw .xmp file Charger les paramètres d’un fichier .xmp Camera Raw + Loaded Camera Raw settings from {file} Paramètres Camera Raw chargés depuis {file} + Loaded Camera Raw settings from {file} ({n} not supported) Paramètres Camera Raw chargés depuis {file} ({n} non pris en charge) Begin recording Démarrer l'enregistrement Bend: Courbure : Bicubic Bicubique @@ -1130,6 +1167,9 @@ Ignore All Tout ignorer Image Size Taille de l'image Import Brushes… Importer des pinceaux… + Gradient Preset Options Options des paramètres prédéfinis de dégradé + Import .GRD Importer .GRD + Choose a gradient file (.grd). Choisissez un fichier de dégradés (.grd). Import brushes (.abr) Importer des pinceaux (.abr) Import Presets Importer des préréglages Imported Importé @@ -2051,6 +2091,11 @@ Rename Brush… Renommer le pinceau… Delete Brush Supprimer le pinceau New Brush Preset… Nouveau préréglage de pinceau… + Capture Brush Size in Preset Capturer la taille du pinceau dans le préréglage + Include Tool Settings Inclure les paramètres de l'outil + Include Color Inclure la couleur + Enter a name for the brush preset Saisissez un nom pour le préréglage de pinceau + A brush preset with this name already exists Un préréglage de pinceau porte déjà ce nom Brush Preset Options Options des préréglages de pinceau Group name Nom du groupe Brush name Nom du pinceau @@ -2745,6 +2790,7 @@ cameraRaw Blending Fusion Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler Scroll to zoom Défiler pour zoomer + Drag to pan · Scroll to zoom · Double-click to fit Glisser pour déplacer · Défiler pour zoomer · Double-cliquer pour ajuster Show/Hide All Panels Afficher/masquer tous les panneaux Show/Hide Panels Afficher/masquer les panneaux Could not open Impossible d’ouvrir @@ -3050,9 +3096,14 @@ curves Smooth Lisser Light Red Rouge clair Medium Blue Bleu moyen Light Gray Gris clair + Object Finder Outil de recherche d’objets + No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + Clear Brush Controls Effacer les commandes de pinceau + Reset All Locked Settings Réinitialiser tous les paramètres verrouillés + Copy Texture to Other Tools Copier la texture dans d'autres outils # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Charger des actions… @@ -3074,3 +3125,5 @@ curves Smooth Lisser Action playback stopped Exécution de l’action arrêtée Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue @plural {n} action|{n} actions {n} action|{n} actions + Auto Add/Delete Ajout/suppr. auto + ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 151a997bfe..9aa29a7cb6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -17,6 +17,10 @@ # singular noun in Hungarian, so every @plural entry has a single form. # Plural messages and command id overrides +# Custom paper dimensions in Print Settings. + Paper Width Papír szélessége + Paper Height Papír magassága + @id edit.purge.undo Visszavonási adatok @id edit.stroke Körvonalazás… @id edit.transform.scale Méretezés @@ -27,6 +31,7 @@ @plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} ecset importálva. Forrás: {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} ecset importálva. Csoport: {group} @plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {n} színátmenet importálva. Forrás: {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {n} mintázat importálva. Forrás: {file} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} színminta importálva. Forrás: {file} @plural Played {n} step|Played {n} steps {n} lépés lejátszva @plural Saved for Web: {n} file|Saved for Web: {n} files Mentés webre: {n} fájl @@ -75,6 +80,13 @@ Black & White… Fekete-fehér… Brightness/Contrast… Fényerő/kontraszt… Browse… Tallózás… + Source Files Forrásfájlok + Use: Használat: + Files Fájlok + Add Open Files Megnyitott fájlok hozzáadása + Sort By Name Rendezés név szerint + Attempt to Automatically Align Source Images Forrásképek automatikus igazításának megkísérlése + Create Smart Object after Loading Layers Intelligens objektum létrehozása a rétegek betöltése után Calculations… Számítások… Canvas Size… Vászonméret… Channel Mixer… Csatornakeverő… @@ -126,6 +138,8 @@ File Info… Fájladatok… Fill… Kitöltés… Find and Replace Text… Szöveg keresése és cseréje… + Add to Dictionary Hozzáadás a szótárhoz + No Spelling Suggestions Nincs helyesírási javaslat Fit Image… Kép méretre igazítása… Flatten All Layer Effects Összes rétegeffektus egybesimítása Flatten All Masks Összes maszk egybesimítása @@ -197,11 +211,15 @@ Reveal All Minden megjelenítése Revert Visszaállítás Rotate Forgatás + Set horizontal skew Vízszintes döntés beállítása + Set vertical skew Függőleges döntés beállítása Rotate 180° Forgatás 180°-kal Rotate 90° Clockwise Forgatás 90°-kal az óramutató járása szerint Rotate 90° Counter Clockwise Forgatás 90°-kal az óramutató járásával szemben Save Mentés Save a Copy… Másolat mentése… + {name} can't keep everything in this document Nem őrizhető meg minden a dokumentumból ebben a formátumban: {name} + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Válassz olyan formátumot, amely mindent megőriz, például Photoshop (PSD), vagy ments másolatot. Save As… Mentés másként… Save for Web (Legacy)… Mentés webre (régi)… Scale Méretarány @@ -428,6 +446,9 @@ Appearance Megjelenés Apply Data Set Adatkészlet alkalmazása Apply Layer Mask Rétegmaszk alkalmazása + Add Mask to Selection Maszk hozzáadása a kijelöléshez + Subtract Mask from Selection Maszk kivonása a kijelölésből + Intersect Mask with Selection Maszk metszése a kijelöléssel Artboard Guides Rajztábla-segédvonalak Artboards to Files Rajztáblák fájlokba Artboards to PDF Rajztáblák PDF-be @@ -441,6 +462,10 @@ Box Blur… Dobozelmosás… Breeze Dark Theme Breeze sötét téma Breeze Light Theme Breeze világos téma + Soft dark Lágy sötét + Soft light Lágy világos + Soft Dark Theme Lágy sötét téma + Soft Light Theme Lágy világos téma Brush Preview Ecset előnézete Brush Settings Ecsetbeállítások Brushes Ecsetek @@ -1159,6 +1184,11 @@ New Brush Új ecset New Brush Preset Új ecset-előbeállítás New Brush Preset… Új ecset-előbeállítás… + Capture Brush Size in Preset Ecsetméret rögzítése az előbeállításban + Include Tool Settings Eszközbeállítások belefoglalása + Include Color Szín belefoglalása + Enter a name for the brush preset Adjon nevet az ecset-előbeállításnak + A brush preset with this name already exists Már van ilyen nevű ecset-előbeállítás New Crop Preset… Új körbevágási előbeállítás… New Tool Preset Új eszköz-előbeállítás No brush presets Nincs ecset-előbeállítás @@ -3394,6 +3424,7 @@ cameraRaw Yellow Sárga Photomerge Fotóegyesítés Pick a screen pixel or press Esc to cancel Válassz egy pixelt a képernyőn, vagy nyomd meg az Esc billentyűt a megszakításhoz Pick screen color Szín kiválasztása a képernyőről + Pick Color From Image Szín felvétele a képről Pins Tűk Pixel Layer Pixelréteg Place Scale Marker Méretarány-jelölő elhelyezése @@ -3972,6 +4003,9 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra + Clear Brush Controls Ecsetvezérlők törlése + Reset All Locked Settings Összes zárolt beállítás visszaállítása + Copy Texture to Other Tools Textúra másolása más eszközökre # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Műveletek betöltése… @@ -3993,3 +4027,23 @@ cameraRaw Yellow Sárga Action playback stopped A művelet lejátszása leállt Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár @plural {n} action|{n} actions {n} művelet + Object Finder Objektumkereső + No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot + Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás + Gradient Preset Options Színátmenet-előbeállítás beállításai + Import .GRD .GRD importálása + Choose a gradient file (.grd). Válasszon színátmenetfájlt (.grd). + Save Settings… Beállítások mentése… + Load Settings… Beállítások betöltése… + Save these settings as a Camera Raw .xmp file A beállítások mentése Camera Raw .xmp-fájlként + Load settings from a Camera Raw .xmp file Beállítások betöltése Camera Raw .xmp-fájlból + Loaded Camera Raw settings from {file} Camera Raw-beállítások betöltve innen: {file} + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-beállítások betöltve innen: {file} ({n} nem támogatott) + Auto Add/Delete Automatikus hozzáadás/törlés + ICO is limited to 256 × 256 pixels Az ICO legfeljebb 256 × 256 képpont lehet + +# Channel Options and New Spot Channel (#3761) + Channel Color Csatorna színe + Color Indicates: A szín jelzése: + Ink Characteristics Festék jellemzői + Solidity: Fedőképesség: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ea51c03775..8182765c4a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -12,7 +12,17 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Channel Options and New Spot Channel (#3761) + Channel Color Warna Saluran + Color Indicates: Warna Menunjukkan: + Solidity: Soliditas: + Ink Characteristics Karakteristik Tinta + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Lebar kertas + Paper Height Tinggi kertas Rows Baris Number Jumlah Margin Margin @@ -68,6 +78,8 @@ Save Simpan Save As… Simpan Sebagai… Save a Copy… Simpan Salinan… + {name} can't keep everything in this document {name} tidak dapat menyimpan semua isi dokumen ini + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Pilih format yang menyimpan semuanya, seperti Photoshop (PSD), atau simpan salinan. Revert Kembalikan Export Ekspor Quick Export as PNG Ekspor Cepat ke PNG @@ -105,6 +117,14 @@ Script Events Manager… Pengelola Event Skrip… Import Impor Browse… Telusuri… + Source Files File Sumber + Use: Gunakan: + Files File + Folder Folder + Add Open Files Tambahkan File Terbuka + Sort By Name Urutkan Menurut Nama + Attempt to Automatically Align Source Images Coba Sejajarkan Gambar Sumber Secara Otomatis + Create Smart Object after Loading Layers Buat Smart Object setelah Memuat Layer Reveal in Finder Tampilkan di Finder Show in Explorer Tampilkan di Explorer Show in Folder Tampilkan di Folder @@ -129,6 +149,8 @@ Search… Cari… Check Spelling… Periksa Ejaan… Find and Replace Text… Cari dan Ganti Teks… + Add to Dictionary Tambahkan ke Kamus + No Spelling Suggestions Tidak Ada Saran Ejaan Fill… Isi… Stroke… Stroke… Content-Aware Fill… Isi Content-Aware… @@ -141,6 +163,8 @@ Again Lagi Scale Skala Rotate Putar + Set horizontal skew Atur kemiringan horizontal + Set vertical skew Atur kemiringan vertikal Skew Miringkan Distort Distorsi Perspective Perspektif @@ -709,6 +733,10 @@ Breeze Dark Theme Tema Breeze Gelap Breeze light Breeze terang Breeze Light Theme Tema Breeze Terang + Soft dark Lembut gelap + Soft light Lembut terang + Soft Dark Theme Tema lembut gelap + Soft Light Theme Tema lembut terang Next Theme Tema Berikutnya Actions Action Brush Settings Pengaturan Kuas @@ -808,6 +836,9 @@ Appearance Tampilan Appearance (Layer Style) Tampilan (Gaya Layer) Apply Layer Mask Terapkan Mask Layer + Add Mask to Selection Tambahkan Mask ke Seleksi + Subtract Mask from Selection Kurangi Mask dari Seleksi + Intersect Mask with Selection Iriskan Mask dengan Seleksi Apply Per Tip Terapkan Per Ujung Apply next selected layer comp Terapkan layer comp terpilih berikutnya Apply previous selected layer comp Terapkan layer comp terpilih sebelumnya @@ -828,6 +859,12 @@ Baseline shift Geser baseline Basic Dasar Before (Y) Sebelum (Y) + Save Settings… Simpan Pengaturan… + Load Settings… Muat Pengaturan… + Save these settings as a Camera Raw .xmp file Simpan pengaturan ini sebagai berkas .xmp Camera Raw + Load settings from a Camera Raw .xmp file Muat pengaturan dari berkas .xmp Camera Raw + Loaded Camera Raw settings from {file} Pengaturan Camera Raw dimuat dari {file} + Loaded Camera Raw settings from {file} ({n} not supported) Pengaturan Camera Raw dimuat dari {file} ({n} tidak didukung) Begin recording Mulai merekam Bend: Lengkung: Bicubic Bicubic @@ -1124,6 +1161,9 @@ Ignore All Abaikan Semua Image Size Ukuran Gambar Import Brushes… Impor Kuas… + Gradient Preset Options Opsi Preset Gradien + Import .GRD Impor .GRD + Choose a gradient file (.grd). Pilih file gradien (.grd). Import brushes (.abr) Impor kuas (.abr) Import Presets Impor Preset Imported Diimpor @@ -2045,6 +2085,11 @@ Rename Brush… Ganti Nama Kuas… Delete Brush Hapus Kuas New Brush Preset… Preset Kuas Baru… + Capture Brush Size in Preset Simpan Ukuran Kuas di Preset + Include Tool Settings Sertakan Pengaturan Alat + Include Color Sertakan Warna + Enter a name for the brush preset Masukkan nama untuk preset kuas + A brush preset with this name already exists Preset kuas dengan nama ini sudah ada Brush Preset Options Opsi Preset Kuas Group name Nama grup Brush name Nama kuas @@ -2732,6 +2777,7 @@ cameraRaw Blending Pembauran Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan Scroll to zoom Gulir untuk memperbesar + Drag to pan · Scroll to zoom · Double-click to fit Seret untuk menggeser · Gulir untuk memperbesar · Klik dua kali untuk menyesuaikan Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel Show/Hide Panels Tampilkan/Sembunyikan Panel Could not open Tidak dapat membuka @@ -3037,9 +3083,14 @@ curves Smooth Haluskan Light Red Merah Terang Medium Blue Biru Sedang Light Gray Abu-abu Muda + Object Finder Pencari Objek + No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + Clear Brush Controls Hapus Kontrol Kuas + Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci + Copy Texture to Other Tools Salin Tekstur ke Alat Lain # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Muat Tindakan… @@ -3061,3 +3112,5 @@ curves Smooth Haluskan Action playback stopped Pemutaran tindakan dihentikan Step {step} is waiting for its dialog Langkah {step} menunggu dialognya @plural {n} action|{n} actions {n} tindakan + Auto Add/Delete Tambah/Hapus Otomatis + ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 060ac72fc6..08d8adcc14 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -12,7 +12,17 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing … of dialog commands is kept. +# Channel Options and New Spot Channel (#3761) + Channel Color Colore canale + Color Indicates: Il colore indica: + Solidity: Solidità: + Ink Characteristics Caratteristiche inchiostro + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Larghezza della carta + Paper Height Altezza della carta Columns Colonne Rows Righe Number Numero @@ -70,6 +80,8 @@ Save Salva Save As… Salva con nome… Save a Copy… Salva una copia… + {name} can't keep everything in this document {name} non può conservare tutto il contenuto di questo documento + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Scegli un formato che conservi tutto, come Photoshop (PSD), oppure salva una copia. Revert Ripristina Export Esporta Quick Export as PNG Esportazione rapida come PNG @@ -107,6 +119,13 @@ Script Events Manager… Gestore eventi script… Import Importa Browse… Sfoglia… + Source Files File di origine + Use: Usa: + Files File + Add Open Files Aggiungi file aperti + Sort By Name Ordina per nome + Attempt to Automatically Align Source Images Prova ad allineare automaticamente le immagini di origine + Create Smart Object after Loading Layers Crea oggetto avanzato dopo il caricamento dei livelli Reveal in Finder Mostra nel Finder Show in Explorer Mostra in Esplora file Show in Folder Mostra nella cartella @@ -131,6 +150,8 @@ Search… Cerca… Check Spelling… Controlla ortografia… Find and Replace Text… Trova e sostituisci testo… + Add to Dictionary Aggiungi al dizionario + No Spelling Suggestions Nessun suggerimento ortografico Fill… Riempi… Stroke… Traccia… Content-Aware Fill… Riempimento in base al contenuto… @@ -145,6 +166,8 @@ Transform Again on a Copy Trasforma di nuovo su una copia Scale Scala Rotate Ruota + Set horizontal skew Imposta inclinazione orizzontale + Set vertical skew Imposta inclinazione verticale Skew Inclina Distort Distorci Perspective Prospettiva @@ -724,6 +747,10 @@ Breeze Dark Theme Tema Breeze scuro Breeze light Breeze chiaro Breeze Light Theme Tema Breeze chiaro + Soft dark Morbido scuro + Soft light Morbido chiaro + Soft Dark Theme Tema morbido scuro + Soft Light Theme Tema morbido chiaro Next Theme Tema successivo Actions Azioni Brush Settings Impostazioni pennello @@ -824,6 +851,9 @@ Appearance Aspetto Appearance (Layer Style) Aspetto (stile livello) Apply Layer Mask Applica maschera di livello + Add Mask to Selection Aggiungi maschera alla selezione + Subtract Mask from Selection Sottrai maschera dalla selezione + Intersect Mask with Selection Interseca maschera con la selezione Apply Per Tip Applica per punta Apply next selected layer comp Applica composizione livelli successiva selezionata Apply previous selected layer comp Applica composizione livelli precedente selezionata @@ -844,6 +874,12 @@ Baseline shift Scostamento linea base Basic Base Before (Y) Prima (Y) + Save Settings… Salva impostazioni… + Load Settings… Carica impostazioni… + Save these settings as a Camera Raw .xmp file Salva queste impostazioni come file .xmp di Camera Raw + Load settings from a Camera Raw .xmp file Carica le impostazioni da un file .xmp di Camera Raw + Loaded Camera Raw settings from {file} Impostazioni Camera Raw caricate da {file} + Loaded Camera Raw settings from {file} ({n} not supported) Impostazioni Camera Raw caricate da {file} ({n} non supportate) Begin recording Inizia registrazione Bend: Curvatura: Bicubic Bicubica @@ -1147,6 +1183,9 @@ Ignore All Ignora tutto Image Size Dimensione immagine Import Brushes… Importa pennelli… + Gradient Preset Options Opzioni predefiniti sfumatura + Import .GRD Importa .GRD + Choose a gradient file (.grd). Scegli un file di sfumature (.grd). Import brushes (.abr) Importa pennelli (.abr) Import Presets Importa predefiniti Imported Importato @@ -2084,6 +2123,11 @@ Rename Brush… Rinomina pennello… Delete Brush Elimina pennello New Brush Preset… Nuovo predefinito pennello… + Capture Brush Size in Preset Acquisisci dimensione pennello nel predefinito + Include Tool Settings Includi impostazioni strumento + Include Color Includi colore + Enter a name for the brush preset Immetti un nome per il predefinito pennello + A brush preset with this name already exists Esiste già un predefinito pennello con questo nome Brush Preset Options Opzioni predefiniti pennello Group name Nome gruppo Brush name Nome pennello @@ -2395,6 +2439,7 @@ cameraRaw Amount Quantità Click to pick · Esc to cancel Clic per scegliere · Esc per annullare Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare Scroll to zoom Scorri per ingrandire + Drag to pan · Scroll to zoom · Double-click to fit Trascina per spostare · Scorri per ingrandire · Doppio clic per adattare Show/Hide All Panels Mostra/nascondi tutti i pannelli Show/Hide Panels Mostra/nascondi pannelli Could not open Impossibile aprire @@ -4002,6 +4047,7 @@ layerLabel No Color Nessun colore gradients sfumature @plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} pennello importato da {file}|{n} pennelli importati da {file} @plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {n} sfumatura importata da {file}|{n} sfumature importate da {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {n} pattern importato da {file}|{n} pattern importati da {file} Data Set: {name} ({index}/{total}) Set di dati: {name} ({index}/{total}) (none) (nessuno) navigator Zoom Zoom @@ -4013,9 +4059,14 @@ navigator Zoom Zoom {index} of {total} {index} di {total} {shortcut} assigned. {shortcut} assegnata. {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. + Object Finder Ricerca oggetti + No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + Clear Brush Controls Cancella controlli pennello + Reset All Locked Settings Ripristina tutte le impostazioni bloccate + Copy Texture to Other Tools Copia texture su altri strumenti # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carica azioni… @@ -4037,3 +4088,5 @@ navigator Zoom Zoom Action playback stopped Esecuzione dell’azione interrotta Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo @plural {n} action|{n} actions {n} azione|{n} azioni + Auto Add/Delete Aggiungi/elimina automaticamente + ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 40749c2d0b..8e878c2c21 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -15,7 +15,17 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Channel Options and New Spot Channel (#3761) + Channel Color チャンネルカラー + Color Indicates: 着色表示: + Solidity: 濃度: + Ink Characteristics インキの特性 + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 用紙の幅 + Paper Height 用紙の高さ Rows 行 Number 数 Top: 上: @@ -71,6 +81,8 @@ Save 保存 Save As… 名前を付けて保存… Save a Copy… コピーを保存… + {name} can't keep everything in this document {name} にはこのドキュメントの内容をすべて保存できません + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Photoshop (PSD) などすべてを保持できる形式を選ぶか、コピーを保存してください。 Revert 復帰 Export 書き出し Quick Export as PNG PNG としてクイック書き出し @@ -108,6 +120,14 @@ Script Events Manager… スクリプトイベント管理… Import 読み込み Browse… 参照… + Source Files ソースファイル + Use: 使用: + Files ファイル + Folder フォルダー + Add Open Files 開いているファイルを追加 + Sort By Name 名前順に並べ替え + Attempt to Automatically Align Source Images ソース画像の自動整列を試す + Create Smart Object after Loading Layers レイヤーの読み込み後にスマートオブジェクトを作成 Reveal in Finder Finder で表示 Show in Explorer エクスプローラーで表示 Show in Folder フォルダーで表示 @@ -132,6 +152,8 @@ Search… 検索… Check Spelling… スペルチェック… Find and Replace Text… テキストを検索して置換… + Add to Dictionary 辞書に追加 + No Spelling Suggestions スペルの候補はありません Fill… 塗りつぶし… Stroke… 境界線を描く… Content-Aware Fill… コンテンツに応じた塗りつぶし… @@ -146,6 +168,8 @@ Transform Again on a Copy 複製してもう一度変形 Scale 拡大・縮小 Rotate 回転 + Set horizontal skew 水平方向の斜体を設定 + Set vertical skew 垂直方向の斜体を設定 Skew 斜体 Distort ゆがみ Perspective 遠近法 @@ -301,7 +325,7 @@ Rename Layer レイヤー名を変更 Create Layer レイヤーを作成 Layer Style レイヤースタイル - Blending Options… レイヤー効果の描画オプション… + Blending Options… レイヤー効果… Bevel & Emboss… ベベルとエンボス… Use Global Light グローバルライトを使用 Inner Shadow… シャドウ (内側)… @@ -726,6 +750,10 @@ Breeze Dark Theme Breeze ダークテーマ Breeze light Breeze ライト Breeze Light Theme Breeze ライトテーマ + Soft dark ソフトダーク + Soft light ソフトライト + Soft Dark Theme ソフトダークテーマ + Soft Light Theme ソフトライトテーマ Next Theme 次のテーマ Actions アクション Brush Settings ブラシ設定 @@ -827,6 +855,9 @@ Appearance 外観 Appearance (Layer Style) 外観 (レイヤースタイル) Apply Layer Mask レイヤーマスクを適用 + Add Mask to Selection マスクを選択範囲に追加 + Subtract Mask from Selection 選択範囲からマスクを削除 + Intersect Mask with Selection 選択範囲とマスクの共通範囲 Apply Per Tip 先端ごとに適用 Apply next selected layer comp 次のレイヤーカンプを適用 Apply previous selected layer comp 前のレイヤーカンプを適用 @@ -847,6 +878,12 @@ Baseline shift ベースラインシフト Basic 基本 Before (Y) 補正前(Y) + Save Settings… 設定を保存… + Load Settings… 設定を読み込み… + Save these settings as a Camera Raw .xmp file この設定を Camera Raw の .xmp ファイルとして保存 + Load settings from a Camera Raw .xmp file Camera Raw の .xmp ファイルから設定を読み込む + Loaded Camera Raw settings from {file} {file} から Camera Raw の設定を読み込みました + Loaded Camera Raw settings from {file} ({n} not supported) {file} から Camera Raw の設定を読み込みました(未対応: {n}) Begin recording 記録を開始 Bend: 曲げ: Bicubic バイキュービック @@ -861,7 +898,7 @@ Black, White 黒、白 Blacks ブラック Blending 合成 - Blending Options 描画オプション + Blending Options レイヤー効果 Blue 青 Blue, Red, Yellow 青、赤、黄 Both Axes 両方の軸 @@ -1149,6 +1186,9 @@ Ignore All すべて無視 Image Size 画像サイズ Import Brushes… ブラシを読み込み… + Gradient Preset Options グラデーションプリセットのオプション + Import .GRD .GRD を読み込み + Choose a gradient file (.grd). グラデーションファイル (.grd) を選択してください。 Import brushes (.abr) ブラシ (.abr) を読み込み Import Presets プリセットを読み込み Imported 読み込み済み @@ -1472,7 +1512,7 @@ Straighten Layer レイヤーの角度補正 Strength: 強さ: Strikethrough 取り消し線 - Stroke 線 + Stroke 境界線 Stroke Catch-up ストロークの追従 Stroke: 線: Location: 位置: @@ -1703,7 +1743,7 @@ Original Ratio 元の縦横比 Out Of Gamut 色域外 Outer Bevel 外側ベベル - Outer Glow 光彩(外側) + Outer Glow 光彩 (外側) Overlay オーバーレイ Paint Bucket Tool 塗りつぶしツール Paints from the document's opening state ドキュメントを開いた時点の状態から描画します @@ -2085,6 +2125,11 @@ Rename Brush… ブラシの名前を変更… Delete Brush ブラシを削除 New Brush Preset… 新規ブラシプリセット… + Capture Brush Size in Preset ブラシサイズをプリセットに保存 + Include Tool Settings ツール設定を含める + Include Color カラーを含める + Enter a name for the brush preset ブラシプリセットの名前を入力してください + A brush preset with this name already exists この名前のブラシプリセットは既に存在します Brush Preset Options ブラシプリセットのオプション Group name グループ名 Brush name ブラシ名 @@ -3602,10 +3647,10 @@ curves Smooth スムーズ Smooth the curve values カーブの値を滑らかにする Drag to draw the curve · {key}-click draws a straight line ドラッグしてカーブを描画 · {key}+クリックで直線を描画 Grid Size: グリッドサイズ: - Vanishing Point: click four corners to create a plane 消点:4 つの角をクリックして面を作成します + Vanishing Point: click four corners to create a plane 消点: 4 つの角をクリックして面を作成します Vanishing Point is not active 消点はアクティブではありません Apply Vanishing Point ({key}) 消点を適用({key}) - Fix the red plane first: its corners must form a convex quad 先に赤い面を修正してください:角が凸四角形になる必要があります + Fix the red plane first: its corners must form a convex quad 先に赤い面を修正してください: 角が凸四角形になる必要があります # Select and Mask view modes (#1754) Onion Skin オニオンスキン Marching Ants 点線の境界 @@ -3652,16 +3697,18 @@ curves Smooth スムーズ @plural {n} sample point removed|{n} sample points removed サンプルポイント {n} 個を削除しました @plural {n} slice|{n} slices {n} 個のスライス @plural {n} step|{n} steps {n} ステップ -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(ほかにブラシプリセットの警告 {n} 件) -@plural {what} with {n} warning|{what} with {n} warnings {what}(警告 {n} 件) +@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 ライトグレー + Object Finder オブジェクトファインダー + No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) - Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n} (Enter で適用、Esc で取り消し) # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… アクションを読み込み… @@ -3683,3 +3730,76 @@ curves Smooth スムーズ Action playback stopped アクションの再生を停止しました Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 + +# Liquify tool names (shown via tl!(tool.label()) in the UI, so they are not found by the static scan) + Forward Warp 前方ワープ + Twirl Clockwise 時計回りに渦巻き + Twirl Counterclockwise 反時計回りに渦巻き + Pucker 収縮 + Bloat 膨張 + Push Left ピクセル移動 + Freeze Mask ブラシでマスクを描画 + Thaw Mask ブラシでマスクを消去 + +# Panel menu items and section labels passed to tl!() as variables (channels panel, paths panel, layer style) + Convert to Alpha Channel アルファチャンネルに変換 + Delete Channel チャンネルを削除 + Duplicate Channel チャンネルを複製 + Exit Quick Mask クイックマスクを終了 + Merge Channels… チャンネルを結合… + Merge Spot Channel スポットチャンネルを結合 + New Channel… 新規チャンネル… + New Spot Channel… 新規スポットチャンネル… + Rename Channel… チャンネル名を変更… + Split Channels チャンネルを分割 + Save Path パスを保存 + General Blending 通常の描画 + +# Character panel tooltip (shown once the tooltip helpers in type_tool.rs pass their text through tl!) + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) カーニング: メトリクス、オプティカル、または 1/1000 em 単位の値 (キャレット位置で Alt+←/→) + +# Glyphs panel "Show" categories (text/src/glyphs.rs; the dropdown passes them through tl!) + Basic Latin 基本ラテン文字 + Latin 1 ラテン 1 + Latin Extended-A ラテン拡張 A + Latin Extended-B ラテン拡張 B + Greek ギリシャ文字 + Cyrillic キリル文字 + Numbers 数字 + Currency 通貨記号 + Math Symbols 数学記号 + Arrows 矢印 + +# Dialog field labels generated from parameter names (Warp Text, Trim, Calculations, Apply Image; not seen by the tl! scanner) + Bend 曲げ + Horizontal Distortion 水平方向のゆがみ + Vertical Distortion 垂直方向のゆがみ + Based On 基準 + Source Channel ソースのチャンネル + Source Invert ソースを反転 + Mask Channel マスクのチャンネル + Source1 Channel ソース 1 のチャンネル + Source1 Invert ソース 1 を反転 + Source2 Channel ソース 2 のチャンネル + Source2 Invert ソース 2 を反転 + R g b RGB + +# Canvas background menu, Blend If labels and Advanced Blending options + Dark Gray ダークグレー + Medium Gray ミディアムグレー + Select Custom Color… カスタムカラーを選択… + This Layer: 現在のレイヤー: + Underlying Layer: 下のレイヤー: + Blend Interior Effects as Group 内部効果をまとめて描画 + Blend Clipped Layers as Group クリップされたレイヤーをまとめて描画 + Transparency Shapes Layer 透明シェイプレイヤー + Layer Mask Hides Effects レイヤーマスクで効果を隠す + Vector Mask Hides Effects ベクトルマスクで効果を隠す + Auto Add/Delete 自動追加・削除 + +# Restored rows (custom print paper #3820, brush presets, Vanishing Point help, brush panel menu) + Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 + Clear Brush Controls ブラシのコントロールを消去 + Reset All Locked Settings ロックされた設定をすべて初期化 + Copy Texture to Other Tools テクスチャを他のツールにコピー + ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a93a20be59..b7fe66cbb5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7,7 +7,20 @@ # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. # Product names and technology names retain their spelling. +# Channel Options and New Spot Channel (#3761) + Channel Color 채널 색상 + Color Indicates: 색상 표시 내용: + Solidity: 견고도: + Masked Areas 마스크 영역 + Selected Areas 선택 영역 + Spot Color 별색 + Ink Characteristics 잉크 특성 + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 용지 너비 + Paper Height 용지 높이 Columns 열 Rows 행 Number 개수 @@ -60,6 +73,8 @@ Save 저장 Save As… 다른 이름으로 저장… Save a Copy… 사본 저장… + {name} can't keep everything in this document {name}에는 이 문서의 모든 내용을 저장할 수 없습니다 + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Photoshop(PSD)처럼 모든 내용을 유지하는 형식을 선택하거나 사본을 저장하세요. Revert 되돌리기 Export 내보내기 Quick Export as PNG PNG로 빠르게 내보내기 @@ -97,6 +112,14 @@ Script Events Manager… 스크립트 이벤트 관리자… Import 가져오기 Browse… 찾아보기… + Source Files 소스 파일 + Use: 사용: + Files 파일 + Folder 폴더 + Add Open Files 열린 파일 추가 + Sort By Name 이름순 정렬 + Attempt to Automatically Align Source Images 소스 이미지 자동 정렬 시도 + Create Smart Object after Loading Layers 레이어를 불러온 후 스마트 오브젝트 만들기 Reveal in Finder Finder에서 보기 Show in Explorer 파일 탐색기에서 보기 Show in Folder 폴더에서 보기 @@ -121,6 +144,8 @@ Search… 검색… Check Spelling… 맞춤법 검사… Find and Replace Text… 텍스트 찾기 및 바꾸기… + Add to Dictionary 사전에 추가 + No Spelling Suggestions 맞춤법 제안 없음 Fill… 채우기… Stroke… 획… Content-Aware Fill… 내용 인식 채우기… @@ -135,6 +160,8 @@ Transform Again on a Copy 사본에 변형 다시 적용 Scale 크기 조절 Rotate 회전 + Set horizontal skew 가로 기울이기 설정 + Set vertical skew 세로 기울이기 설정 Skew 기울이기 Distort 왜곡 Perspective 원근 @@ -700,6 +727,10 @@ Breeze Dark Theme Breeze 어두운 테마 Breeze light Breeze 밝게 Breeze Light Theme Breeze 밝은 테마 + Soft dark 소프트 다크 + Soft light 소프트 라이트 + Soft Dark Theme 소프트 다크 테마 + Soft Light Theme 소프트 라이트 테마 Next Theme 다음 테마 Actions 액션 Brush Settings 브러시 설정 @@ -799,6 +830,9 @@ Appearance 모양새 Appearance (Layer Style) 모양새(레이어 스타일) Apply Layer Mask 레이어 마스크 적용 + Add Mask to Selection 선택 영역에 마스크 추가 + Subtract Mask from Selection 선택 영역에서 마스크 빼기 + Intersect Mask with Selection 마스크와 선택 영역 교차 Apply Per Tip 끝당 적용 Apply next selected layer comp 선택한 다음 레이어 구성 요소 적용 Apply previous selected layer comp 선택한 이전 레이어 구성 요소 적용 @@ -819,6 +853,12 @@ Baseline shift 기준선 이동 Basic 기본 Before (Y) 이전 (Y) + Save Settings… 설정 저장… + Load Settings… 설정 불러오기… + Save these settings as a Camera Raw .xmp file 이 설정을 Camera Raw .xmp 파일로 저장 + Load settings from a Camera Raw .xmp file Camera Raw .xmp 파일에서 설정 불러오기 + Loaded Camera Raw settings from {file} {file}에서 Camera Raw 설정을 불러왔습니다 + Loaded Camera Raw settings from {file} ({n} not supported) {file}에서 Camera Raw 설정을 불러왔습니다 (미지원 {n}개) Begin recording 기록 시작 Bend: 구부림: Bicubic 쌍입방 @@ -1118,6 +1158,9 @@ Ignore All 모두 무시 Image Size 이미지 크기 Import Brushes… 브러시 가져오기… + Gradient Preset Options 그레이디언트 사전 설정 옵션 + Import .GRD .GRD 가져오기 + Choose a gradient file (.grd). 그레이디언트 파일 (.grd)을 선택하세요. Import brushes (.abr) 브러시 가져오기(.abr) Import Presets 사전 설정 가져오기 Imported 가져옴 @@ -2042,6 +2085,11 @@ Rename Brush… 브러시 이름 바꾸기… Delete Brush 브러시 삭제 New Brush Preset… 새 브러시 사전 설정… + Capture Brush Size in Preset 사전 설정에 브러시 크기 캡처 + Include Tool Settings 도구 설정 포함 + Include Color 색상 포함 + Enter a name for the brush preset 브러시 사전 설정의 이름을 입력하십시오 + A brush preset with this name already exists 이 이름의 브러시 사전 설정이 이미 있습니다 Brush Preset Options 브러시 사전 설정 옵션 Group name 그룹 이름 Brush name 브러시 이름 @@ -2908,6 +2956,7 @@ cameraRaw Color 색상 Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소 Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소 Scroll to zoom 스크롤하여 확대/축소 + Drag to pan · Scroll to zoom · Double-click to fit 드래그하여 이동 · 스크롤하여 확대/축소 · 두 번 클릭하여 맞추기 Show/Hide All Panels 모든 패널 표시/숨기기 Show/Hide Panels 패널 표시/숨기기 Could not open 열 수 없음 @@ -3219,9 +3268,14 @@ curves Smooth 매끄럽게 Light Red 연한 빨강 Medium Blue 중간 파랑 Light Gray 연한 회색 + Object Finder 개체 찾기 + No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Clear Brush Controls 브러시 제어 지우기 + Reset All Locked Settings 모든 잠긴 설정 재설정 + Copy Texture to Other Tools 다른 도구로 텍스처 복사 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 액션 불러오기… @@ -3243,3 +3297,5 @@ curves Smooth 매끄럽게 Action playback stopped 액션 재생이 중지되었습니다 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 + Auto Add/Delete 자동 추가/삭제 + ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 6dcec32e92..6ab8b340ad 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -20,6 +20,7 @@ fn requested_languages_resolve_regional_preferences() { ("fr-CA", "fr"), ("id-ID", "id"), ("vi-VN", "vi"), + ("sr-RS", "sr"), ] { assert_eq!(Lang::from_pref(tag), lang(code), "{tag}"); } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index bdf4ef5565..2fdffdfc69 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 20] = [ +pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -198,6 +198,10 @@ pub static LANGUAGES: [LangInfo; 20] = [ LangInfo { code: "ru", name: "Русский", source: catalog!("ru"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, // Ukrainian has the same one/few/many rule for integer counts. LangInfo { code: "uk", name: "Українська", source: catalog!("uk"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, + // Serbian (Latin script, Ekavian) has the same one/few/many rule for integer counts. `sr-RS`, + // `sr-Latn-*` and `sr_RS@latin` resolve here; so do Cyrillic `sr` locales, which read Latin + // rather than English. + LangInfo { code: "sr", name: "Srpski", source: catalog!("sr"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "cs", name: "Čeština", source: catalog!("cs"), plural: plural_cs, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "fr", name: "Français", source: catalog!("fr"), plural: plural_fr, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "id", name: "Bahasa Indonesia", source: catalog!("id"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -622,6 +626,94 @@ mod tests { assert_eq!(trn(ru(), 111, "{n} item", "{n} items"), "111 элементов"); } + #[test] + fn russian_status_messages_agree_with_counts() { + let ru = Lang::from_code("ru").expect("ru registered"); + let cases = [ + ( + "Exported {n} swatch to {file}", + "Exported {n} swatches to {file}", + ["Экспортирован {n} образец в {file}", "Экспортированы {n} образца в {file}", "Экспортировано {n} образцов в {file}"], + ), + ( + "Imported {n} brush from {group}", + "Imported {n} brushes from {group}", + ["Импортирована {n} кисть из {group}", "Импортированы {n} кисти из {group}", "Импортировано {n} кистей из {group}"], + ), + ( + "Imported {n} swatch from {file}", + "Imported {n} swatches from {file}", + ["Импортирован {n} образец из {file}", "Импортированы {n} образца из {file}", "Импортировано {n} образцов из {file}"], + ), + ("Played {n} step", "Played {n} steps", ["Воспроизведён {n} шаг", "Воспроизведены {n} шага", "Воспроизведено {n} шагов"]), + ( + "Updated {n} linked smart object changed on disk", + "Updated {n} linked smart objects changed on disk", + [ + "Обновлён {n} связанный смарт-объект, изменённый на диске", + "Обновлены {n} связанных смарт-объекта, изменённых на диске", + "Обновлено {n} связанных смарт-объектов, изменённых на диске", + ], + ), + ( + "{n} layer recorded by this comp was deleted. Update the comp, or clear the warning.", + "{n} layers recorded by this comp were deleted. Update the comp, or clear the warning.", + [ + "Удалён {n} слой, записанный в этом варианте. Обновите вариант или сбросьте предупреждение.", + "Удалены {n} слоя, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.", + "Удалено {n} слоёв, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.", + ], + ), + ("{n} sample point removed", "{n} sample points removed", ["Удалена {n} точка замера", "Удалены {n} точки замера", "Удалено {n} точек замера"]), + ( + "Loaded {n} action from {file}", + "Loaded {n} actions from {file}", + ["Загружена {n} операция из {file}", "Загружены {n} операции из {file}", "Загружено {n} операций из {file}"], + ), + ( + "Saved {n} action to {file}", + "Saved {n} actions to {file}", + ["Сохранена {n} операция в {file}", "Сохранены {n} операции в {file}", "Сохранено {n} операций в {file}"], + ), + ( + "{n} step with no Photoshop equivalent left out", + "{n} steps with no Photoshop equivalent left out", + [ + "Пропущен {n} шаг без эквивалента в Photoshop", + "Пропущены {n} шага без эквивалента в Photoshop", + "Пропущено {n} шагов без эквивалента в Photoshop", + ], + ), + ]; + // Include zero, teens, and compound counts as well as each basic form. + for (n, form) in [(0, 2), (1, 0), (2, 1), (3, 1), (4, 1), (5, 2), (11, 2), (12, 2), (14, 2), (21, 0), (22, 1), (25, 2), (101, 0), (112, 2)] { + for (one, other, forms) in cases { + let expected = forms[form].replace("{n}", &n.to_string()); + assert_eq!(trn(ru, n, one, other), expected, "{one}, n={n}"); + } + } + assert_eq!( + fmt(&trn(ru, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"), &[("group", "Кисти")]), + "Импортированы 2 кисти из Кисти" + ); + assert_eq!( + fmt(&trn(ru, 22, "Loaded {n} action from {file}", "Loaded {n} actions from {file}"), &[("file", "actions.atn")]), + "Загружены 22 операции из actions.atn" + ); + } + + #[test] + fn russian_font_weights_and_reviewed_labels() { + let ru = Lang::from_code("ru").expect("ru registered"); + assert_eq!(tr_ctx(ru, "fontWeight", "ExtraLight"), "Сверхтонкий"); + assert_eq!(tr_ctx(ru, "fontWeight", "Light"), "Лёгкий"); + assert_eq!(tr_ctx(ru, "fontWeight", "ExtraBold"), "Сверхжирный"); + assert_eq!(tr_ctx(ru, "fontWeight", "Black"), "Сверхжирный (Black)"); + assert_eq!(tr(ru, "Delete and Fill Selection"), "Удалить и залить выделение"); + assert_eq!(tr(ru, "Discard the embedded profile (don't color manage)"), "Не использовать встроенный профиль (без управления цветом)"); + assert_eq!(tr(ru, "No object found there — drag a rectangle around it"), "Объект не найден — обведите его прямоугольником"); + } + #[test] fn ukrainian_resolves_locales_and_preferences() { let uk = Lang::from_code("uk").expect("uk registered"); @@ -680,6 +772,66 @@ mod tests { } } + #[test] + fn serbian_resolves_locales_and_preferences() { + let sr = Lang::from_code("sr").expect("sr registered"); + assert_eq!(sr.name(), "Srpski"); + assert!(sr.complete_menus()); + for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] { + assert_eq!(lang_from_tag(tag), Some(sr), "{tag}"); + assert_eq!(Lang::from_pref(tag), sr, "{tag}"); + } + assert_eq!(tr(sr, "File"), "Datoteka"); + assert_eq!(tr(sr, "Layer"), "Sloj"); + assert_eq!(tr(sr, "New document…"), "Novi dokument…"); + assert_eq!(tr_id(sr, "select.all", "All"), "Izaberi sve", "an id override wins over the plain label"); + assert_eq!(tr(sr, "All"), "Sve"); + assert_eq!(tr_ctx(sr, "cameraRaw", "Light"), "Svetlo"); + assert_eq!(tr_ctx(sr, "fontWeight", "Light"), "Lagano"); + assert_eq!(fmt(tr(sr, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Camera Raw filter (Background)"); + for key in ["Alt", "⌥"] { + assert_eq!( + fmt(tr(sr, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), + format!("Dodaj masku (iz selekcije; {key} invertuje)") + ); + } + } + + #[test] + fn serbian_plural_messages_handle_teens_and_compound_counts() { + let sr = Lang::from_code("sr").expect("sr registered"); + for (n, item, layer) in [ + (0, "stavki", "slojeva"), + (1, "stavka", "sloj"), + (2, "stavke", "sloja"), + (4, "stavke", "sloja"), + (5, "stavki", "slojeva"), + (11, "stavki", "slojeva"), + (12, "stavki", "slojeva"), + (14, "stavki", "slojeva"), + (21, "stavka", "sloj"), + (22, "stavke", "sloja"), + (25, "stavki", "slojeva"), + (101, "stavka", "sloj"), + (111, "stavki", "slojeva"), + (112, "stavki", "slojeva"), + (122, "stavke", "sloja"), + ] { + assert_eq!(trn(sr, n, "{n} item", "{n} items"), format!("{n} {item}")); + assert_eq!(trn(sr, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); + assert_eq!(trn(sr, n, "Group · {n} layer", "Group · {n} layers"), format!("Grupa · {n} {layer}")); + } + for (n, expected) in [ + (1, "Uklonjena je 1 tačka uzorkovanja"), + (3, "Uklonjene su 3 tačke uzorkovanja"), + (5, "Uklonjeno je 5 tačaka uzorkovanja"), + (13, "Uklonjeno je 13 tačaka uzorkovanja"), + (21, "Uklonjena je 21 tačka uzorkovanja"), + ] { + assert_eq!(trn(sr, n, "{n} sample point removed", "{n} sample points removed"), expected); + } + } + #[test] fn catalog_kinds_are_parsed_and_looked_up() { let c = Catalog::parse("# c\n\tHello\tこんにちは\n@id\tfile.save\t保存する\nmenu\tWindows\tウィンドウ群\n@plural\t{n} file|{n} files\t{n} 個\n\n"); diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fc5809033c..9ffc6e89b8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -8,7 +8,17 @@ # stay as they are; the trailing "…" of dialog commands is kept. UI text addresses the user # informally ("je") and uses sentence case. +# Channel Options and New Spot Channel (#3761) + Channel Color Kanaalkleur + Color Indicates: Kleur geeft aan: + Solidity: Soliditeit: + Ink Characteristics Inktkenmerken + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papierbreedte + Paper Height Papierhoogte Rows Rijen Number Aantal Top: Boven: @@ -63,6 +73,8 @@ Save Opslaan Save As… Opslaan als… Save a Copy… Kopie opslaan… + {name} can't keep everything in this document {name} kan niet alles in dit document bewaren + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Kies een indeling die alles bewaart, zoals Photoshop (PSD), of sla een kopie op. Revert Terugzetten Export Exporteren Quick Export as PNG Snel exporteren als PNG @@ -100,6 +112,13 @@ Script Events Manager… Beheer scriptgebeurtenissen… Import Importeren Browse… Bladeren… + Source Files Bronbestanden + Use: Gebruiken: + Files Bestanden + Add Open Files Geopende bestanden toevoegen + Sort By Name Sorteren op naam + Attempt to Automatically Align Source Images Proberen bronafbeeldingen automatisch uit te lijnen + Create Smart Object after Loading Layers Slim object maken na laden van lagen Reveal in Finder Weergeven in Finder Show in Explorer Weergeven in Verkenner Show in Folder Weergeven in map @@ -124,6 +143,8 @@ Search… Zoeken… Check Spelling… Spelling controleren… Find and Replace Text… Tekst zoeken en vervangen… + Add to Dictionary Aan woordenboek toevoegen + No Spelling Suggestions Geen spellingsuggesties Fill… Vullen… Stroke… Omlijnen… Content-Aware Fill… Inhoudsbewust vullen… @@ -138,6 +159,8 @@ Transform Again on a Copy Opnieuw transformeren op een kopie Scale Schalen Rotate Roteren + Set horizontal skew Horizontale schuinte instellen + Set vertical skew Verticale schuinte instellen Skew Schuintrekken Distort Vervormen Perspective Perspectief @@ -705,6 +728,10 @@ Breeze Dark Theme Breeze-thema donker Breeze light Breeze licht Breeze Light Theme Breeze-thema licht + Soft dark Zacht donker + Soft light Zacht licht + Soft Dark Theme Zacht donker thema + Soft Light Theme Zacht licht thema Adwaita Light Theme Adwaita-thema licht Adwaita Dark Theme Adwaita-thema donker Adwaita Adwaita @@ -807,6 +834,9 @@ Appearance Weergave Appearance (Layer Style) Weergave (laagstijl) Apply Layer Mask Laagmasker toepassen + Add Mask to Selection Masker aan selectie toevoegen + Subtract Mask from Selection Masker van selectie aftrekken + Intersect Mask with Selection Masker met selectie laten snijden Apply Per Tip Per punt toepassen Apply next selected layer comp Volgende geselecteerde laagcompositie toepassen Apply previous selected layer comp Vorige geselecteerde laagcompositie toepassen @@ -827,6 +857,12 @@ Baseline shift Basislijnverschuiving Basic Basis Before (Y) Voor (Y) + Save Settings… Instellingen opslaan… + Load Settings… Instellingen laden… + Save these settings as a Camera Raw .xmp file Deze instellingen opslaan als Camera Raw-.xmp-bestand + Load settings from a Camera Raw .xmp file Instellingen laden uit een Camera Raw-.xmp-bestand + Loaded Camera Raw settings from {file} Camera Raw-instellingen geladen uit {file} + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-instellingen geladen uit {file} ({n} niet ondersteund) Begin recording Opname starten Bend: Buiging: Bicubic Bicubisch @@ -1131,6 +1167,9 @@ Ignore All Alles negeren Image Size Afbeeldingsgrootte Import Brushes… Penselen importeren… + Gradient Preset Options Opties voor verloopvoorinstellingen + Import .GRD .GRD importeren + Choose a gradient file (.grd). Kies een verloopbestand (.grd). Import brushes (.abr) Penselen (.abr) importeren Import Presets Voorinstellingen importeren Imported Geïmporteerd @@ -2052,6 +2091,11 @@ Rename Brush… Penseel hernoemen… Delete Brush Penseel verwijderen New Brush Preset… Nieuwe penseelvoorinstelling… + Capture Brush Size in Preset Penseelgrootte vastleggen in voorinstelling + Include Tool Settings Gereedschapsinstellingen opnemen + Include Color Kleur opnemen + Enter a name for the brush preset Voer een naam in voor de penseelvoorinstelling + A brush preset with this name already exists Er bestaat al een penseelvoorinstelling met deze naam Brush Preset Options Opties penseelvoorinstelling Group name Groepsnaam Brush name Penseelnaam @@ -2331,6 +2375,7 @@ Click to pick · Esc to cancel Klik om te kiezen · Esc om te annuleren Pick a screen pixel or press Esc to cancel Kies een schermpixel of druk op Esc om te annuleren Scroll to zoom Scrollen om te zoomen + Drag to pan · Scroll to zoom · Double-click to fit Sleep om te pannen · Scroll om te zoomen · Dubbelklik om passend te maken Show/Hide All Panels Alle panelen tonen/verbergen Show/Hide Panels Panelen tonen/verbergen Could not open Kan niet openen @@ -3809,9 +3854,14 @@ curves Smooth Vloeiend maken Light Red Lichtrood Medium Blue Middelblauw Light Gray Lichtgrijs + Object Finder Objectzoeker + No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + Clear Brush Controls Penseelbesturing wissen + Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen + Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Handelingen laden… @@ -3833,3 +3883,5 @@ curves Smooth Vloeiend maken Action playback stopped Afspelen van de handeling gestopt Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster @plural {n} action|{n} actions {n} handeling|{n} handelingen + Auto Add/Delete Automatisch toevoegen/verwijderen + ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9d3ecd9433..4601e90270 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -18,7 +18,17 @@ # Conventions: menus are nouns (Plik, Edycja, Obraz ...), commands are infinitives (Otwórz…, # Zapisz jako…), sentence case as in the English source. Quotes: „…”. +# Channel Options and New Spot Channel (#3761) + Channel Color Kolor kanału + Color Indicates: Kolor oznacza: + Solidity: Gęstość: + Ink Characteristics Właściwości farby + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Szerokość papieru + Paper Height Wysokość papieru Rows Wiersze Number Liczba Top: Góra: @@ -70,6 +80,8 @@ Save Zapisz Save As… Zapisz jako… Save a Copy… Zapisz kopię… + {name} can't keep everything in this document {name} nie może zachować wszystkiego z tego dokumentu + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Wybierz format, który zachowa wszystko, na przykład Photoshop (PSD), lub zapisz kopię. Revert Przywróć ostatnio zapisany Export Eksportuj Quick Export as PNG Szybki eksport jako PNG @@ -107,6 +119,14 @@ Script Events Manager… Menedżer zdarzeń skryptów… Import Importuj Browse… Przeglądaj… + Source Files Pliki źródłowe + Use: Użyj: + Files Pliki + Folder Folder + Add Open Files Dodaj otwarte pliki + Sort By Name Sortuj według nazwy + Attempt to Automatically Align Source Images Spróbuj automatycznie wyrównać obrazy źródłowe + Create Smart Object after Loading Layers Utwórz obiekt inteligentny po wczytaniu warstw Reveal in Finder Pokaż w Finderze Show in Explorer Pokaż w Eksploratorze Show in Folder Pokaż w folderze @@ -131,6 +151,8 @@ Search… Szukaj… Check Spelling… Sprawdź pisownię… Find and Replace Text… Znajdź i zamień tekst… + Add to Dictionary Dodaj do słownika + No Spelling Suggestions Brak sugestii pisowni Fill… Wypełnij… Stroke… Obrys… Content-Aware Fill… Wypełnienie z uwzględnieniem zawartości… @@ -144,6 +166,8 @@ Transform Again on a Copy Przekształć ponownie na kopii Scale Skaluj Rotate Obróć + Set horizontal skew Ustaw pochylenie poziome + Set vertical skew Ustaw pochylenie pionowe Skew Pochyl Distort Zniekształć Perspective Perspektywa @@ -725,6 +749,10 @@ Breeze Dark Theme Ciemny motyw Breeze Breeze light Breeze jasny Breeze Light Theme Jasny motyw Breeze + Soft dark Łagodny ciemny + Soft light Łagodny jasny + Soft Dark Theme Łagodny ciemny motyw + Soft Light Theme Łagodny jasny motyw Next Theme Następny motyw Actions Akcje Brush Settings Ustawienia pędzla @@ -825,6 +853,9 @@ Appearance Wygląd Appearance (Layer Style) Wygląd (styl warstwy) Apply Layer Mask Zastosuj maskę warstwy + Add Mask to Selection Dodaj maskę do zaznaczenia + Subtract Mask from Selection Odejmij maskę od zaznaczenia + Intersect Mask with Selection Część wspólna maski i zaznaczenia Apply Per Tip Zastosuj dla każdego odcisku Apply next selected layer comp Zastosuj następną zaznaczoną kompozycję warstw Apply previous selected layer comp Zastosuj poprzednią zaznaczoną kompozycję warstw @@ -845,6 +876,12 @@ Baseline shift Przesunięcie linii bazowej Basic Podstawowe Before (Y) Przed (Y) + Save Settings… Zapisz ustawienia… + Load Settings… Wczytaj ustawienia… + Save these settings as a Camera Raw .xmp file Zapisz te ustawienia jako plik .xmp Camera Raw + Load settings from a Camera Raw .xmp file Wczytaj ustawienia z pliku .xmp Camera Raw + Loaded Camera Raw settings from {file} Wczytano ustawienia Camera Raw z pliku {file} + Loaded Camera Raw settings from {file} ({n} not supported) Wczytano ustawienia Camera Raw z pliku {file} (nieobsługiwane: {n}) Begin recording Rozpocznij nagrywanie Bend: Zgięcie: Bicubic Dwusześcienna @@ -1145,6 +1182,9 @@ Ignore All Ignoruj wszystko Image Size Rozmiar obrazu Import Brushes… Importuj pędzle… + Gradient Preset Options Opcje ustawień predefiniowanych gradientu + Import .GRD Importuj .GRD + Choose a gradient file (.grd). Wybierz plik gradientów (.grd). Import brushes (.abr) Importuj pędzle (.abr) Import Presets Importuj ustawienia wstępne Imported Zaimportowano @@ -3335,6 +3375,7 @@ cameraRaw Roughness Chropowatość Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować Scroll to zoom Przewijaj, aby zmieniać powiększenie + Drag to pan · Scroll to zoom · Double-click to fit Przeciągnij, aby przesunąć · Przewijaj, aby powiększyć · Kliknij dwukrotnie, aby dopasować Open in camera raw Otwórz w Camera Raw System title bar Systemowy pasek tytułu More documents Więcej dokumentów @@ -3364,6 +3405,11 @@ cameraRaw Roughness Chropowatość Channels: Kanały: Knockout Wycięcie New Brush Preset… Nowe ustawienie wstępne pędzla… + Capture Brush Size in Preset Zapisz rozmiar pędzla w ustawieniu wstępnym + Include Tool Settings Uwzględnij ustawienia narzędzia + Include Color Uwzględnij kolor + Enter a name for the brush preset Wpisz nazwę ustawienia wstępnego pędzla + A brush preset with this name already exists Ustawienie wstępne pędzla o tej nazwie już istnieje Brush Preset Options Opcje ustawień wstępnych pędzla # Fill dialog, layer style contours and Image Size @@ -3646,9 +3692,14 @@ curves Smooth Wygładź Light Red Jasnoczerwony Medium Blue Średnioniebieski Light Gray Jasnoszary + Object Finder Wyszukiwanie obiektów + No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + Clear Brush Controls Wyczyść elementy sterujące pędzlem + Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia + Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Wczytaj operacje… @@ -3670,3 +3721,5 @@ curves Smooth Wygładź Action playback stopped Odtwarzanie operacji zatrzymane Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji + Auto Add/Delete Automatyczne dodawanie/usuwanie + ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b26e9fd07e..21ce217463 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -15,7 +15,17 @@ # entries the same way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) # stay as they are; the trailing "…" of dialog commands is kept. +# Channel Options and New Spot Channel (#3761) + Channel Color Cor do canal + Color Indicates: A cor indica: + Solidity: Solidez: + Ink Characteristics Características da tinta + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Largura do papel + Paper Height Altura do papel Rows Linhas Number Número Margin Margem @@ -71,13 +81,15 @@ Save Salvar Save As… Salvar como… Save a Copy… Salvar uma cópia… + {name} can't keep everything in this document {name} não consegue manter tudo o que há neste documento + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Escolha um formato que mantenha tudo, como Photoshop (PSD), ou salve uma cópia. Revert Reverter Export Exportar Quick Export as PNG Exportação rápida como PNG Export As… Exportar como… Export Preferences… Preferências de exportação… Save for Web (Legacy)… Salvar para Web (legado)… - Artboards to Files… Prancheta em arquivos… + Artboards to Files… Pranchetas em arquivos… Artboards to PDF… Pranchetas em PDF… Layers to Files… Camadas em arquivos… Layer Comps to Files… Composições de camadas em arquivos… @@ -108,6 +120,13 @@ Script Events Manager… Gerenciador de eventos de scripts… Import Importar Browse… Procurar… + Source Files Arquivos de origem + Use: Usar: + Files Arquivos + Add Open Files Adicionar arquivos abertos + Sort By Name Classificar por nome + Attempt to Automatically Align Source Images Tentar alinhar automaticamente as imagens de origem + Create Smart Object after Loading Layers Criar objeto inteligente após carregar as camadas Reveal in Finder Mostrar no Finder Show in Explorer Mostrar no Explorador Show in Folder Mostrar na pasta @@ -132,6 +151,8 @@ Search… Pesquisar… Check Spelling… Verificar ortografia… Find and Replace Text… Localizar e substituir texto… + Add to Dictionary Adicionar ao dicionário + No Spelling Suggestions Sem sugestões de ortografia Fill… Preencher… Stroke… Traçado… Content-Aware Fill… Preenchimento sensível ao conteúdo… @@ -144,6 +165,8 @@ Again Novamente Scale Escala Rotate Girar + Set horizontal skew Definir inclinação horizontal + Set vertical skew Definir inclinação vertical Skew Inclinar Distort Distorcer Perspective Perspectiva @@ -654,7 +677,7 @@ Mesh Malha Edit Pins Editar alfinetes Document Bounds Limites do documento - Show Extras Options… Opções de mostrar extras… + Show Extras Options… Opções de exibição de extras… Rulers Réguas Snap Encaixar Snap To Encaixar em @@ -711,6 +734,10 @@ Breeze Dark Theme Tema Breeze Escuro Breeze light Breeze claro Breeze Light Theme Tema Breeze Claro + Soft dark Suave escuro + Soft light Suave claro + Soft Dark Theme Tema suave escuro + Soft Light Theme Tema suave claro Next Theme Próximo tema Actions Ações Brush Settings Configurações do pincel @@ -760,7 +787,7 @@ Split Warp Horizontally Dividir deformação horizontalmente Split Warp Vertically Dividir deformação verticalmente Statistics… Estatísticas… - Refine Strokes Traços de refinamento + Refine Strokes Refinar traços @plural {n} item|{n} items {n} item|{n} itens Auto Automático (print to the printer) (imprimir na impressora) @@ -809,6 +836,9 @@ Appearance Aparência Appearance (Layer Style) Aparência (estilo de camada) Apply Layer Mask Aplicar máscara de camada + Add Mask to Selection Adicionar máscara à seleção + Subtract Mask from Selection Subtrair máscara da seleção + Intersect Mask with Selection Fazer interseção da máscara com a seleção Apply Per Tip Aplicar por ponta Apply next selected layer comp Aplicar a próxima composição de camadas selecionada Apply previous selected layer comp Aplicar a composição de camadas selecionada anterior @@ -829,6 +859,12 @@ Baseline shift Deslocamento da linha de base Basic Básico Before (Y) Antes (Y) + Save Settings… Salvar configurações… + Load Settings… Carregar configurações… + Save these settings as a Camera Raw .xmp file Salvar estas configurações como arquivo .xmp do Camera Raw + Load settings from a Camera Raw .xmp file Carregar configurações de um arquivo .xmp do Camera Raw + Loaded Camera Raw settings from {file} Configurações do Camera Raw carregadas de {file} + Loaded Camera Raw settings from {file} ({n} not supported) Configurações do Camera Raw carregadas de {file} ({n} sem suporte) Begin recording Iniciar gravação Bend: Curvatura: Bicubic Bicúbica @@ -1127,6 +1163,9 @@ Ignore All Ignorar tudo Image Size Tamanho da imagem Import Brushes… Importar pincéis… + Gradient Preset Options Opções de predefinições de degradê + Import .GRD Importar .GRD + Choose a gradient file (.grd). Escolha um arquivo de degradês (.grd). Import brushes (.abr) Importar pincéis (.abr) Import Presets Importar predefinições Imported Importado @@ -1358,10 +1397,10 @@ Imported {n} shortcuts from {file}. Click OK to keep them. {n} atalhos importados de {file}. Clique em OK para mantê-los. {n} commands are not in PhotoCraft. {n} comandos não existem no PhotoCraft. Keyboard shortcuts imported Atalhos de teclado importados - Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Seu conjunto de atalhos de teclado {set} está em uso aqui: {n} atalhos diferem dos padrões. Editar › Atalhos de teclado os mostra; Redefinir tudo para o padrão desfaz isso. + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Seu conjunto de atalhos de teclado {set} está em uso aqui: {n} atalhos diferem dos padrões. Editar › Atalhos de teclado os mostra; Redefinir tudo para os padrões desfaz isso. Import keyboard shortcuts? Importar atalhos de teclado? PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? O PhotoCraft encontrou neste computador seu conjunto de atalhos de teclado {set} de outro editor de imagens: {n} de seus atalhos diferem dos do PhotoCraft. Importá-lo? - Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Editar › Atalhos de teclado › Redefinir tudo para o padrão desfaz uma importação. + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Editar › Atalhos de teclado › Redefinir tudo para os padrões desfaz uma importação. Don't Import Não importar The keyboard shortcut set was not imported: {error} O conjunto de atalhos de teclado não foi importado: {error} Reset Section Redefinir seção @@ -2012,7 +2051,7 @@ Show the layer's effects ({key}-click: all layers) Mostrar os efeitos da camada ({key}-clique: todas as camadas) Brush picker Seletor de pincéis Erase Apagar - Right click with painting tools Clique direito com ferramentas de pintura + Right click with painting tools Clique com o botão direito com ferramentas de pintura System Info… Informações do sistema… System Info Informações do sistema More panels Mais painéis @@ -2047,6 +2086,11 @@ Rename Brush… Renomear pincel… Delete Brush Excluir pincel New Brush Preset… Nova predefinição de pincel… + Capture Brush Size in Preset Capturar tamanho do pincel na predefinição + Include Tool Settings Incluir configurações da ferramenta + Include Color Incluir cor + Enter a name for the brush preset Digite um nome para a predefinição de pincel + A brush preset with this name already exists Já existe uma predefinição de pincel com este nome Brush Preset Options Opções de predefinições de pincel Group name Nome do grupo Brush name Nome do pincel @@ -2616,7 +2660,7 @@ Shadow Clipping Warning (U) Aviso de corte de sombras (U) Highlight Clipping Warning (O) Aviso de corte de realces (O) Show Lab Color Readouts Mostrar leituras de cor Lab - Show Vectorscope Mostrar vectorscópio + Show Vectorscope Mostrar vetorscópio Show Red at 3 o'clock Mostrar vermelho na posição de 3 horas Show Skin Tone Indicator Mostrar indicador de tom de pele Tone Histogram Histograma de tons @@ -2669,7 +2713,7 @@ cameraRaw Whites Brancos Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Arraste ou clique nos pontos do polígono · segure Alt durante o laço para segmentos retos · {add} adicionar · {sub} antes de desenhar subtrai Extend Estender Structure: Estrutura: - Content-Aware Move Tool Ferramenta Mover com reconhecimento de conteúdo + Content-Aware Move Tool Ferramenta Mover sensível ao conteúdo TIFF Options Opções de TIFF Targa Options Opções de Targa 24 bits/pixel 24 bits/pixel @@ -2750,6 +2794,7 @@ cameraRaw Whites Brancos Click to pick · Esc to cancel Clique para escolher · Esc para cancelar Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar Scroll to zoom Role para ampliar + Drag to pan · Scroll to zoom · Double-click to fit Arraste para mover · Role para ampliar · Clique duas vezes para ajustar Show/Hide All Panels Mostrar/ocultar todos os painéis Show/Hide Panels Mostrar/ocultar painéis Could not open Não foi possível abrir @@ -2817,7 +2862,7 @@ fontWeight Medium Médio fontWeight SemiBold Seminegrito fontWeight Bold Negrito fontWeight ExtraBold Extranegrito -fontWeight Black Ultraniegra +fontWeight Black Ultranegrito fontWeight Italic Itálico # Pattern Stamp tool @@ -3301,7 +3346,7 @@ layerLabel No Color Sem cor In White Branco de entrada Out Black Preto de saída Out White Branco de saída - Tint Color Cor da matiz + Tint Color Cor da tonalidade Stops Pontos de parada Center X Centro X Center Y Centro Y @@ -3654,7 +3699,7 @@ layerLabel No Color Sem cor Defringe Remover halos Diffuse Difusão Diffuse Glow Brilho difuso - Displace Mesclar + Displace Deslocar Do you want to save you made to “{name}” before quitting? Deseja salvar as alterações feitas em “{name}” antes de sair? Dust & Scratches Poeira e rabiscos Export Contents Exportar conteúdo @@ -3689,7 +3734,7 @@ layerLabel No Color Sem cor Legacy Legado Lens Blur Desfoque de lente Lens Correction Correção de lente - Lens Flare Reflexo de flash + Lens Flare Reflexo de lente Lighting Effects Efeitos de iluminação Liquify Dissolver Lock Layers Bloquear camadas @@ -3839,9 +3884,14 @@ layerLabel No Color Sem cor Light Red Vermelho-claro Medium Blue Azul médio Light Gray Cinza-claro + Object Finder Localizador de objetos + No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + Clear Brush Controls Limpar controles de pincel + Reset All Locked Settings Redefinir todas as configurações bloqueadas + Copy Texture to Other Tools Copiar textura para outras ferramentas # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carregar ações… @@ -3863,3 +3913,5 @@ layerLabel No Color Sem cor Action playback stopped Reprodução da ação interrompida Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo @plural {n} action|{n} actions {n} ação|{n} ações + Auto Add/Delete Adicionar/excluir automaticamente + ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e57d0686b9..ca950c2e05 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -17,9 +17,19 @@ # letters; the trailing "…" of dialog commands is kept; the colon of a label ("Width:") becomes # the Russian typographic equivalent, with the space the source carries. +# Channel Options and New Spot Channel (#3761) + Channel Color Цвет канала + Color Indicates: Цветом обозначены: + Solidity: Плотность: + Ink Characteristics Характеристики краски + # Menus # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Ширина бумаги + Paper Height Высота бумаги Rows Строки Number Количество Margin Поля @@ -79,6 +89,8 @@ Save Сохранить Save As… Сохранить как… Save a Copy… Сохранить копию… + {name} can't keep everything in this document {name} не может сохранить все содержимое этого документа + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Выберите формат, который сохранит все, например Photoshop (PSD), или сохраните копию. Save… Сохранить… Revert Восстановить Import Импорт @@ -114,7 +126,7 @@ Fill… Выполнить заливку… Stroke… Обвести… Content-Aware Fill… Заливка с учётом содержимого… - Delete and Fill Selection Удалить и заполнить выделение + Delete and Fill Selection Удалить и залить выделение Define Brush Preset… Определить кисть… Define Pattern… Определить паттерн… Define Custom Shape… Определить свою фигуру… @@ -202,6 +214,9 @@ Enable Layer Mask Включить маску слоя Disable Layer Mask Отключить маску слоя Apply Layer Mask Применить маску слоя + Add Mask to Selection Добавить маску к выделению + Subtract Mask from Selection Вычесть маску из выделения + Intersect Mask with Selection Пересечь маску с выделением Link Layer Mask Связать маску слоя Unlink Layer Mask Отвязать маску слоя Vector Mask Векторная маска @@ -270,6 +285,8 @@ Check Spelling Проверка орфографии Language Options Языковые параметры Find and Replace Text… Найти и заменить текст… + Add to Dictionary Добавить в словарь + No Spelling Suggestions Нет вариантов написания Paste Lorem Ipsum Вставить условный текст Glyphs Panel Панель «Глифы» Type Styles Стили текста @@ -893,7 +910,7 @@ Assign working space Назначить рабочее пространство Use the embedded profile (instead of the working space) Использовать встроенный профиль (вместо рабочего пространства) Leave as is (don't color manage) Оставить как есть (без управления цветом) - Discard the embedded profile (don't color manage) Отклонить встроенный профиль (без управления цветом) + Discard the embedded profile (don't color manage) Не использовать встроенный профиль (без управления цветом) Embed Color Profile Встроить цветовой профиль Assign Profile… Назначить профиль… Convert to Profile… Преобразовать в профиль… @@ -1018,9 +1035,17 @@ Rename Brush… Переименовать кисть… Delete Brush Удалить кисть Import Brushes… Импортировать кисти… + Gradient Preset Options Параметры наборов градиентов + Import .GRD Импортировать .GRD + Choose a gradient file (.grd). Выберите файл градиентов (.grd). Brush Name Имя кисти Brush Stroke Мазок кисти New Brush Preset… Новый набор параметров кисти… + Capture Brush Size in Preset Сохранить размер кисти в наборе + Include Tool Settings Включить настройки инструмента + Include Color Включить цвет + Enter a name for the brush preset Введите имя набора параметров кисти + A brush preset with this name already exists Набор параметров кисти с таким именем уже существует Brush Preset Options Параметры наборов кистей New Brush Новая кисть Create new brush from these settings Создать новую кисть из этих параметров @@ -1182,6 +1207,8 @@ Offset Смещение Reset Transform Сбросить трансформацию Rotate Повернуть + Set horizontal skew Задать наклон по горизонтали + Set vertical skew Задать наклон по вертикали Flip Horizontal Отразить по горизонтали Flip Vertical Отразить по вертикали Frame Sync Синхронизация кадров @@ -1428,6 +1455,10 @@ Breeze Dark Theme Тёмная тема Breeze Breeze light Breeze светлая Breeze Light Theme Светлая тема Breeze + Soft dark Мягкая тёмная + Soft light Мягкая светлая + Soft Dark Theme Мягкая тёмная тема + Soft Light Theme Мягкая светлая тема Studio Theme Тема Studio Studio Light Theme Светлая тема Studio Adwaita Light Theme Светлая тема Adwaita @@ -1678,6 +1709,12 @@ Show in Explorer Показать в Проводнике Show in Folder Показать в папке Before (Y) До (Y) + Save Settings… Сохранить настройки… + Load Settings… Загрузить настройки… + Save these settings as a Camera Raw .xmp file Сохранить эти настройки в файл Camera Raw .xmp + Load settings from a Camera Raw .xmp file Загрузить настройки из файла Camera Raw .xmp + Loaded Camera Raw settings from {file} Настройки Camera Raw загружены из {file} + Loaded Camera Raw settings from {file} ({n} not supported) Настройки Camera Raw загружены из {file} (не поддерживается: {n}) After (X) После (X) Original Оригинал Current Текущий @@ -1831,6 +1868,14 @@ Border… Граница… Bottom Edges Нижние края Browse… Обзор… + Source Files Исходные файлы + Use: Использовать: + Files Файлы + Folder Папка + Add Open Files Добавить открытые файлы + Sort By Name Сортировать по имени + Attempt to Automatically Align Source Images Попытаться автоматически выровнять исходные изображения + Create Smart Object after Loading Layers Создать смарт-объект после загрузки слоёв Canvas Guides Направляющие холста Character Styles Panel Панель «Стили символов» Clear Canvas Guides Удалить направляющие холста @@ -2592,6 +2637,7 @@ cameraRaw Roughness Неровность Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены Scroll to zoom Колесо — изменить масштаб + Drag to pan · Scroll to zoom · Double-click to fit Перетаскивание — перемещение · Колесо — масштаб · Двойной щелчок — вписать Show/Hide All Panels Показать/скрыть все панели Show/Hide Panels Показать/скрыть панели Could not open Не удалось открыть @@ -2654,14 +2700,14 @@ cameraRaw Roughness Неровность Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Слои не помещаются в бюджет памяти GPU. Увеличение параметра «Память» в разделе Настройки › Производительность повышает бюджет, но не больше четверти памяти этого компьютера. The GPU compositor wasn't used: {reason} Компоновщик GPU не использовался: {reason} fontWeight Thin Тонкий -fontWeight ExtraLight Особо светлый -fontWeight Light Светлый +fontWeight ExtraLight Сверхтонкий +fontWeight Light Лёгкий fontWeight Regular Обычный fontWeight Medium Средний fontWeight SemiBold Полужирный fontWeight Bold Жирный -fontWeight ExtraBold Особо жирный -fontWeight Black Чёрный +fontWeight ExtraBold Сверхжирный +fontWeight Black Сверхжирный (Black) fontWeight Italic Курсив # Pattern Stamp tool @@ -2975,16 +3021,16 @@ curves Smooth Сгладить The layer or its mask changed. Drag the mask onto the Trash again. Слой или его маска изменились. Перетащите маску в корзину ещё раз. Delete the vector mask? Удалить векторную маску? # Counted status messages and labels (#3454; adapted from PR #3821) -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Экспортирован {n} образец в {file}|Экспортировано {n} образца в {file}|Экспортировано {n} образцов в {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Импортирована {n} кисть из {group}|Импортировано {n} кисти из {group}|Импортировано {n} кистей из {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Импортирован {n} образец из {file}|Импортировано {n} образца из {file}|Импортировано {n} образцов из {file} -@plural Played {n} step|Played {n} steps Воспроизведён {n} шаг|Воспроизведено {n} шага|Воспроизведено {n} шагов -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Обновлён {n} связанный смарт-объект, изменённый на диске|Обновлено {n} связанных смарт-объекта, изменённых на диске|Обновлено {n} связанных смарт-объектов, изменённых на диске +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Экспортирован {n} образец в {file}|Экспортированы {n} образца в {file}|Экспортировано {n} образцов в {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Импортирована {n} кисть из {group}|Импортированы {n} кисти из {group}|Импортировано {n} кистей из {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Импортирован {n} образец из {file}|Импортированы {n} образца из {file}|Импортировано {n} образцов из {file} +@plural Played {n} step|Played {n} steps Воспроизведён {n} шаг|Воспроизведены {n} шага|Воспроизведено {n} шагов +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Обновлён {n} связанный смарт-объект, изменённый на диске|Обновлены {n} связанных смарт-объекта, изменённых на диске|Обновлено {n} связанных смарт-объектов, изменённых на диске @plural Saved for Web: {n} file|Saved for Web: {n} files Сохранено для Web: {n} файл|Сохранено для Web: {n} файла|Сохранено для Web: {n} файлов -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Удалён {n} слой, записанный в этом варианте. Обновите вариант или сбросьте предупреждение.|Удалено {n} слоя, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.|Удалено {n} слоёв, записанных в этом варианте. Обновите вариант или сбросьте предупреждение. +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Удалён {n} слой, записанный в этом варианте. Обновите вариант или сбросьте предупреждение.|Удалены {n} слоя, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.|Удалено {n} слоёв, записанных в этом варианте. Обновите вариант или сбросьте предупреждение. @plural {n} plane|{n} planes {n} плоскость|{n} плоскости|{n} плоскостей @plural {n} preset|{n} presets {n} набор|{n} набора|{n} наборов -@plural {n} sample point removed|{n} sample points removed Удалена {n} точка замера|Удалено {n} точки замера|Удалено {n} точек замера +@plural {n} sample point removed|{n} sample points removed Удалена {n} точка замера|Удалены {n} точки замера|Удалено {n} точек замера @plural {n} slice|{n} slices {n} фрагмент|{n} фрагмента|{n} фрагментов @plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (и ещё {n} предупреждение о наборах кистей)|{warning} (и ещё {n} предупреждения о наборах кистей)|{warning} (и ещё {n} предупреждений о наборах кистей) @@ -2994,19 +3040,24 @@ curves Smooth Сгладить Light Red Светло-красный Medium Blue Средне-синий Light Gray Светло-серый + Object Finder Поиск объектов + No object found there — drag a rectangle around it Объект не найден — обведите его прямоугольником Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Clear Brush Controls Очистить параметры кисти + Reset All Locked Settings Сбросить все заблокированные параметры + Copy Texture to Other Tools Скопировать текстуру в другие инструменты # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Загрузить операции… Save Actions… Сохранить операции… {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) -@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Загружена {n} операция из {file}|Загружено {n} операции из {file}|Загружено {n} операций из {file} +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Загружена {n} операция из {file}|Загружены {n} операции из {file}|Загружено {n} операций из {file} @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} шаг пока не имеет эквивалента в PhotoCraft|{n} шага пока не имеют эквивалента в PhotoCraft|{n} шагов пока не имеют эквивалента в PhotoCraft -@plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранено {n} операции в {file}|Сохранено {n} операций в {file} -@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущено {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop +@plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранены {n} операции в {file}|Сохранено {n} операций в {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущены {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Вставить остановку… @@ -3018,3 +3069,5 @@ curves Smooth Сгладить Action playback stopped Воспроизведение операции остановлено Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций + Auto Add/Delete Автодобавление/удаление + ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv new file mode 100644 index 0000000000..7a85f8d58f --- /dev/null +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -0,0 +1,4203 @@ +# Serbian (sr) catalog, Latin script, Ekavian. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists +# the Serbian forms "one|few|many" (1, 21, 101 … | 2–4, 22–24 … but +# not 12–14 | everything else), see `plural` in i18n/mod.rs +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Serbian computing vocabulary (sloj, maska, selekcija, četkica, gradijent, zamućenje, +# neprozirnost, providnost ...). No proprietary localisation resource, string table or product UI +# was consulted or copied; in particular not the Serbian localisation of Adobe Photoshop. Add +# entries the same way. +# Conventions: Serbian Latin script (latinica), Ekavian (ekavica), sentence case. Menus are nouns +# (Datoteka, Uređivanje, Slika ...), commands are imperatives (Otvori…, Sačuvaj kao…), tools read +# "Alatka …" (Alatka četkica, Alatka za pomeranje), presets are "šabloni", patterns "šare" and +# swatches "uzorci boja". Serbian quotation marks „…“ around names. Product and technology names +# (PhotoCraft, ArtCraft, Camera Raw, OpenType, Lab ...) stay as they are; RGB/CMYK stay as they +# are; the trailing "…" of dialog commands is kept. A count that is not an @plural entry is kept +# out of noun agreement ("Broj: {n}", "slojeva: {n}") so every number reads correctly. + +# Guide layout dialog + Rows Redovi + Number Broj + Margin Margina + Top: Gore: + Left: Levo: + Bottom: Dole: + Right: Desno: + Target: Cilj: + Selected Artboards Izabrane radne table + Center Columns Centriraj kolone + Clear Existing Guides Ukloni postojeće vodilje + Invalid guide layout Neispravan raspored vodilja + Leave empty for automatic size Ostavite prazno za automatsku veličinu + 5 Columns, 3 Rows 5 kolona, 3 reda + 8 Columns 8 kolona + 12 Columns 12 kolona + + File Datoteka + Edit Uređivanje + Image Slika + Layer Sloj + Type Tekst + Select Selekcija + Selected Izabrano + Scope Opseg + Top Left Gore levo + Top Right Gore desno + Bottom Left Dole levo + Bottom Right Dole desno + Filter Filter + View Prikaz + Window Prozor + Help Pomoć + (coming soon) (uskoro) + New… Novo… + Open… Otvori… + Open As… Otvori kao… + Open EXR Parts… Otvori EXR delove… + Add beauty precomp from the AOVs Dodaj „beauty“ predkompoziciju iz AOV prolaza + Both Oba + Left Levo + Right Desno + Open Recent Otvori nedavno + Clear Recent Files Obriši spisak nedavnih datoteka + Remove from Recent Ukloni iz nedavnih + Remove {name} from Recent Ukloni {name} iz nedavnih + Close Zatvori + Minimize Umanji + Maximize Uvećaj + Restore Vrati + Close All Zatvori sve + Close Others Zatvori ostale + Save Sačuvaj + Save As… Sačuvaj kao… + Save a Copy… Sačuvaj kopiju… + {name} can't keep everything in this document {name} ne može da sačuva sve iz ovog dokumenta + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Izaberite format koji sve čuva, na primer Photoshop (PSD), ili sačuvajte kopiju. + Revert Vrati sačuvano + Export Izvoz + Quick Export as PNG Brzi izvoz u PNG + Export As… Izvezi kao… + Export Preferences… Podešavanja izvoza… + Save for Web (Legacy)… Sačuvaj za veb (staro)… + Artboards to Files… Radne table u datoteke… + Artboards to PDF… Radne table u PDF… + Layers to Files… Slojevi u datoteke… + Layer Comps to Files… Kompozicije slojeva u datoteke… + Color Lookup Tables… Tabele preslikavanja boja… + Data Sets as Files… Skupovi podataka kao datoteke… + Paths to Illustrator… Putanje u Illustrator… + Render Video… Renderuj video… + Generate Generisanje + Image Assets Slikovni resursi + Place Embedded… Postavi ugrađeno… + Place Linked… Postavi povezano… + Package… Spakuj… + Automate Automatizacija + Batch… Grupna obrada… + Create Droplet… Napravi droplet… + Crop and Straighten Photos Opseci i ispravi fotografije + Contact Sheet II… Kontakt-list II… + Conditional Mode Change… Uslovna promena režima… + Fit Image… Uklopi sliku… + Lens Correction… Korekcija objektiva… + Merge to HDR Pro… Spoji u HDR Pro… + Photomerge… Spajanje fotografija… + Scripts Skripte + Image Processor… Obrada slika… + Delete All Empty Layers Obriši sve prazne slojeve + Flatten All Layer Effects Spljošti sve efekte slojeva + Flatten All Masks Spljošti sve maske + Script Events Manager… Upravljač događajima skripti… + Import Uvoz + Browse… Pregledaj… + Source Files Izvorne datoteke + Use: Koristi: + Files Datoteke + Add Open Files Dodaj otvorene datoteke + Sort By Name Poređaj po imenu + Attempt to Automatically Align Source Images Pokušaj automatski poravnati izvorne slike + Create Smart Object after Loading Layers Napravi pametni objekat posle učitavanja slojeva + Reveal in Finder Prikaži u Finderu + Show in Explorer Prikaži u Exploreru + Show in Folder Prikaži u fascikli + File Info… Informacije o datoteci… + Print… Štampaj… + Print One Copy Štampaj jedan primerak + Exit Izađi + Undo Opozovi + Redo Ponovi + Toggle Last State Prebaci na poslednje stanje + Fade… Ublaži… + Cut Iseci + Copy Kopiraj + Copy Merged Kopiraj spojeno + Paste Nalepi + Paste Special Specijalno lepljenje + Paste in Place Nalepi na isto mesto + Paste Into Nalepi unutra + Paste Outside Nalepi spolja + Paste Lorem Ipsum Nalepi Lorem Ipsum + Clear Obriši + Search… Pretraga… + Check Spelling… Proveri pravopis… + Find and Replace Text… Pronađi i zameni tekst… + Fill… Popuni… + Stroke… Ocrtaj… + Content-Aware Fill… Popuna prema sadržaju… + Delete and Fill Selection Obriši i popuni selekciju + Content-Aware Scale Skaliranje prema sadržaju + Puppet Warp Lutkarsko krivljenje + Perspective Warp Perspektivno krivljenje + Free Transform Slobodna transformacija + Transform Transformacija + Again Ponovo + Scale Skaliranje + Rotate Rotacija + Skew Iskošenje + Set horizontal skew Podesi horizontalno iskošenje + Set vertical skew Podesi vertikalno iskošenje + Distort Izobličenje + Perspective Perspektiva + Warp Krivljenje + Flip Horizontal Okreni horizontalno + Flip Vertical Okreni vertikalno + Rotate 180° Rotiraj za 180° + Rotate 90° Clockwise Rotiraj za 90° udesno + Rotate 90° Counter Clockwise Rotiraj za 90° ulevo + 180° 180° + 90° Clockwise 90° udesno + 90° Counter Clockwise 90° ulevo + Arbitrary… Proizvoljno… + Auto-Align Layers… Automatski poravnaj slojeve… + Auto-Blend Layers… Automatski stopi slojeve… + Define Brush Preset… Definiši šablon četkice… + Define Pattern… Definiši šaru… + Define Custom Shape… Definiši prilagođeni oblik… + Purge Isprazni + Clipboard Ostava + Histories Istorije + Video Cache Video keš + All Sve + Presets Šabloni + Preset Manager… Upravljač šablonima… + Migrate Presets Prenesi šablone + Export/Import Presets… Izvoz/uvoz šablona… + Color Settings… Podešavanja boja… + Assign Profile… Dodeli profil… + Convert to Profile… Pretvori u profil… + Keyboard Shortcuts… Prečice na tastaturi… + Menus… Meniji… + Toolbar… Traka alatki… + Preferences Podešavanja + General… Opšte… + Interface… Interfejs… + Workspace… Radni prostor… + Tools… Alatke… + History Log… Dnevnik istorije… + File Handling… Rukovanje datotekama… + Export… Izvoz… + Performance… Performanse… + Scratch Disks… Radni diskovi… + Cursors… Kursori… + Transparency & Gamut… Providnost i gamut… + Units & Rulers… Jedinice i lenjiri… + Guides, Grid & Slices… Vodilje, mreža i isečci… + Plug-ins… Dodaci… + Type… Tekst… + Enhanced Controls… Napredne kontrole… + Camera Raw… Camera Raw… + Raw Defaults… Podrazumevana Raw podešavanja… + Integrations… Integracije… + AI Integrations… AI integracije… + Install Plug-in… Instaliraj dodatak… + Language Options Jezičke opcije + Settings… Podešavanja… + Mode Režim + Bitmap Bitmapa + Grayscale Nijanse sive + Duotone Duoton + Indexed Color Indeksirane boje + RGB Color RGB boje + CMYK Color CMYK boje + Lab Color Lab boje + Multichannel Višekanalno + 8 Bits/Channel 8 bita/kanal + 16 Bits/Channel 16 bita/kanal + 32 Bits/Channel 32 bita/kanal + Color Table… Tabela boja… + Bitmap… Bitmapa… + Duotone… Duoton… + Indexed Color… Indeksirane boje… + Adjustments Korekcije + Brightness/Contrast… Osvetljenost/kontrast… + Levels… Nivoi… + Curves… Krive… + Exposure… Ekspozicija… + Vibrance… Živost… + Hue/Saturation… Nijansa/zasićenost… + Color Balance… Balans boja… + Black & White… Crno-belo… + Photo Filter… Foto-filter… + Channel Mixer… Mešač kanala… + Color Lookup… Preslikavanje boja… + Invert Invertuj + Posterize… Posterizuj… + Threshold… Prag… + Gradient Map… Mapa gradijenta… + Selective Color… Selektivna boja… + Shadows/Highlights… Senke/svetla… + HDR Toning… HDR toniranje… + Desaturate Ukloni zasićenost + Match Color… Uskladi boje… + Replace Color… Zameni boju… + Equalize Izjednači + Auto Tone Automatski ton + Auto Contrast Automatski kontrast + Auto Color Automatske boje + Image Size… Veličina slike… + Canvas Size… Veličina platna… + Image Rotation Rotacija slike + Flip Canvas Horizontal Okreni platno horizontalno + Flip Canvas Vertical Okreni platno vertikalno + Crop Opseci + Trim… Odseci ivice… + Reveal All Otkrij sve + Duplicate… Dupliraj… + Apply Image… Primeni sliku… + Calculations… Proračuni… + Variables Promenljive + Define… Definiši… + Data Sets… Skupovi podataka… + Apply Data Set… Primeni skup podataka… + Variable Data Sets… Promenljivi skupovi podataka… + Trap… Trepovanje… + Analysis Analiza + Set Measurement Scale… Postavi razmeru merenja… + Select Data Points… Izaberi tačke podataka… + Record Measurements Zabeleži merenja + Ruler Tool Alatka lenjir + Count Tool Alatka za brojanje + Place Scale Marker… Postavi oznaku razmere… + Custom… Prilagođeno… + New Novo + Layer… Sloj… + Group… Grupa… + Group from Layers… Grupa od slojeva… + Artboard… Radna tabla… + Artboard from Group… Radna tabla od grupe… + Artboard from Layers… Radna tabla od slojeva… + Layer via Copy Sloj kopiranjem + Layer via Cut Sloj isecanjem + Layer From Background… Sloj od pozadine… + Layer from Background… Sloj od pozadine… + Background From Layer Pozadina od sloja + New Shape Layer Novi sloj oblika + New Type Layer Novi tekstualni sloj + New Fill Layer Novi sloj popune + New Adjustment Layer Novi korekcioni sloj + New Layer Based Slice Novi isečak zasnovan na sloju + New Smart Object via Copy Novi pametni objekat kopiranjem + New Blank Video Layer Novi prazan video sloj + New Video Layer from File… Novi video sloj iz datoteke… + Duplicate Layer… Dupliraj sloj… + Delete Layer Obriši sloj + Delete Obriši + Rename Layer Preimenuj sloj + Create Layer Napravi sloj + Layer Style Stil sloja + Blending Options… Opcije stapanja… + Bevel & Emboss… Kosina i reljef… + Inner Shadow… Unutrašnja senka… + Inner Glow… Unutrašnji sjaj… + Satin… Saten… + Color Overlay… Prekrivanje bojom… + Gradient Overlay… Prekrivanje gradijentom… + Pattern Overlay… Prekrivanje šarom… + Outer Glow… Spoljašnji sjaj… + Drop Shadow… Bačena senka… + Copy Layer Style Kopiraj stil sloja + Paste Layer Style Nalepi stil sloja + Clear Layer Style Ukloni stil sloja + Global Light… Globalno svetlo… + Hide All Effects Sakrij sve efekte + Scale Effects… Skaliraj efekte… + Smart Filter Pametni filter + Convert for Smart Filters Pretvori za pametne filtere + Clear Smart Filters Ukloni pametne filtere + Disable Smart Filters Onemogući pametne filtere + Delete Filter Mask Obriši masku filtera + Disable Filter Mask Onemogući masku filtera + Solid Color… Puna boja… + Gradient… Gradijent… + Pattern… Šara… + Layer Mask Maska sloja + Vector Mask Vektorska maska + Reveal Selection Otkrij selekciju + Hide Selection Sakrij selekciju + Hide All Sakrij sve + From Transparency Iz providnosti + Delete Vector Mask Obriši vektorsku masku + Enable Layer Mask Omogući masku sloja + Disable Layer Mask Onemogući masku sloja + Enable Vector Mask Omogući vektorsku masku + Link Layer Mask Poveži masku sloja + Unlink Layer Mask Razveži masku sloja + Link Vector Mask Poveži vektorsku masku + Add Vector Mask Dodaj vektorsku masku + Vector Mask from Current Path Vektorska maska od tekuće putanje + Create Clipping Mask Napravi masku isecanja + Release Clipping Mask Otpusti masku isecanja + Smart Objects Pametni objekti + Smart Object Pametni objekat + Convert to Smart Object Pretvori u pametni objekat + Convert to Embedded Pretvori u ugrađeni + Convert to Layers Pretvori u slojeve + Convert to Linked… Pretvori u povezani… + Edit Contents Uredi sadržaj + Replace Contents… Zameni sadržaj… + Relink to File… Ponovo poveži sa datotekom… + Export Contents… Izvezi sadržaj… + Update Modified Content Ažuriraj izmenjeni sadržaj + Update All Modified Content Ažuriraj sav izmenjeni sadržaj + Stack Mode Režim steka + Entropy Entropija + Kurtosis Kurtozis + Maximum Maksimum + Mean Srednja vrednost + Median Medijana + Minimum Minimum + Range Opseg + Skewness Asimetrija + Standard Deviation Standardna devijacija + Summation Zbir + Variance Varijansa + Video Layers Video slojevi + Interpret Footage… Tumači snimak… + Replace Footage… Zameni snimak… + Reload Frame Ponovo učitaj kadar + Show Altered Video Prikaži izmenjeni video + Restore Frame Vrati kadar + Restore All Frames Vrati sve kadrove + Insert Blank Frame Umetni prazan kadar + Delete Frame Obriši kadar + Duplicate Frame Dupliraj kadar + Frame from Layers Okvir od slojeva + Video Frames to Layers… Video kadrovi u slojeve… + Rasterize Rasterizuj + Rasterize Type Rasterizuj tekst + Rasterize Type Layer Rasterizuj tekstualni sloj + Rasterize Shape Rasterizuj oblik + New Guide Layout… Novi raspored vodilja… + New Guides From Shape Nove vodilje od oblika + Lock Layers… Zaključaj slojeve… + Link Layers Poveži slojeve + Select Linked Layers Izaberi povezane slojeve + Merge Layers Spoji slojeve + Merge Down Spoji nadole + Merge Group Spoji grupu + Merge Visible Spoji vidljive + Stamp Visible Utisni vidljive + Stamp Down Utisni nadole + Flatten Image Spljošti sliku + Matting Čišćenje ivica + Color Decontaminate… Dekontaminacija boja… + Remove Black Matte Ukloni crni oreol + Remove White Matte Ukloni beli oreol + Defringe… Ukloni rub… + Group Layers Grupiši slojeve + Ungroup Layers Razgrupiši slojeve + Hide Layers Sakrij slojeve + Arrange Raspored + Bring to Front Premesti na vrh + Bring Forward Pomeri napred + Send Backward Pomeri nazad + Send to Back Premesti na dno + Reverse Obrni + Combine Shapes Kombinuj oblike + Unite Shapes Ujedini oblike + Subtract Front Shape Oduzmi prednji oblik + Intersect Shape Areas Presek oblasti oblika + Exclude Overlapping Shapes Isključi preklapajuće oblike + Merge Shape Components Spoji komponente oblika + Align Poravnaj + Top Edges Gornje ivice + Vertical Centers Vertikalni centri + Bottom Edges Donje ivice + Left Edges Leve ivice + Horizontal Centers Horizontalni centri + Right Edges Desne ivice + Distribute Raspodeli + Vertically Vertikalno + Horizontally Horizontalno + Layer Edges Ivice sloja + Isolate Layers Izoluj slojeve + Layer Content Options… Opcije sadržaja sloja… + Fill Content Sadržaj popune + Deselect Layers Poništi izbor slojeva + Find Layers Pronađi slojeve + Layer Comps Kompozicije slojeva + Layers Slojevi + All Layers Svi slojevi + Hidden Layers Skriveni slojevi + Mask All Objects Maskiraj sve objekte + Subject Subjekat + Sky Nebo + Panels Paneli + Character Panel Panel znakova + Paragraph Panel Panel pasusa + Character Styles Panel Panel stilova znakova + Paragraph Styles Panel Panel stilova pasusa + Glyphs Panel Panel glifova + Anti-Alias Uglađivanje ivica + Sharp Oštro + Crisp Jasno + Strong Snažno + Smooth Glatko + Windows LCD Windows LCD + Windows Windows + Orientation Orijentacija + Horizontal Horizontalno + Vertical Vertikalno + OpenType OpenType + Standard Ligatures Standardne ligature + Contextual Alternates Kontekstualne alternative + Discretionary Ligatures Opcione ligature + Swash Kićeni oblici + Oldstyle Starinske cifre + Stylistic Alternates Stilske alternative + Titling Alternates Naslovne alternative + Ornaments Ornamenti + Ordinals Redni brojevi + Fractions Razlomci + Create Work Path Napravi radnu putanju + Convert to Shape Pretvori u oblik + Warp Text… Krivljenje teksta… + Convert to Point Text Pretvori u tačkasti tekst + Convert to Paragraph Text Pretvori u tekst pasusa + Default Features Podrazumevane funkcije + East Asian Features Funkcije za istočnoazijska pisma + Middle Eastern Features Funkcije za bliskoistočna pisma + Middle Eastern & South Asian Composer Slagač za bliskoistočna i južnoazijska pisma + Update All Text Layers Ažuriraj sve tekstualne slojeve + Replace All Missing Fonts Zameni sve fontove koji nedostaju + Resolve Missing Fonts… Razreši fontove koji nedostaju… + Font Preview Size Veličina pregleda fonta + None Ništa + Small Mala + Medium Srednja + Large Velika + Extra Large Veoma velika + Huge Ogromna + Load Default Type Styles Učitaj podrazumevane stilove teksta + Save Default Type Styles Sačuvaj podrazumevane stilove teksta + Deselect Poništi selekciju + Reselect Ponovo izaberi + Inverse Inverzno + Inverse Selection Obrni selekciju + Similar Slično + Color Range… Opseg boja… + Focus Area… Oblast fokusa… + Select and Mask… Izaberi i maskiraj… +# Select and Mask dialog labels + Smart Radius Pametni poluprečnik + Shift Edge Pomeri ivicu + Decontaminate Očisti boje + Output Izlaz + Refine Edge Doteraj ivicu + Modify Izmeni + Border… Obod… + Smooth… Izgladi… + Expand… Proširi… + Contract… Suzi… + Feather… Omekšaj ivice… + Grow Uvećaj + Transform Selection Transformiši selekciju + Edit in Quick Mask Mode Uredi u režimu brze maske + Load Selection… Učitaj selekciju… + Save Selection… Sačuvaj selekciju… + Match All Uskladi sve + Last Filter Poslednji filter + Filter Gallery… Galerija filtera… + Adaptive Wide Angle… Prilagodljivi široki ugao… + Camera Raw Filter… Camera Raw filter… + Liquify… Tečno izobličavanje… + Vanishing Point… Tačka nestajanja… + Blur Zamućenje + Blur Gallery Galerija zamućenja + Field Blur… Zamućenje polja… + Iris Blur… Iris zamućenje… + Tilt-Shift… Tilt-shift… + Path Blur… Zamućenje duž putanje… + Spin Blur… Rotaciono zamućenje… + Blur More Jače zamućenje + Box Blur… Pravougaono zamućenje… + Gaussian Blur… Gausovo zamućenje… + Lens Blur… Zamućenje objektiva… + Motion Blur… Zamućenje pokreta… + Radial Blur… Radijalno zamućenje… + Shape Blur… Zamućenje oblikom… + Smart Blur… Pametno zamućenje… + Surface Blur… Površinsko zamućenje… + Average Prosek + Displace… Premeštanje… + Pinch… Skupljanje… + Polar Coordinates… Polarne koordinate… + Ripple… Talasanje… + Shear… Smicanje… + Spherize… Sferizacija… + Twirl… Uvrtanje… + Wave… Talas… + ZigZag… Cik-cak… + Noise Šum + Add Noise… Dodaj šum… + Despeckle Ukloni mrlje + Dust & Scratches… Prašina i ogrebotine… + Median… Medijana… + Reduce Noise… Smanji šum… + Pixelate Pikselizacija + Color Halftone… Polutonski raster u boji… + Crystallize… Kristalizacija… + Facet Fasete + Fragment Fragmentacija + Mezzotint… Mecotinta… + Mosaic… Mozaik… + Pointillize… Poentilizam… + Render Renderovanje + Clouds Oblaci + Difference Clouds Oblaci razlike + Fibers… Vlakna… + Flame… Plamen… + Lens Flare… Odsjaj objektiva… + Lighting Effects… Efekti osvetljenja… + Relight… Ponovno osvetljavanje… + Picture Frame… Ram za sliku… + Tree… Drvo… + Sharpen Izoštravanje + Sharpen Edges Izoštri ivice + Sharpen More Jače izoštravanje + Smart Sharpen… Pametno izoštravanje… + Unsharp Mask… Maska neoštrine… + High Pass… Visokopropusni filter… + Stylize Stilizacija + Diffuse… Rasipanje… + Emboss… Reljef… + Extrude… Istiskivanje… + Find Edges Pronađi ivice + Oil Paint… Uljana boja… + Solarize Solarizacija + Tiles… Pločice… + Trace Contour… Praćenje konture… + Wind… Vetar… + Video Video + De-Interlace… Uklanjanje preplitanja… + NTSC Colors NTSC boje + Other Ostalo + Maximum… Maksimum… + Minimum… Minimum… + Offset… Pomak… + Plug-ins Dodaci + Proof Setup Podešavanje probnog prikaza + Proof Colors Probne boje + Gamut Warning Upozorenje o gamutu + Working CMYK Radni CMYK + Working Cyan Plate Radna cijan ploča + Working Magenta Plate Radna magenta ploča + Working Yellow Plate Radna žuta ploča + Working Black Plate Radna crna ploča + Working CMY Plate Radna CMY ploča + Legacy Macintosh RGB Stari Macintosh RGB + Internet Standard RGB Internet standard RGB + Monitor RGB RGB monitora + Color Blindness — Protanopia-type Daltonizam — tip protanopije + Color Blindness — Deuteranopia-type Daltonizam — tip deuteranopije + Zoom In Uvećaj prikaz + Zoom Out Umanji prikaz + Fit on Screen Uklopi u ekran + Fit Layer(s) on Screen Uklopi sloj(eve) u ekran + Fit Artboard on Screen Uklopi radnu tablu u ekran + 100% 100% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size Veličina za štampu + Pixel Aspect Ratio Odnos stranica piksela + Pixel Aspect Ratio Correction Korekcija odnosa stranica piksela + Custom Pixel Aspect Ratio… Prilagođeni odnos stranica piksela… + Square Kvadrat + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC, široki ekran (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL, široki ekran (1.46) + Anamorphic 2:1 (2) Anamorfno 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + 32-bit Preview Options… Opcije 32-bitnog pregleda… + Screen Mode Režim ekrana + Standard Screen Mode Standardni režim ekrana + Full Screen Mode With Menu Bar Režim celog ekrana sa trakom menija + Full Screen Mode Režim celog ekrana + Cycle Screen Mode Smenjuj režime ekrana + Show Prikaži + Extras Dodatni elementi + Selection Edges Ivice selekcije + Target Path Ciljna putanja + Current Path Tekuća putanja + Grid Mreža + Guides Vodilje + Canvas Guides Vodilje platna + Artboard Guides Vodilje radne table + Count Broj + Smart Guides Pametne vodilje + Slices Isečci + Notes Beleške + Pixel Grid Mreža piksela + Pixel Art Piksel-art + Pixel Art Preview Pregled piksel-arta + Pattern Preview Pregled šare + Brush Preview Pregled četkice + Mesh Mrežica + Edit Pins Uredi čiode + Document Bounds Granice dokumenta + Show Extras Options… Opcije prikaza dodatnih elemenata… + Rulers Lenjiri + Snap Prianjanje + Snap To Prianjaj uz + Lock Guides Zaključaj vodilje + Clear Guides Ukloni vodilje + Clear Selected Artboard Guides Ukloni vodilje izabranih radnih tabli + Clear Canvas Guides Ukloni vodilje platna + New Guide… Nova vodilja… + Lock Slices Zaključaj isečke + Clear Slices Ukloni isečke + Match Zoom Uskladi zum + Match Location Uskladi položaj + Match Rotation Uskladi rotaciju + Cascade Kaskadno + Tile Popločaj + Tile All Horizontally Popločaj sve horizontalno + Tile All Vertically Popločaj sve vertikalno + 2-up Horizontal Po 2, horizontalno + 2-up Vertical Po 2, vertikalno + 3-up Horizontal Po 3, horizontalno + 3-up Stacked Po 3, naslagano + 3-up Vertical Po 3, vertikalno + 4-up Po 4 + 6-up Po 6 + Consolidate All to Tabs Objedini sve u kartice + Float in Window Odvoji u prozor + Float All in Windows Odvoji sve u prozore + New Window for Document Novi prozor za dokument + Workspace Radni prostor + Essentials Osnovno + Graphic and Web Grafika i veb + Painting Slikanje + Photography Fotografija + New Workspace… Novi radni prostor… + Delete Workspace… Obriši radni prostor… + Reset Workspace Resetuj radni prostor + Reset Tool Resetuj alatku + Reset All Tools Resetuj sve alatke + Reset all tools to their default settings? Vratiti sve alatke na podrazumevana podešavanja? + Lock Workspace Zaključaj radni prostor + Theme Tema + Pro Theme Pro tema + Pro Medium Gray Theme Pro srednje siva tema + Pro Light Gray Theme Pro svetlosiva tema + Pro Light Theme Pro svetla tema + Studio Theme Studio tema + Studio Light Theme Studio svetla tema + Adwaita Light Theme Adwaita svetla tema + Adwaita Dark Theme Adwaita tamna tema + Classic Theme Klasična tema + Solarized dark Solarized tamna + Solarized Dark Theme Solarized tamna tema + Breeze dark Breeze tamna + Breeze Dark Theme Breeze tamna tema + Breeze light Breeze svetla + Breeze Light Theme Breeze svetla tema + Soft dark Meka tamna + Soft Dark Theme Meka tamna tema + Soft light Meka svetla + Soft Light Theme Meka svetla tema + Next Theme Sledeća tema + Actions Radnje + Brush Settings Podešavanja četkice + Brushes Četkice + Channels Kanali + Character Znak + Character Styles Stilovi znakova + Clone Source Izvor kloniranja + Color Boja + Glyphs Glifovi + Gradients Gradijenti + Histogram Histogram + History Istorija + Info Informacije + Measurement Log Dnevnik merenja + Modifier Keys Modifikatorski tasteri + Navigator Navigator + Options Opcije + Paragraph Pasus + Paragraph Styles Stilovi pasusa + Paths Putanje + Patterns Šare + Properties Svojstva + Shapes Oblici + Styles Stilovi + Swatches Uzorci boja + Timeline Vremenska linija + Tool Presets Šabloni alatki + Tools Alatke + About PhotoCraft O programu PhotoCraft + PhotoCraft Website Veb-sajt PhotoCraft-a + PhotoCraft on GitHub PhotoCraft na GitHub-u + ArtCraft Website Veb-sajt ArtCraft-a + Join the ArtCraft Discord… Pridružite se ArtCraft Discord zajednici… + Report an Issue… Prijavi problem… + WIA Support… WIA podrška… + Apply Primeni + HSB/HSL HSB/HSL + Invert… Invertuj… + Load Files into Stack… Učitaj datoteke u stek… + Motion Pokret + Notes… Beleške… + Other… Ostalo… + Remove Warp Split Ukloni podelu krivljenja + Shape Oblik + Split Warp Crosswise Podeli krivljenje unakrsno + Split Warp Horizontally Podeli krivljenje horizontalno + Split Warp Vertically Podeli krivljenje vertikalno + Statistics… Statistika… + Refine Strokes Doteraj poteze +@plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki + Auto Automatski + (print to the printer) (štampaj na štampaču) + + Add variable + Dodaj promenljivu + 30 frames @ 30 fps 30 kadrova pri 30 fps + 3D LUT File 3D LUT datoteka + Absolute Apsolutno + Absolute Colorimetric Apsolutno kolorimetrijski + Action Radnja + Adaptive Prilagodljivo + Adaptive Wide Angle isn't open Prilagodljivi široki ugao nije otvoren + Add Dodaj + Add Layer Mask Dodaj masku sloja + Add Mask to Selection Dodaj masku selekciji + Subtract Mask from Selection Oduzmi masku od selekcije + Intersect Mask with Selection Presek maske i selekcije + Add Stop Dodaj tačku + Add a layer style Dodaj stil sloja + Add a mask (from the selection; {key} inverts) Dodaj masku (iz selekcije; {key} invertuje) + Add an adjustment Dodaj korekciju + Add disk Dodaj disk + Add space after paragraph Dodaj razmak posle pasusa + Add space before paragraph Dodaj razmak pre pasusa + Add to Sample Dodaj u uzorak + Add to selection ({key}) Dodaj u selekciju ({key}) + Adjust for Zoom Prilagodi zumu + Adjustment visible Korekcija je vidljiva + Adobe RGB (1998) compatible Kompatibilno sa Adobe RGB (1998) + Align and Distribute Poravnanje i raspodela + Align and distribute Poravnanje i raspodela + Align horizontal centers Poravnaj horizontalne centre + Align left edges Poravnaj leve ivice + Align right edges Poravnaj desne ivice + Aligned Poravnato + All Caps Sve velika slova + All Slices Svi isečci + All User Slices Svi korisnički isečci + All shortcuts reset to PhotoCraft defaults. Sve prečice su vraćene na podrazumevane vrednosti PhotoCraft-a. + Alt Alt + Always use pressure for opacity Uvek koristi pritisak za neprozirnost + Always use pressure for size Uvek koristi pritisak za veličinu + Amount Količina + Anchor: Sidro: + Angle Ugao + Angle Gradient Ugaoni gradijent + Angle Jitter Kolebanje ugla + Answer the unsaved-changes prompt first Prvo odgovorite na upit o nesačuvanim izmenama + Anti-alias Uglađivanje ivica + Appearance Izgled + Appearance (Layer Style) Izgled (stil sloja) + Apply Layer Mask Primeni masku sloja + Apply Per Tip Primeni po vrhu + Apply next selected layer comp Primeni sledeću izabranu kompoziciju slojeva + Apply previous selected layer comp Primeni prethodnu izabranu kompoziciju slojeva + Artboard Radna tabla + Artboard Background Color Boja pozadine radne table + Assign working space Dodeli radni prostor boja + Author: Autor: + Auto Hide Automatski sakrij + Auto-Select: Automatski izbor: + Automatically level near-horizontal line segments Automatski izravnaj skoro horizontalne segmente linija + Automatically straighten and level Automatski ispravi i izravnaj + Automatically straighten near-vertical line segments Automatski ispravi skoro vertikalne segmente linija + Background Pozadina + Background Color Boja pozadine + Background Contents Sadržaj pozadine + Balance Balans + Baseline Osnovna linija + Baseline shift Pomak osnovne linije + Basic Osnovno + Before (Y) Pre (Y) + Begin recording Započni snimanje + Bend: Savijanje: + Bicubic Bikubično + Bicubic (smooth gradients) Bikubično (glatki prelazi) + Automatic Automatski + Bicubic Smoother (enlargement) Bikubično glađe (uvećanje) + Bicubic Sharper (reduction) Bikubično oštrije (smanjenje) + Bilinear Bilinearno + Black Crna + Black & White Crno-belo + Black Point Compensation Kompenzacija crne tačke + Black, White Crna, bela + Blacks Crne + Blending Stapanje + Blending Options Opcije stapanja + Blue Plava + Blue, Red, Yellow Plava, crvena, žuta + Both Axes Obe ose + Bottom Dole + Brightness Osvetljenost + Brightness Jitter Kolebanje osvetljenosti + Brightness/Contrast Osvetljenost/kontrast + Bring the selected pin forward Pomeri izabranu čiodu napred + Brush Preset picker Birač šablona četkice + Brush Reconstruct Options Opcije četkice za rekonstrukciju + Brush Tool Options Opcije alatke četkica + Cache Level: Nivo keša: + Camera Raw isn't open Camera Raw nije otvoren + Cancel Otkaži + Cancel (Esc) Otkaži (Esc) + Cancel any current edits (Esc) Otkaži sve tekuće izmene (Esc) + Cancel current crop operation (Esc) Otkaži tekuće opsecanje (Esc) + Cancel transform (Esc) Otkaži transformaciju (Esc) + Cancel warp (Esc) Otkaži krivljenje (Esc) + Canvas Platno + Canvas extension color: Boja proširenja platna: + Capture Zabeleži + Capture the visibility, position and style of every layer with + below, then switch between versions. Zabeležite vidljivost, položaj i stil svakog sloja dugmetom + ispod, a zatim se prebacujte između verzija. + Catch-up on Stroke End Sustizanje na kraju poteza + Center Centar + Center Crop Marks Središnje oznake za sečenje + Center text Centriraj tekst + Change Promeni + Change All Promeni sve + Change Screen Mode (F) Promeni režim ekrana (F) + Change To: Promeni u: + Channel Mixer Mešač kanala + Channel: Kanal: + Check All Layers Proveri sve slojeve + Check Spelling Provera pravopisa + Clarity Jasnoća + Clear All Obriši sve + Clear Override Ukloni nadjačavanje + Clear Samples Obriši uzorke + Click sets foreground · right-click sets background Klik postavlja boju prednjeg plana · desni klik postavlja boju pozadine + Click the first point or press {key} to close · Esc cancels Kliknite prvu tačku ili pritisnite {key} da zatvorite · Esc otkazuje + Click to add a point · drag off or {key}-click or Delete to remove Kliknite da dodate tačku · prevucite van, {key}-klik ili Delete da uklonite + Click to apply, {key}-click to clear overrides, double-click for Style Options Kliknite da primenite, {key}-klik da uklonite nadjačavanja, dvoklik za opcije stila + Click to edit the gradient Kliknite da uredite gradijent + Click to restore the current colour Kliknite da vratite tekuću boju + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kliknite da uvećate · {key}-klik da umanjite · prevucite udesno/ulevo da uvećate/umanjite + Click with the Note tool to add a note. Kliknite alatkom za beleške da dodate belešku. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · {key} završi · Esc otkaži + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · {alt}-prevlačenje: razdvoji ručicu · Klik ili prevlačenje prve tačke: zatvori · {key} završi · Esc otkaži + Clip to layer below Iseci prema sloju ispod + Clipped Isečeno + Close Style Options Zatvori opcije stila + Cmd Cmd + Coated CMYK Premazani CMYK + Color & Swatches Boja i uzorci + Color Balance Balans boja + Color Detail Detalji boje + Color Grading Gradacija boja + Color Lookup Preslikavanje boja + Color Management Upravljanje bojama + Color Mixer Mešač boja + Color Mode Režim boja + Color Priority Prioritet boje + Color Range preview Pregled opsega boja + Color: Boja: + Colorize Oboji + Colors Boje + Commit Perspective Warp ({key}) Potvrdi perspektivno krivljenje ({key}) + Commit Puppet Warp ({key}) Potvrdi lutkarsko krivljenje ({key}) + Commit any current edits ({key}) Potvrdi sve tekuće izmene ({key}) + Commit current crop operation ({key}) Potvrdi tekuće opsecanje ({key}) + Commit transform ({key}) Potvrdi transformaciju ({key}) + Commit warp ({key}) Potvrdi krivljenje ({key}) + Constant Konstantno + Constrain proportions Zadrži proporcije + Contact Sheet II Kontakt-list II + Content-Aware Prema sadržaju + Contiguous Susedno + Contrast Kontrast + Control: Kontrola: + Convert document's colors to the working space Pretvori boje dokumenta u radni prostor + Convert to sRGB Pretvori u sRGB + Copies Primerci + Copper Bakar + Copyright Autorska prava + Copyright and Contact Info Autorska prava i kontakt podaci + Corner Crop Marks Ugaone oznake za sečenje + Corner radius Poluprečnik ugla + Correction Korekcija + Count Jitter Kolebanje broja + Create Napravi + Create Droplet Napravi droplet + Create Texture Napravi teksturu + Create Video Timeline Napravi video vremensku liniju + Create a new count group Napravi novu grupu brojanja + Create a new document or open an existing file. Napravite novi dokument ili otvorite postojeću datoteku. + Create a new group Napravi novu grupu + Create a new layer Napravi novi sloj + Create new action Napravi novu radnju + Create new channel Napravi novi kanal + Create new document from current state Napravi novi dokument od tekućeg stanja + Create new fill or adjustment layer Napravi novi sloj popune ili korekcioni sloj + Create new gradient Napravi novi gradijent + Create new group Napravi novu grupu + Create new layer comp Napravi novu kompoziciju slojeva + Create new pattern from the selection Napravi novu šaru od selekcije + Create new shape from the current path Napravi novi oblik od tekuće putanje + Create new snapshot Napravi novi snimak stanja + Create new tool preset Napravi novi šablon alatke + Crop Factor Faktor izreza + Ctrl Ctrl + Current & Below Tekući i ispod + Current Layer Tekući sloj + Current Size Trenutna veličina + Current Tool Only Samo tekuća alatka + Curve Kriva + Curves Krive + Custom Prilagođeno + Custom Shapes Prilagođeni oblici + Cyan Cijan + Cyan · Red Cijan · crvena + Darken Zatamnjenje + Darks Tamni tonovi + Data Sets Skupovi podataka + Default Podrazumevano + Default colours (D) Podrazumevane boje (D) + Default printer Podrazumevani štampač + Default restored. Podrazumevano stanje je vraćeno. + Define Definiši + Define at least one variable first. Prvo definišite bar jednu promenljivu. + Dehaze Uklanjanje izmaglice + Delete Cropped Pixels Obriši isečene piksele + Delete Group Obriši grupu + Delete Layer Mask Obriši masku sloja + Delete Layers Obriši slojeve + Delete Shortcut Obriši prečicu + Delete Timeline Obriši vremensku liniju + Delete Tool Preset Obriši šablon alatke + Delete count group Obriši grupu brojanja + Delete current channel Obriši tekući kanal + Delete current state Obriši tekuće stanje + Delete effect layer Obriši sloj efekta + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Obrisati sloj „{name}“? + Delete {count} layers? {names} Obrisati izabrane slojeve ({count})? {names} + Delete layer? Obrisati sloj? + The document is closed Dokument je zatvoren + The document or selected layers changed. Please request deletion again. Dokument ili izabrani slojevi su se promenili. Ponovo zatražite brisanje. + Missing delete command Nedostaje komanda za brisanje + Invalid delete command Neispravna komanda za brisanje + Layer deleted, but confirmation preference could not be saved: {error} Sloj je obrisan, ali podešavanje potvrde nije moglo da se sačuva: {error} + + Delete layer Obriši sloj + Delete layer comp Obriši kompoziciju slojeva + Delete note Obriši belešku + Delete selected measurements Obriši izabrana merenja + Delete style Obriši stil + Delete tool preset Obriši šablon alatke + Density Gustina + Density: Gustina: + Depth Dubina + Depth Jitter Kolebanje dubine + Description Opis + Deselect All Poništi sav izbor + Detail Detalji + Diamond Gradient Romboidni gradijent + Difference Razlika + Diffusion Difuzija + Discard the embedded profile (don't color manage) Odbaci ugrađeni profil (bez upravljanja bojama) + Discontiguous Nesusedno + Discord Discord + Dismiss Odbaci + Display P3 Display P3 + Display Text Tekst za prikaz + Dither Diterovanje + Divide… Podeli… + Document Dokument + Don't Save Nemoj sačuvati + No Ne + Yes Da + Done Gotovo + Dots Tačke + Drag a rectangle around the object Prevucite pravougaonik oko objekta + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Prevucite po slici da dodate ograničenje; Shift za horizontalno/vertikalno; desni klik za brisanje. + Drag to move the active shape's path or the Work Path Prevucite da pomerite putanju aktivnog oblika ili radnu putanju + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: izaberi sidrišnu tačku · Prevlačenje: pomeri tačku, ručicu ili segment · {shift}-klik: dodaj · {alt}-klik: cela podputanja + Draw with the Pen tool (P) or make a work path from a selection. Crtajte alatkom pero (P) ili napravite radnu putanju od selekcije. + Drop an image or PSD anywhere to open it. Prevucite sliku ili PSD bilo gde da biste je otvorili. + Duplicate Layers… Dupliraj slojeve… + Duplicate style Dupliraj stil + Edit Toolbar… Uredi traku alatki… + Edit in Quick Mask Mode (Q) Uredi u režimu brze maske (Q) + Edit in Standard Mode (Q) Uredi u standardnom režimu (Q) + Effects Efekti + Embed Color Profile Ugradi profil boja + Embedded Profile Mismatch Neslaganje ugrađenog profila + Enable airbrush-style build-up effects Omogući postepeno nagomilavanje kao kod vazdušne četkice + Enhance Edge Poboljšaj ivicu + Entire Font Ceo font + Exact Tačno + Expansion: Proširenje: + Export Presets Izvezi šablone + Export selected measurements (CSV) Izvezi izabrana merenja (CSV) + Exposure Ekspozicija + Exposure and Gamma Ekspozicija i gama + Exposure: Ekspozicija: + Eyedropper Pipeta + Factor: Faktor: + Faux Bold Lažno podebljano + Faux Italic Lažni kurziv + Feather Omekšavanje ivica + Fewer Points Manje tačaka + File Settings Podešavanja datoteke + Fill Popuna + Fill: Popuna: + Filter for adjustment layers Filtriraj korekcione slojeve + Filter for pixel layers Filtriraj piksel slojeve + Filter for shape layers Filtriraj slojeve oblika + Filter for smart objects Filtriraj pametne objekte + Filter for type layers Filtriraj tekstualne slojeve + Filter: Filter: + Finger Painting Slikanje prstom + Fisheye Riblje oko + Fit Uklopi + Fit Screen Uklopi u ekran + Fill Screen Popuni ekran + Fit in View Uklopi u prikaz + Fixed Fiksno + Fixed Ratio Fiksni odnos + Fixed Size Fiksna veličina + Flip Okreni + Flip X Okreni po X + Flip X Jitter Kolebanje okretanja po X + Flip Y Okreni po Y + Flip Y Jitter Kolebanje okretanja po Y + Flow Protok + Flow Jitter Kolebanje protoka + Focal Length Žižna daljina + Font Font + Font Size: Veličina fonta: + Font size Veličina fonta + Foreground Prednji plan + Foreground to Background Od prednjeg plana do pozadine + Foreground/Background Jitter Kolebanje prednjeg plana/pozadine + Format Format + Frame from Layers… Okvir od slojeva… + Free Slobodno + Full Spherical Potpuno sferno + Fuzziness: Rasplinutost: + GPU GPU + Gamma Correction Korekcija game + Gamma: Gama: + Global Globalno + Glow Color Boja sjaja + Gradient Map Mapa gradijenta + Grain Zrno + Gray Siva + Gray Gamma 2.2 Siva gama 2.2 + Green Zelena + Group Grupa + Hardness Tvrdoća + Heart Srce + Height Visina + Height: Visina: + Hide Auto Slices Sakrij automatske isečke + Hide Properties Sakrij svojstva + Highlight Compression Kompresija svetlih tonova + Highlight Priority Prioritet svetlih tonova + Highlights Svetla + Home Početna + Horizontal scale Horizontalna razmera + Hue Nijansa + Hue Jitter Kolebanje nijanse + Hue/Saturation Nijansa/zasićenost + Hyphenate Deljenje reči na slogove + Ignore Zanemari + Ignore All Zanemari sve + Image Size Veličina slike + Import Brushes… Uvezi četkice… + Import brushes (.abr) Uvezi četkice (.abr) + Import Presets Uvezi šablone + Imported Uvezeno + Indent First Uvlačenje prvog reda + Indent Left Levo uvlačenje + Indent Right Desno uvlačenje + Indent first line Uvuci prvi red + Indent left margin Uvlačenje leve margine + Indent right margin Uvlačenje desne margine + Input: Ulaz: + Inside Unutra + Interlaced Isprepletano + Interpolation: Interpolacija: + Intersect with selection ({key}) Presek sa selekcijom ({key}) + Invert All Invertuj sve + Invert has no settings. Invertovanje nema podešavanja. + Italic Kurziv + Join us on Discord Pridružite nam se na Discordu + Justify all Obostrano poravnaj sve redove + Justify last centered Obostrano, poslednji red centriran + Justify last left Obostrano, poslednji red levo + Justify last right Obostrano, poslednji red desno + Kerning Kerning + Keyboard Shortcuts Prečice na tastaturi + Keyboard Shortcuts and Menus Prečice na tastaturi i meniji + Kind Vrsta + Label Size Veličina oznake + Labels Oznake + Lanczos (sharp) Lanczos (oštro) + Landscape Položeno + Last Document State Poslednje stanje dokumenta + Layout Raspored + Leading Prored + Leading (set to the font size × auto-leading when Auto) Prored (veličina fonta × automatski prored kada je Automatski) + Leave as is (don't color manage) Ostavi kako jeste (bez upravljanja bojama) + Left align text Poravnaj tekst levo + Length: Dužina: + Level Nivo + Levels Nivoi + Lighten Posvetljenje + Lightness Svetlina + Lights Svetli tonovi + Limits: Granice: + Linear Gradient Linearni gradijent + Link layers Poveži slojeve + Link width and height Poveži širinu i visinu + Liquify is not open Tečno izobličavanje nije otvoreno + Load Učitaj + Load 3D LUT… Učitaj 3D LUT… + Load channel as selection Učitaj kanal kao selekciju + Load… Učitaj… + Localized Color Clusters Lokalizovane grupe boja + Lock Zaključaj + Lock all Zaključaj sve + Lock image pixels Zaključaj piksele slike + Lock layer Zaključaj sloj + Lock position Zaključaj položaj + Lock transparent pixels Zaključaj providne piksele + Lock: Zaključaj: + Logical Length: Logička dužina: + Logical Units: Logičke jedinice: + Luminance Luminansa + Luminance Detail Detalji luminanse + Magenta Magenta + Magenta · Green Magenta · zelena + Maintain aspect ratio Zadrži odnos stranica + Marker Size Veličina markera + Mask All Maskiraj sve + Mask Options Opcije maske + Masking Maskiranje + Master Glavno + Matte Podloga + Median: Medijana: + Menus Meniji + Mesh Size Veličina mrežice + Metadata Metapodaci + Metrics Metrika + Midpoint Središnja tačka + Midtones Srednji tonovi + Midtones from: Srednji tonovi od: + Midtones to: Srednji tonovi do: + Minimum Depth Minimalna dubina + Minimum Diameter Minimalni prečnik + Minimum Roundness Minimalna zaobljenost + Mode: Režim: + Monochrome Jednobojno + More Points Više tačaka + Move effect layer down Pomeri sloj efekta nadole + Move effect layer up Pomeri sloj efekta nagore + Move to Premesti u + Name Ime + Name: Ime: + Nearest Neighbor Najbliži sused + Nearest Neighbor (hard edges) Najbliži sused (oštre ivice) + New Size Nova veličina + New document… Novi dokument… + New effect layer Novi sloj efekta + New selection Nova selekcija + Next frame Sledeći kadar + Next note Sledeća beleška + No Color Bez boje + No Color Management Bez upravljanja bojama + No Dither Bez diterovanja + No author Nema autora + No character style Bez stila znakova + No document Nema dokumenta + No matching commands Nema odgovarajućih komandi + No presets for the current tool. Nema šablona za tekuću alatku. + No presets of this type. Nema šablona ove vrste. + No properties Nema svojstava + No tool presets. Nema šablona alatki. + Noise Reduction Smanjenje šuma + Normal Normalno + Not in Dictionary: Nije u rečniku: + OK U redu + Offset Pomak + Only Web Colors Samo veb boje + Opacity Neprozirnost + Opacity Jitter Kolebanje neprozirnosti + Opacity: Neprozirnost: + Open a document to define variables. Otvorite dokument da biste definisali promenljive. + Open a document to select a colour range. Otvorite dokument da biste izabrali opseg boja. + Open a document to use the timeline. Otvorite dokument da biste koristili vremensku liniju. + Optical Optički + Optimized Optimizovano + Option-click to define a source point to clone from Option-klik da odredite izvornu tačku za kloniranje + Original Original + Output Channel: Izlazni kanal: + Output Levels: Izlazni nivoi: + Output: Izlaz: + Outside Spolja + Overlay Color Boja preklopa + Overlay Opacity Neprozirnost preklopa + Overlay: Rule of Thirds Preklop: pravilo trećina + Override Pressure Nadjačaj pritisak + Override Rotation Nadjačaj rotaciju + Override Tilt Nadjačaj nagib + PRESET DETAILS DETALJI ŠABLONA + PSD PSD + Paint Overlay Preklop za slikanje + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Položaji panela se čuvaju u radnom prostoru. Prečice na tastaturi, meniji i traka alatki su opcioni. + Paper Papir + Path Putanja + Pattern Šara + Pattern Size Veličina šare + Pause Pauza + Percent Procenat + Perceptual Perceptivno + Perspective Warp is not active Perspektivno krivljenje nije aktivno + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivno krivljenje: nacrtajte ravni duž perspektive slike, a zatim pređite na krivljenje + Photo Filter Foto-filter + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft upravlja bojama + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — izvorni uređivač slika otvorenog koda, napisan u Rust-u. + Pin Depth: Dubina čiode: + Pixel Length: Dužina u pikselima: + Pixels/Centimeter Piksela/centimetar + Pixels/Inch Piksela/inč + Pixels: Pikseli: + Play Pusti + Play selection Pusti izabrano + Portrait Uspravno + Position Položaj + Position and Size Položaj i veličina + Posterize Posterizacija + Preserve Details Očuvaj detalje + Preserve Luminosity Očuvaj osvetljenje + Preset Šablon + Preset Type Vrsta šablona + Preset: Šablon: + Presets: Šabloni: + Press keys… Pritisnite tastere… + Pressure Pritisak + Pressure for Opacity Pritisak za neprozirnost + Pressure for Size Pritisak za veličinu + Prevent auto-nesting in and out of Artboards and Frames Spreči automatsko ugnežđavanje u radne table i okvire i izvlačenje iz njih + Preview Pregled + Previous frame Prethodni kadar + Previous note Prethodna beleška + Print Štampaj + Printer Štampač + Printer Manages Colors Štampač upravlja bojama + Printer Setup Podešavanje štampača + Printing Marks Oznake za štampu + Progressive Progresivno + Promote Unapredi + Protect Detail Zaštiti detalje + Protect Foreground Color Zaštiti boju prednjeg plana + Protect Tones Zaštiti tonove + Proximity Match Podudaranje po blizini + Pulled String Mode Režim zategnute niti + Puppet Warp is not active Lutkarsko krivljenje nije aktivno + Purity Čistoća + Quality Kvalitet + Quick Actions Brze radnje + Quick Export As PNG Brzi izvoz u PNG + Radial Gradient Radijalni gradijent + Radius Poluprečnik + Radius: Poluprečnik: + Range: Opseg: + Rate Brzina + Recently Used Nedavno korišćeno + Reconstruct Rekonstruiši + Record ● a sequence of edits, then play ▶ it on any document. Snimite ● niz izmena, a zatim ga pustite ▶ na bilo kom dokumentu. + Red Crvena + Redefine style by current selection Redefiniši stil prema tekućoj selekciji + Refine Doteraj + Reflected Gradient Reflektovani gradijent + Registration Marks Oznake registracije + Regular Obično + Relative Relativno + Relative Colorimetric Relativno kolorimetrijski + Remove Ukloni + Remove All Pins Ukloni sve čiode + Remove Background Ukloni pozadinu + Remove Planes Ukloni ravni + Remove Tool Alatka za uklanjanje + Remove stop Ukloni tačku + Remove variable Ukloni promenljivu + Rename Preimenuj + Rename Group… Preimenuj grupu… + Rename Layer… Preimenuj sloj… + Rename Tool Preset… Preimenuj šablon alatke… + Rename… Preimenuj… + Resample Ponovo uzorkuj + Reset All to Defaults Vrati sve na podrazumevano + Import Shortcuts… Uvezi prečice… + Load a keyboard shortcut set saved as .kys Učitaj skup prečica sačuvan kao .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Uvezeno {n} prečica iz {file}. Kliknite U redu da ih zadržite. + {n} commands are not in PhotoCraft. {n} komandi ne postoji u PhotoCraft-u. + Keyboard shortcuts imported Prečice na tastaturi su uvezene + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Ovde se koristi vaš skup prečica {set}: {n} prečica se razlikuje od podrazumevanih. Uređivanje › Prečice na tastaturi ih prikazuje; Vrati sve na podrazumevano poništava ovo. + Import keyboard shortcuts? Uvesti prečice na tastaturi? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft je na ovom računaru pronašao vaš skup prečica {set} iz drugog uređivača slika: {n} prečica se razlikuje od PhotoCraft-ovih. Uvesti ga? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Uređivanje › Prečice na tastaturi › Vrati sve na podrazumevano poništava uvoz. + Don't Import Nemoj uvesti + The keyboard shortcut set was not imported: {error} Skup prečica nije uvezen: {error} + Reset Section Resetuj odeljak + Reset Transform Resetuj transformaciju + Reset to defaults Vrati na podrazumevano + Resolution Rezolucija + Resolution: Rezolucija: + Restore All Vrati sve + Restore Defaults Vrati podrazumevano + Restrictive (Web) Restriktivno (veb) + Right align text Poravnaj tekst desno + Rigid Kruto + Rotate: Rotacija: + Rotation Rotacija + Roughness Hrapavost + Roundness Zaobljenost + Roundness Jitter Kolebanje zaobljenosti + Rulers & Grids Lenjiri i mreže + Sample All Layers Uzorkuj sve slojeve + Sample: Uzorak: + Sampling: Background Swatch Uzorkovanje: uzorak pozadine + Sampling: Continuous Uzorkovanje: neprekidno + Sampling: Once Uzorkovanje: jednom + Saturate Zasiti + Saturation Zasićenost + Saturation Jitter Kolebanje zasićenosti + Save HTML and Images Sačuvaj HTML i slike + Save as PDF Sačuvaj kao PDF + Save selection as channel Sačuvaj selekciju kao kanal + Save… Sačuvaj… + Scale to Fit Media Skaliraj prema medijumu + Scatter Rasejavanje + Scratch disks Radni diskovi + Search Pretraga + Search commands Pretraži komande + Search commands, tools and panels… Pretraži komande, alatke i panele… + Search fonts Pretraži fontove + Select All Izaberi sve + Select Subject Izaberi subjekat + Select a type layer or click into text to insert glyphs Izaberite tekstualni sloj ili kliknite u tekst da umetnete glifove + Select text to apply a character style Izaberite tekst da primenite stil znakova + Select: Izaberi: + Selections Selekcije + Selective Selektivno + Selective Color Selektivna boja + Send the selected pin backward Pomeri izabranu čiodu nazad + Set additional smoothing options Podesi dodatne opcije izglađivanja + Set background color Postavi boju pozadine + Set foreground color Postavi boju prednjeg plana + Set painting symmetry options Podesi opcije simetrije slikanja + Symmetry Off Simetrija isključena + Set shape fill type Podesi vrstu popune oblika + Set shape stroke type Podesi vrstu obruba oblika + Set the text color Postavi boju teksta + Shadows Senke + Shape: Oblik: + Sharpening Izoštravanje + Shift Shift + Shortcut removed. Prečica je uklonjena. + Show All Menu Items Prikaži sve stavke menija + Show Backdrop Prikaži podlogu + Show Guides Prikaži vodilje + Show Mask Prikaži masku + Show Mesh Prikaži mrežicu + Show Overlay Prikaži preklop + Show Transform Controls Prikaži kontrole transformacije + Show or hide the Notes panel Prikaži ili sakrij panel Beleške + Show: Prikaži: + Sides Stranice + Sides: Stranice: + Size Veličina + Size Jitter Kolebanje veličine + Size: Veličina: + Slice Options… Opcije isečka… + Slices From Guides Isečci od vodilja + Slices are locked (View › Lock Slices) Isečci su zaključani (Prikaz › Zaključaj isečke) + Small Caps Kapitalke + Smoothing Izglađivanje + Space After Razmak posle + Space Before Razmak pre + Spacing Razmak + Spell check complete. Provera pravopisa je završena. + Split: Podela: + Star ratio Odnos zvezde + Stop playing/recording Zaustavi puštanje/snimanje + Straighten Ispravi + Straighten Layer Ispravi sloj + Strength: Jačina: + Strikethrough Precrtano + Stroke Obrub + Stroke Catch-up Sustizanje poteza + Stroke: Obrub: + Location: Položaj: + Style Stil + Style Options Opcije stila + Style Options… Opcije stila… + Style: Normal Stil: normalno + Subtract from Sample Oduzmi od uzorka + Subtract from selection ({key}) Oduzmi od selekcije ({key}) + Suggestions: Predlozi: + Swap colours (X) Zameni boje (X) + Swaps height and width Zamenjuje visinu i širinu + Switch between free transform and warp modes Prebaci između režima slobodne transformacije i krivljenja + Switch theme Promeni temu + Temperature Temperatura + Tetrahedral Tetraedarski + Text Position: Položaj teksta: + Texture Tekstura + Texture Each Tip Tekstura za svaki vrh + Threshold Prag + Threshold Level Nivo praga + Thumbnail size (smallest: list) Veličina sličica (najmanja: spisak) + Tilt X Nagib X + Tilt Y Nagib Y + Tint Preliv + Toggle count group visibility Uključi/isključi vidljivost grupe brojanja + Toggle text orientation Promeni orijentaciju teksta + Tolerance Tolerancija + Tolerance: Tolerancija: + Tone: Ton: + Toolbar Traka alatki + Top Gore + Total: Ukupno: + Tracking Razmak između slova + Tracking (1/1000 em) Razmak između slova (1/1000 em) + Transparency Providnost + Transparent Providno + Trilinear Trilinearno + Type Styles Stilovi teksta + Type: Vrsta: + Underline Podvučeno + Untitled Bez naslova + Untitled-1 Bez naslova-1 + Update layer comp (visibility, position and appearance) Ažuriraj kompoziciju slojeva (vidljivost, položaj i izgled) + Use Default Koristi podrazumevano + Use Legacy Koristi staro + Use Measurement Scale Koristi razmeru merenja + Use the embedded profile (instead of the working space) Koristi ugrađeni profil (umesto radnog prostora) + Variables bind a layer's visibility, text or pixels to a named data slot. Promenljive vezuju vidljivost, tekst ili piksele sloja za imenovano polje podataka. + Vertical scale Vertikalna razmera + Vibrance Živost + View Options Opcije prikaza + Vignetting Vinjetiranje + Violet, Orange Ljubičasta, narandžasta + Visibility Vidljivost + Visible Vidljivo + Warp: Krivljenje: + Web Snap Prianjanje uz veb boje + Weight Debljina + Weight: Debljina: + White Bela + White Balance: As Shot Balans bele: kao pri snimanju + Whites Bele + Width Širina + Slant Iskošenost + Width: Širina: + Work Path Radna putanja + Yellow Žuta + Yellow · Blue Žuta · plava + Zoom Zum + Zoom in Uvećaj + Zoom out Umanji + command or shortcut komanda ili prečica + current tekuće + image file path putanja datoteke slike + modified izmenjeno + name ime + new novo + {name} options Opcije: {name} + ↑↓ navigate {key} run esc close ↑↓ kretanje {key} pokreni esc zatvori + Version {version} Verzija {version} + New Document Novi dokument + New Document With Mask Novi dokument sa maskom + New Layer With Mask Novi sloj sa maskom + New Layer Novi sloj + Preset Name Ime šablona + No saved presets yet. Još nema sačuvanih šablona. + Error Greška + Command Komanda + Adjustment layers have nothing to transform Korekcioni slojevi nemaju ništa što bi se transformisalo + Altitude Visina + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45 mm + Aqua Akva + Art & Illustration Umetnost i ilustracija + ArtCraft ArtCraft + Background Eraser Tool Alatka gumica za pozadinu + Bevel & Emboss Kosina i reljef + Blend Mode Režim stapanja + Blues Plave + Blur Tool Alatka za zamućivanje + Bold Podebljano + Brush Četkica + Block Blok + Brush Pose Položaj četkice + Brush Tip Shape Oblik vrha četkice + Brush Tool Alatka četkica + Build-up Nagomilavanje + Burn Tool Alatka za zatamnjivanje + Centimeters Centimetri + Choke Sužavanje + Classic Klasično + Click to add text Kliknite da dodate tekst + Click to fill similar colours Kliknite da popunite slične boje + Clone Stamp Tool Alatka pečat za kloniranje + Color Burn Zatamnjivanje boje + Color Dodge Posvetljivanje boje + Color Dynamics Dinamika boje + Color Overlay Prekrivanje bojom + Comic Book Strip + Conform Usaglašeno + Content-Aware Fill needs a selection on a pixel layer Popuna prema sadržaju zahteva selekciju na piksel sloju + Could not transform: the layer is empty Transformacija nije moguća: sloj je prazan + Could not use Perspective Warp: the layer is empty Perspektivno krivljenje nije moguće: sloj je prazan + Could not use Puppet Warp: no opaque pixels Lutkarsko krivljenje nije moguće: nema neprozirnih piksela + Could not use Puppet Warp: the layer is empty Lutkarsko krivljenje nije moguće: sloj je prazan + Crop Tool Alatka za opsecanje + Crosswise Unakrsno + Current Tool Tekuća alatka + Custom Shape Tool Alatka za prilagođeni oblik + Cyans Cijan + Default PhotoCraft Size Podrazumevana veličina PhotoCraft-a + Delete Workspace Obriši radni prostor + Diamond Romb + Dimensions: Dimenzije: + Direction Smer + Distance Udaljenost + Document Dimensions Dimenzije dokumenta + Document Profile Profil dokumenta + Document Sizes Veličine dokumenta + Dodge Tool Alatka za posvetljivanje + Down Nadole + Drag to draw a gradient Prevucite da nacrtate gradijent + Drag to move the active layer Prevucite da pomerite aktivni sloj + Drag to pan · hold Space with any tool Prevucite da pomerate prikaz · držite razmaknicu uz bilo koju alatku + Drop Shadow Bačena senka + Dual Brush Dvostruka četkica + Edge Ivica + Efficiency Efikasnost + Efficiency: 100% Efikasnost: 100% + Ellipse Tool Alatka elipsa + Elliptical Marquee Tool Alatka za eliptičnu selekciju + Single Row Marquee Tool Alatka za selekciju jednog reda + Single Column Marquee Tool Alatka za selekciju jedne kolone + Emboss Reljef + Eraser Gumica + Eraser Tool Alatka gumica + ExtraBold Ekstra podebljano + ExtraLight Ekstra lagano + Eyedropper Tool Alatka pipeta + Fade Iščezavanje + Fill Opacity Neprozirnost popune + Film & Video Film i video + From Od + General Opšte + GitHub GitHub + Gradient Overlay Prekrivanje gradijentom + Gradient Tool Alatka gradijent + Greens Zelene + Hand Tool Alatka ruka + Hard Tvrdo + Hard Mix Oštro mešanje + Healing Brush Tool Alatka četkica za popravku + Highlights from: Svetla od: + History Brush Tool Alatka četkica istorije + Horizontal Type Tool Alatka za horizontalni tekst + Horizontal Type Mask Tool Alatka za horizontalnu masku teksta + Vertical Type Mask Tool Alatka za vertikalnu masku teksta + Image Size: Veličina slike: + Inches Inči + Indexed Indeksirano + Initial Direction Početni smer + Inner Bevel Unutrašnja kosina + Inner Glow Unutrašnji sjaj + Inner Shadow Unutrašnja senka + Lab Lab + Landscape, 10 x 8 Položeno, 10 x 8 + Landscape, 6 x 4 Položeno, 6 x 4 + Landscape, 7 x 5 Položeno, 7 x 5 + Lasso Laso + Lasso Tool Alatka laso + Layer Count Broj slojeva + Layer Knocks Out Drop Shadow Sloj izbija bačenu senku + Legal Legal + Letter Letter + Light Svetlo + Line Tool Alatka linija + Linear Linearno + Linear Burn Linearno zatamnjivanje + Linear Height Linearna visina + Link with Layer Poveži sa slojem + Lorem Ipsum Lorem Ipsum + Magentas Magenta + Magic Eraser Tool Alatka čarobna gumica + Magic Wand Tool Alatka čarobni štapić + Marquee Okvirna selekcija + Mean: Srednja vrednost: + Measurement Scale Razmera merenja + Measurement Scale Marker Oznaka razmere merenja + Millimeters Milimetri + Mobile Mobilni + Move Tool Alatka za pomeranje + Could not use the move tool because the layer is locked. Alatka za pomeranje ne može da se koristi jer je sloj zaključan. + Could not complete the Free Transform command because the layer is locked. Komanda Slobodna transformacija ne može da se izvrši jer je sloj zaključan. + Multiply Množenje + My Brushes Moje četkice + Neutrals Neutralne + New Workspace Novi radni prostor + Note Tool Alatka za beleške + Object Selection Tool Alatka za selekciju objekta + Off Isključeno + Open Otvori + Orange Narandžasta + Original Ratio Originalni odnos + Out Of Gamut Van gamuta + Outer Bevel Spoljašnja kosina + Outer Glow Spoljašnji sjaj + Overlay Preklapanje + Paint Bucket Tool Alatka kofica s bojom + Paints from the document's opening state Slika iz stanja dokumenta pri otvaranju + Path Selection Tool Alatka za selekciju putanje + Direct Selection Tool Alatka za direktnu selekciju + Add Anchor Point Tool Alatka za dodavanje sidrišne tačke + Delete Anchor Point Tool Alatka za brisanje sidrišne tačke + Click a segment to add an anchor point · {alt}-click an anchor to delete it Kliknite segment da dodate sidrišnu tačku · {alt}-klik na tačku da je obrišete + Click an anchor point to delete it · {alt}-click a segment to add one Kliknite sidrišnu tačku da je obrišete · {alt}-klik na segment da dodate novu + Pattern Overlay Prekrivanje šarom + Pen Pressure Pritisak olovke + Pen Tilt Nagib olovke + Pen Tool Alatka pero + Convert Point Tool Alatka za pretvaranje tačke + Photo Fotografija + PhotoCraft website Veb-sajt PhotoCraft-a + Picas Pike + Pillow Emboss Jastučasti reljef + Pixel Replacement Zamena piksela + Pixels Pikseli + Points Tačke + Polygon Tool Alatka mnogougao + Polygonal Lasso Tool Alatka mnogougaoni laso + Portrait, 4 x 6 Uspravno, 4 x 6 + Portrait, 5 x 7 Uspravno, 5 x 7 + PostScript (72 points/inch) PostScript (72 tačke/inč) + Postcard Razglednica + Poster Poster + Pro (Dark) Pro (tamna) + Pro (Light) Pro (svetla) + Pro (Light Gray) Pro (svetlosiva) + Pro (Medium Gray) Pro (srednje siva) + Protect Texture Zaštiti teksturu + Purple Ljubičasta + Quick Mask Brza maska + Quick Selection Tool Alatka za brzu selekciju + Radial Radijalno + Rasterize Layer Rasterizuj sloj + Rasterize Layers Rasterizuj slojeve + Ratio Odnos + Recent Nedavno + Rectangle Tool Alatka pravougaonik + Rectangular Marquee Tool Alatka za pravougaonu selekciju + Reds Crvene + Reflected Reflektovano + Sample: foreground colour Uzorak: boja prednjeg plana + Sampled Colors Uzorkovane boje + Satin Saten + Save Pixel Aspect Ratio Sačuvaj odnos stranica piksela + Scattering Rasejavanje + Scratch Sizes Veličine radnog diska + Select Data Points Izaberi tačke podataka + Selection Selekcija + SemiBold Polupodebljano + Shadows up to: Senke do: + Shape Dynamics Dinamika oblika + Sharpen Tool Alatka za izoštravanje + Show Extras Options Opcije prikaza dodatnih elemenata + Slice Select Tool Alatka za izbor isečka + Slice Tool Alatka za isecanje na isečke + Smart Filters Pametni filteri + Smudge Tool Alatka za razmazivanje + Soft Meko + Soften Omekšaj + Source Izvor + Source: set Izvor: postavljen + Sponge Tool Alatka sunđer + Spot Healing Brush Tool Alatka četkica za tačkastu popravku + Spread Raširenost + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Std Dev: Std. devijacija: + Studio (Dark) Studio (tamna) + Studio (Light) Studio (svetla) + Stylus Wheel Točkić olovke + Subtract Oduzimanje + Tabloid Tabloid + Text Replacement Zamena teksta + Thin Tanko + Traditional (72.27 points/inch) Tradicionalno (72,27 tačaka/inč) + Transfer Prenos + Triangle Tool Alatka trougao + Unsaved changes Nesačuvane izmene + Uses the foreground and background colours. Koristi boje prednjeg plana i pozadine. + Web Veb + Web Large Veb, veliko + Web Minimum Veb, minimum + Web Most Common Veb, najčešće + WebP (lossless) WebP (bez gubitaka) + Wet Edges Vlažne ivice + Yellows Žute + Zoom Tool Alatka lupa + {name} Properties Svojstva: {name} + Revert “{name}” to the last saved version? Your changes will be lost. Vratiti „{name}“ na poslednju sačuvanu verziju? Vaše izmene će biti izgubljene. + Do you want to save the changes you made to “{name}” before quitting? Želite li da sačuvate izmene u „{name}“ pre izlaska? + Do you want to save the changes you made to “{name}” before closing? Želite li da sačuvate izmene u „{name}“ pre zatvaranja? + Doc: {flat}/{layered} Dok.: {flat}/{layered} + Scratch: {size} Radni disk: {size} + Untagged {mode} {mode} bez oznake profila + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch Rezolucija: {dpi} piksela/inč + Doc: {w} × {h} px Dok.: {w} × {h} px + Frame {current} / {total} Kadar {current} / {total} + Count: {n} Broj: {n} + BLANK DOCUMENT PRESETS ({n}) ŠABLONI PRAZNIH DOKUMENATA ({n}) + Constraints ({n}) Ograničenja ({n}) + Align {what} Poravnaj: {what} +@plural {n} Layer|{n} Layers {n} sloj|{n} sloja|{n} slojeva +@plural {n} layer|{n} layers {n} sloj|{n} sloja|{n} slojeva +@plural Group · {n} layer|Group · {n} layers Grupa · {n} sloj|Grupa · {n} sloja|Grupa · {n} slojeva + Additional plugins folder Dodatna fascikla za dodatke + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Dozvoli upravljanje agentima + Allow scripts to connect Dozvoli skriptama da se povežu + Always Uvek + Always create smart objects when placing Uvek pravi pametne objekte pri postavljanju + Animated zoom Animirano zumiranje + Apply auto tone Primeni automatski ton + Ask Pitaj + Ask before saving layered tiff Pitaj pre čuvanja TIFF-a sa slojevima + Auto collapse icon panels Automatski skupi panele sa ikonama + Auto show hidden panels Automatski prikaži skrivene panele + Auto show home screen Automatski prikaži početni ekran + Autosave Automatsko čuvanje + Autosave minutes Automatsko čuvanje (minuti) + Beep when done Zvučni signal po završetku + Bicubic automatic Bikubično automatski + Bicubic sharper Bikubično oštrije + Bicubic smoother Bikubično glađe + Bit depth Dubina bita + Brush preview color Boja pregleda četkice + Cache levels Nivoi keša + Cache tile size Veličina pločice keša + Camera Raw Defaults Podrazumevana Camera Raw podešavanja + Canvas border Ivica platna + Show canvas dots Prikaži tačke na platnu + Canvas color Boja platna + Canvas custom color Prilagođena boja platna + Color picker Birač boja + Color space Prostor boja + Column width Širina kolone + Concise Sažeto + Control port Kontrolni port + Convert to srgb Pretvori u sRGB + Cursors Kursori + Custom dark Prilagođena tamna + Custom light Prilagođena svetla + Dark Tamna + Dark gray Tamnosiva + Dashed lines Isprekidane linije + Destination Odredište + Detailed Detaljno + Disks Diskovi + Display p3 Display P3 + Double click layer mask launches select and mask Dvoklik na masku sloja otvara Izaberi i maskiraj + Drop shadow Bačena senka + Dynamic color sliders Dinamički klizači boja + East asian Istočnoazijsko + Effect cache mb Keš efekata (MB) + Enable flick panning Omogući pomeranje prikaza zamahom + Enable floating document window docking Omogući usidravanje plutajućih prozora dokumenata + Enable narrow options bar Omogući usku traku opcija + Enabled Omogućeno + Enhanced Controls Napredne kontrole + Export clipboard Izvezi ostavu + Extra large Veoma velika + File Handling Rukovanje datotekama + File path Putanja datoteke + Fill new type layers with placeholder Popuni nove tekstualne slojeve probnim tekstom + Font preview Pregled fonta + Full size tip Vrh pune veličine + Gamut warning color Boja upozorenja o gamutu + Gamut warning opacity Neprozirnost upozorenja o gamutu + Generator enabled Generator je omogućen + Gif GIF + Glossy Sjajno + Grid color Boja mreže + Grid colors Boje mreže + Grid size Veličina mreže + Grid style Stil mreže + Grid unit Jedinica mreže + Gridline every Linija mreže na svakih + Guide color Boja vodilja + Guide style Stil vodilja + Guides, Grid & Slices Vodilje, mreža i isečci + Gutter Žleb + History Log Dnevnik istorije + History states Stanja istorije + Ignore exif profile tag Zanemari EXIF oznaku profila + Image interpolation Interpolacija slike + Image previews Pregledi slika + Integrations Integracije + Interface Interfejs + Jpeg quality JPEG kvalitet + Jpg JPG + Language Jezik + Large tabs Velike kartice + Legacy compositing Staro komponovanje + Light gray Svetlosiva + Line Linija + Lines Linije + Lossless Bez gubitaka + Lowercase extension Ekstenzija malim slovima + Maximize psd compatibility Maksimalna kompatibilnost PSD-a + Medium gray Srednje siva + Memory usage mb Korišćenje memorije (MB) + Low resolution previews Pregledi niske rezolucije + Missing glyph protection Zaštita od glifova koji nedostaju + Modern Moderno + Nearest neighbor Najbliži sused + Never Nikad + Normal tip Normalan vrh + Open as smart object Otvori kao pametni objekat + Open in camera raw Otvori u Camera Raw-u + Open documents as tabs Otvaraj dokumente kao kartice + Overscroll Pomeranje preko ivice + Performance Performanse + Png PNG + Point size Veličina tačke + Precise Precizno + Preserve details Očuvaj detalje + Print resolution Rezolucija štampe + Pro Pro + Pro light Pro svetla + Pro light gray Pro svetlosiva + Pro medium Pro srednja + Pro photo ProPhoto + Quick export format Format brzog izvoza + Quick export location Lokacija brzog izvoza + Recent file count Broj nedavnih datoteka + Recent files Nedavne datoteke + Recent fonts Nedavni fontovi + Recover on launch Oporavi pri pokretanju + Remember workspace changes Zapamti izmene radnog prostora + Resize image during place Promeni veličinu slike pri postavljanju + Rotate view with trackpad Rotiraj prikaz pomoću dodirne table + Same folder Ista fascikla + Save in background Čuvaj u pozadini + Scratch Disks Radni diskovi + Screen Ekran + Screen resolution Rezolucija ekrana + Scrubby slider acceleration Ubrzanje klizača prevlačenjem + Sessions only Samo sesije + Sharpen for Izoštri za + Show channels in color Prikaži kanale u boji + Show crosshair in brush tip Prikaži krstić u vrhu četkice + Show extension panels Prikaži panele proširenja + Show font names in english Prikaži imena fontova na engleskom + Show menu colors Prikaži boje menija + Show only crosshair while painting Prikaži samo krstić tokom slikanja + Show slice numbers Prikaži brojeve isečaka + Show tooltips Prikaži opise alatki + Enable animations Omogući animacije + Show highlight on rollover Istakni pri prelasku pokazivačem + Show transformation values Prikaži vrednosti transformacije + Slice color Boja isečka + Smart guide color Boja pametnih vodilja + Smart quotes Pametni navodnici + Snap vector tools and transforms to pixel grid Prianjaj vektorske alatke i transformacije uz mrežu piksela + Srgb sRGB + Standard Standardno + Studio Studio + Studio light Studio svetla + Adwaita Adwaita + Adwaita dark Adwaita tamna + Subdivisions Pododeljci + System Sistem + Text engine Mašina za tekst + Text file Tekstualna datoteka + Tiny Sićušno + Touch gestures Pokreti na dodirnom ekranu + Transparency & Gamut Providnost i gamut + Type units Jedinice teksta + UI font size Veličina fonta interfejsa + UI scale Razmera interfejsa + Units & Rulers Jedinice i lenjiri + Use additional plugins folder Koristi dodatnu fasciklu za dodatke + Use esc to commit Koristi Esc za potvrdu + Use gpu Koristi GPU + Use legacy free transform Koristi staru slobodnu transformaciju + Use shift key for tool switch Koristi Shift za promenu alatke + Vary round brush hardness on hud Menjaj tvrdoću okrugle četkice na HUD-u + Webp WebP + World ready Za sva pisma sveta + Zoom clicked point to center Zumiraj tako da kliknuta tačka bude u centru + Zoom resizes windows Zumiranje menja veličinu prozora + Zoom with scroll wheel Zumiraj točkićem miša + Zoom with trackpad pinch Zumiraj štipanjem na dodirnoj tabli + Pass Through Propuštanje + Dissolve Rastapanje + Darker Color Tamnija boja + Linear Dodge (Add) Linearno posvetljivanje (sabiranje) + Lighter Color Svetlija boja + Soft Light Meko svetlo + Hard Light Oštro svetlo + Vivid Light Živo svetlo + Linear Light Linearno svetlo + Pin Light Tačkasto svetlo + Exclusion Isključenje + Divide Deljenje + Luminosity Svetlina tona +@plural {n} sample|{n} samples {n} uzorak|{n} uzorka|{n} uzoraka + {added}, {removed} subtracted {added}, oduzeto: {removed} + Adjustment Preview Pregled korekcije + Airbrush: keeps depositing paint while the pointer rests. Vazdušna četkica: nastavlja da nanosi boju dok pokazivač miruje. + Angle: Ugao: + Blend If Stapaj ako + Close Tab Group Zatvori grupu kartica + Collapse Skupi + Collapse All Groups Skupi sve grupe + Collapse Panel Group Skupi grupu panela + Collapse group ({key}-click: all groups) Skupi grupu ({key}-klik: sve grupe) + Create new brush from the current settings Napravi novu četkicu od tekućih podešavanja + Create new brush from these settings Napravi novu četkicu od ovih podešavanja + Update the current brush with these settings Ažuriraj tekuću četkicu ovim podešavanjima + Delete brush Obriši četkicu + Drag the arrow to set the angle, the dots to set the roundness Prevucite strelicu da podesite ugao, a tačke da podesite zaobljenost + Expand Proširi + Expand Panel Group Proširi grupu panela + Expand group ({key}-click: all groups) Proširi grupu ({key}-klik: sve grupe) + Fade steps Koraci iščezavanja + Hide the layer's effects ({key}-click: all layers) Sakrij efekte sloja ({key}-klik: svi slojevi) + Import Brushes… (.abr) Uvezi četkice… (.abr) + Move Group Down Pomeri grupu nadole + Move Group Up Pomeri grupu nagore + Reset the brush to the defaults Vrati četkicu na podrazumevano + Restore original size Vrati originalnu veličinu + Roundness: Zaobljenost: + Search Brushes Pretraži četkice + Show the layer's effects ({key}-click: all layers) Prikaži efekte sloja ({key}-klik: svi slojevi) + Brush picker Birač četkica + Erase Briši + Right click with painting tools Desni klik alatkama za slikanje + System Info… Informacije o sistemu… + System Info Informacije o sistemu + More panels Više panela + More documents Više dokumenata + These settings aren't available in PhotoCraft yet. Ova podešavanja još nisu dostupna u PhotoCraft-u. + Graphics Grafika + Reset GPU Backend Resetuj GPU pozadinski sistem + Applies at next launch. Primenjuje se pri sledećem pokretanju. + Click to sample the foreground colour · {key}-click for background Kliknite da uzorkujete boju prednjeg plana · {key}-klik za pozadinu + {key}-click to set the source {key}-klik da postavite izvor + {key} to subtract {key} za oduzimanje + Clone source {n} ({key}-click with Clone Stamp to set) Izvor kloniranja {n} ({key}-klik pečatom za kloniranje da postavite) + Source: not set ({key}-click with Clone Stamp) Izvor: nije postavljen ({key}-klik pečatom za kloniranje) + Pixel Layer Piksel sloj + Type Layer Tekstualni sloj + Shape Layer Sloj oblika + Group Layer Sloj grupe + Fill Layer Sloj popune + Smart Object Layer Sloj pametnog objekta + Create Warped Text Napravi iskrivljeni tekst + Rasterize Smart Object Rasterizuj pametni objekat + Rasterize Fill Content Rasterizuj sadržaj popune + Rasterize Layer Style Rasterizuj stil sloja + Unlock: picking a preset replaces these settings Otključaj: izbor šablona zamenjuje ova podešavanja + Lock: keep these settings when picking another preset Zaključaj: zadrži ova podešavanja pri izboru drugog šablona + Diameter: Prečnik: + Hardness: Tvrdoća: + No brush presets Nema šablona četkica + Toggle the Brush Settings panel (F5) Prikaži/sakrij panel Podešavanja četkice (F5) + Create new brush preset from the current settings Napravi novi šablon četkice od tekućih podešavanja + Brush Settings… (F5): every option of this brush Podešavanja četkice… (F5): sve opcije ove četkice + Rename Brush… Preimenuj četkicu… + Delete Brush Obriši četkicu + New Brush Preset… Novi šablon četkice… + Capture Brush Size in Preset Sačuvaj veličinu četkice u šablonu + Include Tool Settings Uključi podešavanja alatke + Include Color Uključi boju + Enter a name for the brush preset Unesite ime šablona četkice + A brush preset with this name already exists Šablon četkice sa ovim imenom već postoji + Brush Preset Options Opcije šablona četkice + Group name Ime grupe + Brush name Ime četkice + Brush Name Ime četkice + Brush Stroke Potez četkicom + Off: the speed of the pointer sets the spacing Isključeno: brzina pokazivača određuje razmak + Tilt Scale Razmera nagiba + Brush Projection Projekcija četkice + Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha + Mixer Brush Četkica za mešanje + Wet Vlažnost + Mix Mešanje + Wetness Jitter Kolebanje vlažnosti + Mix Jitter Kolebanje mešanja + Show the layer mask as an overlay Prikaži masku sloja kao preklop + Toggle visibility Uključi/isključi vidljivost + Unlink the mask from the layer Razveži masku od sloja + Link the mask to the layer Poveži masku sa slojem + Gradient Gradijent + Gradient Options Opcije gradijenta + Align with layer Poravnaj sa slojem + Reset Alignment Resetuj poravnanje + Centre the gradient (offset 0, 0) Centriraj gradijent (pomak 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Uređivač gradijenta + Gradient picker Birač gradijenta + Save As Preset Sačuvaj kao šablon + The gradient the Gradient tool paints with Gradijent kojim slika alatka gradijent + Adds the current gradient to the Gradients panel Dodaje tekući gradijent u panel Gradijenti + Gradient stops Tačke gradijenta + Select a type layer to edit its character and paragraph settings. Izaberite tekstualni sloj da biste uredili podešavanja znakova i pasusa. + Text color Boja teksta + Type Options Opcije teksta + Anti-aliasing method Metod uglađivanja ivica + Horizontal type Horizontalni tekst + Vertical type Vertikalni tekst + Fill path with foreground color Popuni putanju bojom prednjeg plana + Stroke path with brush Ocrtaj putanju četkicom + Load path as a selection Učitaj putanju kao selekciju + Make work path from selection Napravi radnu putanju od selekcije + Add vector mask Dodaj vektorsku masku + Create new path Napravi novu putanju + Delete path Obriši putanju + Delete vector mask Obriši vektorsku masku + CPU (no GPU acceleration) CPU (bez GPU ubrzanja) + DirectX 12 DirectX 12 + GPU backend GPU pozadinski sistem + Linux display server Linux server prikaza + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure Koristi pritisak na tabletu + Vulkan Vulkan + Alt-click to define a source point to clone from Alt-klik da odredite izvornu tačku za kloniranje + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje od koje bi se napravila selekcija: nacrtajte je alatkom pero ili je izaberite u panelu Putanje. + Cancel opening Otkaži otvaranje + Cancelled opening {name} Otkazano otvaranje: {name} + Developing {name} Razvijanje: {name} + Cancelled {label} Otkazano: {label} + Esc to cancel Esc za otkazivanje + Opened {name} Otvoreno: {name} + Opening {name} Otvaranje: {name} + Saved {name} Sačuvano: {name} + Saving {name} Čuvanje: {name} + Saving {percent} Čuvanje {percent} + A save to {name} is still running; try again when it finishes Čuvanje u {name} je još u toku; pokušajte ponovo kada se završi + {label} is writing the file and can no longer be cancelled {label}: datoteka se upisuje i više ne može da se otkaže + Opening {name}… Otvaranje: {name}… + Opening… Otvaranje… + Reading… Čitanje… + Working… Obrada… +@id select.all Izaberi sve +@id edit.purge.undo Podaci za opoziv + Delete Smart Filters Obriši pametne filtere + Enable Smart Filters Omogući pametne filtere + New Layer… Novi sloj… + Delete Layer… Obriši sloj… + Color Range Opseg boja + Select Similar Colors Izaberi slične boje + New Selection Nova selekcija + Adaptive Wide Angle Prilagodljivi široki ugao + Chalk & Charcoal Kreda i ugalj + Charcoal Ugalj + Chrome Hrom + Conté Crayon Konte krejon + Graphic Pen Grafičko pero + Halftone Pattern Polutonska šara + Note Paper Papir za beleške + Photocopy Fotokopija + Plaster Gips + Reticulation Retikulacija + Stamp Pečat + Torn Edges Pocepane ivice + Water Paper Vodeni papir + Canvas rotation Rotacija platna + Extensions Proširenja + Libraries Biblioteke + 3D 3D + Toolbars Trake alatki + Move Pomeranje + Wand Štapić + Pencil Olovka + Clone Stamp Pečat za kloniranje + Healing Popravka + Dodge Posvetljivanje + Burn Zatamnjivanje + Pen Pero + Hand Ruka + Sampler Uzorkivač + Note tool Alatka za beleške + Quick Selection Brza selekcija + Magnetic Lasso Magnetni laso + History Brush Četkica istorije + Art History Brush Umetnička četkica istorije + Pattern Stamp Pečat za šaru + Spot Healing Tačkasta popravka + Red Eye Crvene oči + Patch Zakrpa + Content-Aware Move Pomeranje prema sadržaju + Rotate View Rotiraj prikaz + Perspective Crop Perspektivno opsecanje + Color Replacement Zamena boje + Dodge/Burn Posvetljivanje/zatamnjivanje + Dynamics Dinamika + Tilt Nagib + Reset Resetuj + Defaults restored. Podrazumevana podešavanja su vraćena. + Stop Zaustavi + Create new set Napravi novi skup + Technology Previews Probne tehnologije + New Group Nova grupa + Layer Comp Kompozicija slojeva + Create new layer Napravi novi sloj + Group · {n} layer Grupa · slojeva: {n} + General Blend Opšte stapanje + Advanced Blending Napredno stapanje + RGB RGB + Alpha Alfa + Spot Channel Kanal spot boje + Working Gray Plate Radna siva ploča + HSL/Grayscale HSL/nijanse sive + Tone Curve Tonska kriva + Split Toning Podeljeno toniranje + Lens Corrections Korekcije objektiva + Camera Calibration Kalibracija fotoaparata + Snapshots Snimci stanja + Recovery Oporavak + Fill Light Svetlo popune + Convert to Grayscale Pretvori u nijanse sive + Sharpness Oštrina + Color Noise Reduction Smanjenje šuma boje + Luminance Smoothness Glatkoća luminanse + Make Proizvođač + Model Model + Lens Objektiv + F F + ISO ISO + Focal Length35mm Žižna daljina (35 mm) + Aspect Ratio Odnos stranica + Time Vreme + Artist Umetnik + Enter description… Unesite opis… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Industrijski standard + Uncoated CMYK Nepremazani CMYK + Newspaper Novinski papir + Working Spaces Radni prostori + Color Management Policies Pravila upravljanja bojama + Detect Faces Prepoznaj lica + Localize Color Clusters Lokalizuj grupe boja + Duplicated selection name Ime duplirane selekcije + Operation Operacija + New channel only Samo novi kanal + Masked Areas Maskirane oblasti + Selected Areas Izabrane oblasti + Spot Color Spot boja + Color indicates Boja označava + Create Snapshot Napravi snimak stanja + Delete Snapshot Obriši snimak stanja + New Snapshot Novi snimak stanja + Snapshot 1 Snimak stanja 1 + Step Forward Korak napred + Step Backward Korak nazad + Nonlinear History Nelinearna istorija + Set New Snapshot as Default Postavi novi snimak stanja kao podrazumevani + Automatically Create First Snapshot Automatski napravi prvi snimak stanja + Automatically Create New Snapshot When Saving Automatski napravi novi snimak stanja pri čuvanju + Show New Snapshot Dialog by Default Podrazumevano prikaži dijalog za novi snimak stanja + Clear History Obriši istoriju + Panel Options… Opcije panela… + View menu Meni prikaza + Keep view in sync Drži prikaz usklađenim + {zoom}% {zoom}% + Mouse Controls Kontrole mišem + Initial Direction of Stroke Početni smer poteza + Direction of Stroke Smer poteza + Humidity Vlažnost vazduha + Auto Clean Brush Automatski čisti četkicu + Spacing: Razmak: + Brush Tip Vrh četkice + New Layer from Brush Novi sloj od četkice + New Brush Nova četkica + New Tool Preset Novi šablon alatke + Auto-Leading Automatski prored + Scale Horizontally Skaliraj horizontalno + Scale Vertically Skaliraj vertikalno + Baseline Shift Pomak osnovne linije + Mac LCD Mac LCD + Mac Sharp Mac oštro + Mac Crisp Mac jasno + Mac Strong Mac snažno + Mac Smooth Mac glatko + Superscript Eksponent + Subscript Indeks + Ligature Ligatura + Alternative Alternativa + Roman Uspravno + Bold Italic Podebljani kurziv + Ultra Light Ultra lagano + Extra Light Ekstra lagano + Semi Bold Polupodebljano + Extra Bold Ekstra podebljano + Ultra Bold Ultra podebljano + Condensed Suženo + First Line Indent Uvlačenje prvog reda + Hanging Punctuation Viseća interpunkcija + Roman Hanging Punctuation Viseća interpunkcija za latinicu + Avoid Widows/Orphans Izbegavaj udovice/siročiće + Compose Slaganje + Adobe Single-line Composer Adobe slagač pojedinačnih redova + Adobe Every-line Composer Adobe slagač svih redova + Justification Obostrano poravnanje + Word Spacing Razmak između reči + Letter Spacing Razmak između slova + Glyph Scaling Skaliranje glifova + Auto-leading % Automatski prored % + Single Word Justification Obostrano poravnanje jedne reči + Alignment Poravnanje + Justify Poravnaj obostrano + Glyph Glif + Alternates for Current Selection Alternative za tekuću selekciju + Digits Cifre + Symbols Simboli + Punctuation Interpunkcija + Path: Putanja: + Path to Illustrator… Putanja u Illustrator… + Make Work Path Napravi radnu putanju + Make Selection Napravi selekciju + Fill Path Popuni putanju + Stroke Path Ocrtaj putanju + Clipping Path Putanja isecanja + New Path Nova putanja + Duplicate Path Dupliraj putanju + Delete Path Obriši putanju + Auto-Hide Automatski sakrij + W Š + H V + X X + Y Y + Frame Sync Sinhronizacija kadrova + Quick Sync Brza sinhronizacija + Audio Zvuk + Frame Duration Trajanje kadra + Loop Petlja + Split at Playhead Podeli na mestu glave za reprodukciju + Flatten Frames Into Clips Spljošti kadrove u klipove + Flatten Frames Into Layers Spljošti kadrove u slojeve + Convert Frames > Video Layers Pretvori kadrove > video slojevi + New Video Layer Novi video sloj + New Video Group Nova video grupa + Limit Playback Ograniči reprodukciju + Enable Timeline Quick Config Omogući brzo podešavanje vremenske linije + Set Start Timecode… Postavi početni vremenski kod… + Timecode Vremenski kod + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Trenutno vreme + Out Izlaz + In Ulaz + Duration Trajanje + Repeat Ponavljaj + Play Once Pusti jednom + Info Options… Opcije informacija… + First Color Readout Prvo očitavanje boje + Second Color Readout Drugo očitavanje boje + Actual Color Stvarna boja + Grayscale Slider Klizač nijansi sive + Web Color Veb boja + HSB Color HSB boja + Total Ink Ukupno mastilo + Opacity Mask Maska neprozirnosti + Ruler Lenjir + Document Size Veličina dokumenta + Timing Vreme izvršavanja + Status Information Statusne informacije + Saved Multichannel Sačuvano višekanalno + Mouse Coordinates Koordinate miša + Ruler Units Jedinice lenjira + Hide Sakrij + New Layer Comp Nova kompozicija slojeva + Duplicate Layer Comp Dupliraj kompoziciju slojeva + Rename Layer Comp Preimenuj kompoziciju slojeva + Apply Layer Comp Primeni kompoziciju slojeva + Update Layer Comp Ažuriraj kompoziciju slojeva + Restore last viewed comp Vrati poslednju prikazanu kompoziciju + Method Metod + Select All Layers Izaberi sve slojeve + Keep proportions Zadrži proporcije + Interpolation Interpolacija + Author Autor + Caption Natpis + Caption Credits Zasluge za natpis + Keywords Ključne reči + Copyright Info URL URL informacija o autorskim pravima + Copyright Status Status autorskih prava + Copyrighted Zaštićeno autorskim pravima + Public Domain Javno dobro + Unknown Nepoznato + Credit Zasluge + Transmission Reference Referenca prenosa + Urgency Hitnost + High Visoko + Low Nisko + Category Kategorija + Supplemental Categories Dodatne kategorije + Object Name / Title Ime objekta / naslov + Date Created Datum nastanka + City Grad + Location Lokacija + Province / State Pokrajina / država + Country Zemlja + Byline Potpis autora + Byline Title Zvanje autora + Phone Telefon + Email E-pošta + URLs URL adrese + Last Modified Poslednja izmena + EXIF EXIF + GPS GPS + Camera Data Podaci fotoaparata + Camera Model Model fotoaparata + Camera Make Proizvođač fotoaparata + EXIF Version EXIF verzija + Exposure Program Program ekspozicije + Aperture Otvor blende + Shutter Speed Brzina zatvarača + Metering Mode Režim merenja svetla + Light Source Izvor svetla + Flash Blic + Lens Info Podaci o objektivu + ISO Speed ISO osetljivost + Subject Distance Udaljenost objekta + DateTime Original Izvorni datum i vreme + Create Date Datum kreiranja + Modify Date Datum izmene + Software Softver + Raw Data Neobrađeni podaci + Advanced Napredno + Import… Uvezi… + PDF Presentation… PDF prezentacija… + Create Multiple Picture Packages Napravi više paketa slika + Picture Package… Paket slika… + Web Photo Gallery… Veb galerija fotografija… + Choose… Izaberi… + Select Folder… Izaberi fasciklu… + Override Action "Save" Commands Nadjačaj komande „Sačuvaj“ u radnji + File Naming Imenovanje datoteka + Compatibility Kompatibilnost + Mac OS Mac OS + Unix Unix + Include ICC Profile Uključi ICC profil + Error File Datoteka grešaka + Stop For Errors Zaustavi se kod grešaka + Log Errors To File Beleži greške u datoteku + Errors Greške + Settings Podešavanja + Settings: Podešavanja: + Working Space Radni prostor boja + RGB: RGB: + CMYK: CMYK: + Gray: Siva: + Spot: Spot: + Advanced Controls Napredne kontrole + Desaturate Monitor Colors By Smanji zasićenost boja monitora za + Blend RGB Colors Using Gamma 1.0 Stapaj RGB boje pomoću game 1.0 + Profile Mismatches: Ask When Opening Neslaganje profila: pitaj pri otvaranju + Ask When Pasting Pitaj pri lepljenju + Missing Profiles: Ask When Opening Profili koji nedostaju: pitaj pri otvaranju + Embed profiles Ugradi profile + Ask Before Saving Saved Embedded Pitaj pre čuvanja sa ugrađenim profilom + Coerce to sRGB Prinudno pretvori u sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Graphic and web Grafika i veb + Minimalistic Minimalistički + Expand All Groups Proširi sve grupe + Photo (2 × 3 in) Fotografija (2 × 3 in) + Photo (4 × 6 in) Fotografija (4 × 6 in) + Photo (5 × 7 in) Fotografija (5 × 7 in) + Photo (8 × 10 in) Fotografija (8 × 10 in) + Recent Items Nedavne stavke + Save Preset… Sačuvaj šablon… + Delete Preset… Obriši šablon… + Preset Name: Ime šablona: + Width and Height Širina i visina + Color Profile Profil boja + Units Jedinice + Columns Kolone + Create new count group Napravi novu grupu brojanja + Adjustment Layer Korekcioni sloj + Pencil Tool Alatka olovka + Pixel Piksel + Adjustment Korekcija + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Prevucite (laso) ili kliknite tačke (mnogougao) · {add} dodaje · {sub} oduzima + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · strelice pomeraju malo · X menja orijentaciju · {commit} potvrđuje · Esc otkazuje + {mode} Color · {bits} bit {mode} boje · {bits} bita + Drag to move the layer Prevucite da pomerite sloj + Scrubby Zoom Zumiranje prevlačenjem + Auto Erase Automatsko brisanje + Color Adaptation Prilagođavanje boja + Preserve Transparency Očuvaj providnost + shortcut prečica + Go to Layer Idi na sloj + Saved Sačuvano + After (X) Posle (X) + Current Tekuće + Reference point location Položaj referentne tačke + Toggle reference point Uključi/isključi referentnu tačku + Arc Luk + Arch Svod + Bulge Ispupčenje + Shell Lower Školjka dole + Shell Upper Školjka gore + Flag Zastava + Fish Riba + Wave Talas + Rise Uzdizanje + Inflate Naduvavanje + Twist Zavrtanje + Squeeze Stiskanje + Add Pins Dodaj čiode + Delete Pins Obriši čiode + Rotate Auto Automatska rotacija + Rotate Fixed Fiksna rotacija + Create Plane Napravi ravan + Edit Plane Uredi ravan + Select Plane Izaberi ravan + Measure Meri + Heal Popravka + Heal Off Popravka isključena + Heal On Popravka uključena + Heal Auto Automatska popravka + Blend Stapanje + Brush Size Veličina četkice + Heal Color Popravka boje + Mask Maska + Channel Kanal + Rectangle Pravougaonik + Result Rezultat + New Channel Novi kanal + Save Selection Sačuvaj selekciju + Load Selection Učitaj selekciju + Duplicate Dupliraj + Fast Brzo + Accurate Precizno + Add to Dodaj u + Subtract from Oduzmi od + Intersect with Presek sa + Capture Comp Zabeleži kompoziciju + No Layer Comps Nema kompozicija slojeva + Adobe PDF Presets… Adobe PDF šabloni… + 3D… 3D… + Technology Previews… Probne tehnologije… + Background from Layer Pozadina od sloja + Add a mask (from the selection; ⌥ inverts) Dodaj masku (iz selekcije; ⌥ invertuje) + Add to selection (⇧) Dodaj u selekciju (⇧) + Airbrush: keeps painting while the pointer rests. Vazdušna četkica: nastavlja da slika dok pokazivač miruje. + Click the first point or press ↵ to close · Esc cancels Kliknite prvu tačku ili pritisnite ↵ da zatvorite · Esc otkazuje + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Kliknite da dodate tačku · prevucite van, ⌘/Ctrl-klik ili Delete da uklonite + Click to apply, ⌥-click to clear overrides, double-click for Style Options Kliknite da primenite, ⌥-klik da uklonite nadjačavanja, dvoklik za opcije stila + Click to zoom in · ⌥-click to zoom out Kliknite da uvećate · ⌥-klik da umanjite + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · ↩ završi · Esc otkaži + Commit Perspective Warp (↩) Potvrdi perspektivno krivljenje (↩) + Commit Puppet Warp (↩) Potvrdi lutkarsko krivljenje (↩) + Commit any current edits (⌘↩) Potvrdi sve tekuće izmene (⌘↩) + Commit current crop operation (↵) Potvrdi tekuće opsecanje (↵) + Commit transform (↩) Potvrdi transformaciju (↩) + Commit warp (↩) Potvrdi krivljenje (↩) + Create a new layer (⇧⌘N) Napravi novi sloj (⇧⌘N) + Custom pattern Prilagođena šara + General Brushes Opšte četkice + Grid (⌘') Mreža (⌘') + Intersect with selection (⇧⌥) Presek sa selekcijom (⇧⌥) + New document… ⌘N Novi dokument… ⌘N + Open… ⌘O Otvori… ⌘O + Rulers (⌘R) Lenjiri (⌘R) + Search commands (⌘K) Pretraži komande (⌘K) + Show Guides (⌘;) Prikaži vodilje (⌘;) + Subtract from selection (⌥) Oduzmi od selekcije (⌥) + Uses the foreground and background colors. Koristi boje prednjeg plana i pozadine. + ↑↓ navigate ↵ run esc close ↑↓ kretanje ↵ pokreni esc zatvori + Adds grain to the soft edges of the brush tip. Dodaje zrno mekim ivicama vrha četkice. + Keeps the current texture when switching presets. Zadržava tekuću teksturu pri promeni šablona. + Watercolour look: lighter interior, darker rims. Izgled akvarela: svetlija unutrašnjost, tamniji rubovi. + Collapse group (⌥-click: all groups) Skupi grupu (⌥-klik: sve grupe) + Expand group (⌥-click: all groups) Proširi grupu (⌥-klik: sve grupe) + Hide the layer's effects (⌥-click: all layers) Sakrij efekte sloja (⌥-klik: svi slojevi) + Show the layer's effects (⌥-click: all layers) Prikaži efekte sloja (⌥-klik: svi slojevi) + +# Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). + New from Clipboard Novo iz ostave + Free Transform a Copy Slobodna transformacija kopije + Transform Again on a Copy Ponovi transformaciju na kopiji + Select Layer Above Izaberi sloj iznad + Select Layer Below Izaberi sloj ispod + Select Top Layer Izaberi gornji sloj + Select Bottom Layer Izaberi donji sloj + Add Layer Above to Selection Dodaj sloj iznad u izbor + Add Layer Below to Selection Dodaj sloj ispod u izbor + +# Labels added on main after this catalog (depth labels, Wayland drop, profiles, rendering mode) + 8 bit 8 bita + Integer Celobrojno + 16 bit 16 bita + 32 bit (float) 32 bita (pokretni zarez) + Floating point Pokretni zarez + Use Global Light Koristi globalno svetlo + Native file drag-and-drop is unavailable Izvorno prevlačenje i otpuštanje datoteka nije dostupno + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Izvorno prevlačenje i otpuštanje datoteka još nije podržano na Wayland-u. Koristite Datoteka › Otvori ili kopirajte sliku u menadžeru datoteka i nalepite je pomoću {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Da biste otpuštali datoteke, pokrenite PhotoCraft pod XWayland-om: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ili postavite Podešavanja › Performanse › Linux server prikaza na X11: PhotoCraft će se tada uvek pokretati pod XWayland-om. + Edit › Paste Uređivanje › Nalepi + Use File › Open, or paste a copied image with {paste}. Koristite Datoteka › Otvori ili nalepite kopiranu sliku pomoću {paste}. + Mixer Brush Tool Alatka četkica za mešanje + Color Replacement Tool Alatka za zamenu boje + Invalid {mode} profile Neispravan {mode} profil + Unnamed {mode} profile Neimenovani {mode} profil + Show bounding box when dragging layer Prikaži granični okvir pri prevlačenju sloja + System title bar Sistemska naslovna traka + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso za zamrzavanje: prevucite da zamrznete oblast, Alt-prevlačenje da je odmrznete (L) + Rendering Mode Režim renderovanja + Automatic (recommended) Automatski (preporučeno) + CPU / Compatibility CPU / kompatibilnost + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatski režim koristi GPU ubrzanje kada je dostupno, a u slučaju grešaka prelazi na renderovanje procesorom. + Switched to CPU rendering Prebačeno na renderovanje procesorom + GPU acceleration could not continue. Your documents are unchanged. GPU ubrzanje nije moglo da se nastavi. Vaši dokumenti su nepromenjeni. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft koristi CPU kompozitor slike. Prozor i dalje može da koristi vaš grafički adapter. + Retry GPU requires a restart. Save your work first. Ponovni pokušaj sa GPU-om zahteva ponovno pokretanje. Prvo sačuvajte svoj rad. + Keep Using CPU Nastavi sa CPU-om + Retry GPU Ponovo pokušaj sa GPU-om + GPU Backend GPU pozadinski sistem + Details Detalji + Rendering mode Režim renderovanja + +# Camera Raw scopes and the Patch tool + Shadow Clipping Warning (U) Upozorenje o odsecanju senki (U) + Highlight Clipping Warning (O) Upozorenje o odsecanju svetla (O) + Show Lab Color Readouts Prikaži Lab očitavanja boja + Show Vectorscope Prikaži vektorskop + Show Red at 3 o'clock Prikaži crvenu na poziciji 3 sata + Show Skin Tone Indicator Prikaži indikator tona kože + Tone Histogram Tonski histogram + Show Selected Region Prikaži izabranu oblast +cameraRaw Light Svetlo + Camera Raw Filter ({layer}) Camera Raw filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights Svetli tonovi +cameraRaw Darks Tamni tonovi +cameraRaw Blacks Crne +cameraRaw Amount Količina +cameraRaw Tint Preliv +cameraRaw Masking Maskiranje +cameraRaw Blending Stapanje + Patch Tool Alatka zakrpa + Patch: Zakrpa: + Lasso around an area, then drag the selection Zaokružite oblast lasom, a zatim prevucite selekciju + Transform On Drop Transformiši pri otpuštanju + Scale or rotate the moved area, then press {key} Skalirajte ili rotirajte pomerenu oblast, a zatim pritisnite {key} + Now drag the selection onto another area Sada prevucite selekciju na drugu oblast + +# Warp grid presets (#388) + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Mreža: + Edit Type Uredi tekst + + New Style… Novi stil… + Style {name} saved Stil {name} je sačuvan + + Create new style (from the selected type) Napravi novi stil (od izabranog teksta) + Create new style from the selected layer Napravi novi stil od izabranog sloja + + Make Default Postavi kao podrazumevano + Reset to Default Vrati na podrazumevano + Full-resolution preview unavailable Pregled u punoj rezoluciji nije dostupan + No patterns Nema šara + Enable {name} Omogući {name} + Hold Alt while drawing for straight segments Držite Alt tokom crtanja za prave segmente + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Prevucite ili kliknite tačke mnogougla · držite Alt tokom crtanja lasom za prave segmente · {add} dodaje · {sub} pre crtanja oduzima + Extend Proširi + Structure: Struktura: + Content-Aware Move Tool Alatka za pomeranje prema sadržaju + TIFF Options TIFF opcije + Targa Options Targa opcije + 24 bits/pixel 24 bita/piksel + 32 bits/pixel 32 bita/piksel + Including layers will increase file size. Uključivanje slojeva povećaće veličinu datoteke. + Discard Layers and Save a Copy Odbaci slojeve i sačuvaj kopiju + Don't show again Ne prikazuj ponovo + Save failed: {error} Čuvanje nije uspelo: {error} + Save cancelled Čuvanje je otkazano + Couldn't place {name}: {error} Postavljanje {name} nije uspelo: {error} + Magnetic Lasso Tool Alatka magnetni laso + Frequency Frekvencija + Use tablet pressure to change pen width Koristi pritisak na tabletu za promenu širine pera + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About O programu + Contributors Saradnici + Models Modeli + Sort Sortiraj + Username Korisničko ime + Display name Prikazno ime + Real name Pravo ime + Reverse the order Obrni redosled + Grab bag Mešavina + Table Tabela + Name (A–Z) Ime (A–Z) + Merged PRs Spojeni PR-ovi + Commits Komitovi + Lines added Dodate linije + Lines deleted Obrisane linije + Line delta Razlika u linijama + Binary assets added Dodati binarni resursi + Binary assets removed Uklonjeni binarni resursi + First commit Prvi komit + First Prvi + Last commit Poslednji komit + Last Poslednji + Contributors: {contributors} · Commits: {commits} Saradnici: {contributors} · Komitovi: {commits} + No contributor data was built into this copy. U ovu kopiju nisu ugrađeni podaci o saradnicima. + No model credits were built into this copy. U ovu kopiju nisu ugrađene zasluge modela. + Company Kompanija + Version Verzija + % of all commits % svih komitova + Lines +/− Linije +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR-ova: {prs}, komitova: {commits}, linija: +{added} / −{deleted} (Δ {delta}), binarnih resursa: +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch Napravi novi uzorak boje + New Swatch… Novi uzorak boje… + New Swatch Group… Nova grupa uzoraka boja… + Import Swatches… Uvezi uzorke boja… + Replace Swatches… Zameni uzorke boja… + Export Swatches… Izvezi uzorke boja… + Export Swatches for Exchange… Izvezi uzorke boja za razmenu… + Reset Swatches Resetuj uzorke boja + Add to Swatches Dodaj u uzorke boja +# Layer Style > Blending Options (Advanced Blending) + Blend If: Stapaj ako: + Channels: Kanali: + Knockout Izbijanje + Apply At Canvas Bounds Primeni na granicama platna + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Birač boja (boja teksta) + Color Picker ({name}) Birač boja ({name}) + Color Picker (Fill Color) Birač boja (boja popune) + Fill color Boja popune + Color Picker (Stroke Color) Birač boja (boja obruba) + Stroke color Boja obruba + Pick screen color Uzmi boju sa ekrana + Sample a pixel anywhere on the screen Uzorkujte piksel bilo gde na ekranu + Click to pick · Esc to cancel Kliknite da uzmete boju · Esc za otkazivanje + Pick a screen pixel or press Esc to cancel Izaberite piksel na ekranu ili pritisnite Esc da otkažete + Scroll to zoom Pomerajte točkić za zumiranje + Show/Hide All Panels Prikaži/sakrij sve panele + Show/Hide Panels Prikaži/sakrij panele + Could not open Otvaranje nije uspelo + {path}: the path is not valid Unicode; rename the file and open it again. {path}: putanja nije ispravan Unicode; preimenujte datoteku i ponovo je otvorite. + +# Fill dialog, layer style contours and Image Size + Contents: Sadržaj: + Custom Pattern: Prilagođena šara: + Foreground Color Boja prednjeg plana + Color… Boja… + 50% Gray 50% siva + {size} (was {old}) {size} (bilo je {old}) + Cone Kupa + Cone (Inverted) Kupa (obrnuta) + Domed Kupola + Domed (Inverted) Kupola (obrnuta) + Diagonal (Descending) Dijagonala (opadajuća) + Contour Editor Uređivač konture + Anti-aliased Uglađeno + Corner Ugao + Gloss Contour Kontura sjaja + Contour Kontura + Cone - Inverted Kupa - obrnuta + Cove - Deep Udubljenje - duboko + Cove - Shallow Udubljenje - plitko + Gaussian Gausova + Half Round Polukrug + Ring Prsten + Ring - Double Prsten - dvostruki + Rolling Slope - Descending Talasasta kosina - opadajuća + Rounded Steps Zaobljeni stepenici + Sawtooth 1 Testera 1 + + Red Eye Tool Alatka za crvene oči + Pupil Size Veličina zenice + Darken Amount Količina zatamnjenja + Click a red pupil to neutralize it Kliknite crvenu zenicu da je neutrališete + open source otvoreni kod + …and {n} more …i još {n} + + Eyedroppers: Pipete: + Set Black Point Postavi crnu tačku + Set Neutral Gray Point Postavi tačku neutralne sive + Set White Point Postavi belu tačku + Unavailable in this color mode Nedostupno u ovom režimu boja + Selection Preview: Pregled selekcije: + Black Matte Crna podloga + White Matte Bela podloga + +# Layer colour labels. + Seafoam Morska pena + Indigo Indigo + Fuchsia Fuksija + Violet Ljubičasta + Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje procesorom + This redraw took {seconds} s on the CPU. Ovo iscrtavanje je trajalo {seconds} s na procesoru. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmene koje menjaju ceo dokument, kao što su režim stapanja, neprozirnost ili vidljivost sloja, ponovo iscrtavaju ceo dokument. + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Njegovi slojevi ne staju u budžet GPU memorije. Povećanje vrednosti Korišćenje memorije u Podešavanja › Performanse povećava budžet, do četvrtine memorije ovog računara. + The GPU compositor wasn't used: {reason} GPU kompozitor nije korišćen: {reason} +fontWeight Thin Tanko +fontWeight ExtraLight Ekstra lagano +fontWeight Light Lagano +fontWeight Regular Obično +fontWeight Medium Srednje +fontWeight SemiBold Polupodebljano +fontWeight Bold Podebljano +fontWeight ExtraBold Ekstra podebljano +fontWeight Black Crno +fontWeight Italic Kurziv + +# Pattern Stamp tool + Pattern Stamp Tool Alatka pečat za šaru + Impressionist Impresionistički + Rotate View Tool Alatka za rotiranje prikaza + Reset View Resetuj prikaz + Drag around the centre to rotate the view · Shift constrains to 15° Prevlačite oko centra da rotirate prikaz · Shift ograničava na korake od 15° + +# Painting symmetry presets. + Dual Axis Dvostruka osa + Diagonal Dijagonala + Transform Symmetry Transformiši simetriju + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Uzorak tačke + 3 by 3 Average Prosek 3 x 3 + 5 by 5 Average Prosek 5 x 5 + 11 by 11 Average Prosek 11 x 11 + 31 by 31 Average Prosek 31 x 31 + 51 by 51 Average Prosek 51 x 51 + 101 by 101 Average Prosek 101 x 101 + Sample Size: Veličina uzorka: + All Layers No Adjustments Svi slojevi bez korekcija + Current & Below No Adjustments Tekući i ispod bez korekcija + Show Sampling Ring Prikaži prsten uzorkovanja + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa + Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju + Color to Alpha… Boja u alfu… + Transparency Threshold Prag providnosti + Opacity Threshold Prag neprozirnosti + PhotoCraft — Screen Color Picker PhotoCraft — birač boja sa ekrana + + Rasterize svg groups deeper than Rasterizuj SVG grupe dublje od + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 isključuje ovaj prag; SVG grupe ugnežđene dublje od 100 nivoa uvek se rasterizuju. + Rule of Thirds Pravilo trećina + Triangle Trougao + Golden Ratio Zlatni presek + Golden Spiral Zlatna spirala + Auto Show Overlay Automatski prikaži preklop + Always Show Overlay Uvek prikaži preklop + Never Show Overlay Nikad ne prikazuj preklop + Cycle Overlay Smenjuj preklope + Cycle Orientation Smenjuj orijentaciju + Set the overlay options for the Crop tool Podesi opcije preklopa za alatku za opsecanje + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik je preuzelo radno okruženje, koje pomera prozore Alt-prevlačenjem. Postavite taster za pomeranje prozora u njemu na Super ili zaključajte Alt u Prozor › Modifikatorski tasteri. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Birač boja (boja stila sloja) + Auto Hide Notifications Automatski sakrij obaveštenja + Notification Duration (seconds) Trajanje obaveštenja (sekunde) + Appearance Mode Režim izgleda + Dark Theme Tamna tema + Light Theme Svetla tema + Pro Medium Pro srednja + Studio Light Studio svetla + Next Appearance Mode Sledeći režim izgleda + Sync with system Usklađeno sa sistemom + Active Aktivno + Color Picker (Solid Color) Birač boja (puna boja) + Straighten the image by drawing a line on it Ispravite sliku tako što ćete povući liniju preko nje + Set additional Crop options Podesi dodatne opcije opsecanja + Show Cropped Area Prikaži isečenu oblast + Enable Crop Shield Omogući zaštitni sloj opsecanja + Match Canvas Uskladi sa platnom + Auto Adjust Opacity Automatski podesi neprozirnost + Crop Shield color Boja zaštitnog sloja opsecanja + Color Picker (Crop Shield Color) Birač boja (boja zaštitnog sloja opsecanja) + From Center Od centra + Link radii Poveži poluprečnike + Star Ratio: Odnos zvezde: + Create Rectangle Napravi pravougaonik + Create Ellipse Napravi elipsu + Create Triangle Napravi trougao + Create Polygon Napravi mnogougao + Pressure curve Kriva pritiska + Click to add a point, right-click to remove Kliknite da dodate tačku, desni klik da je uklonite + Some brush presets could not be loaded Neki šabloni četkica nisu mogli da se učitaju + Retry saving presets Ponovo pokušaj čuvanje šablona + Brush preset changes are not saved: {error} Izmene šablona četkica nisu sačuvane: {error} + Keep this tab open to retain your brushes. Držite ovu karticu otvorenom da biste zadržali svoje četkice. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Skladište pregledača nije dostupno; četkice važe samo za ovu sesiju. Držite ovu karticu otvorenom da biste ih zadržali. + Saving brush presets… Keep this tab open until saving finishes. Čuvanje šablona četkica… Držite ovu karticu otvorenom dok se čuvanje ne završi. + Reflect Refleksija + Glow Sjaj + Negation Negacija + XOR XOR + Color Burn (Paint.NET) Zatamnjivanje boje (Paint.NET) + Color Dodge (Paint.NET) Posvetljivanje boje (Paint.NET) + Stroke Options Opcije obruba + Solid Puna + Dashed Isprekidana + Dotted Tačkasta + Caps Završeci + Butt / Flat Ravno / tupo + Corners Uglovi + Miter Šiljato + Round Zaobljeno + Bevel Zakošeno + Miter limit Granica šiljatosti + Dashed line Isprekidana linija + Dash Crtica + Gap Razmak + Dash / gap lengths × stroke width Dužine crtica / razmaka × širina obruba + Add pair Dodaj par + Remove pair Ukloni par + Dash offset Pomak crtica + Open paths use centered strokes Otvorene putanje koriste centrirani obrub + Use finite lengths and a positive pattern total Koristite konačne dužine i pozitivan ukupni zbir šablona + No subject found — try Object Selection or the Lasso Subjekat nije pronađen — probajte selekciju objekta ili laso + Quick Selection found nothing there Brza selekcija tu nije ništa pronašla + Object Selection found nothing there — try a tighter rectangle Selekcija objekta tu nije ništa pronašla — probajte uži pravougaonik + No subject found — try a rectangle around the object Subjekat nije pronađen — probajte pravougaonik oko objekta + Click the first point or press {key} to close · {del} removes a point · Esc cancels Kliknite prvu tačku ili pritisnite {key} da zatvorite · {del} uklanja tačku · Esc otkazuje + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Kliknite, pa se pomerajte duž ivice · kliknite da pričvrstite tačku · {alt}-klik za pravi segment · dvoklik za zatvaranje + Follows only edges this close to the pointer ([ and ] change it) Prati samo ivice na ovoj udaljenosti od pokazivača ([ i ] je menjaju) + Higher values follow only edges that contrast sharply with their surroundings Veće vrednosti prate samo ivice koje su u oštrom kontrastu sa okolinom + How often fastening points are placed by themselves Koliko se često tačke pričvršćivanja postavljaju same + Appearance Settings… Podešavanja izgleda… + Add to favorites Dodaj u omiljene + Remove from favorites Ukloni iz omiljenih +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Proizvođač +cameraData Model Model +cameraData Date Taken Datum snimanja +cameraData Exposure Ekspozicija +cameraData F-Stop F-broj +cameraData ISO Speed ISO osetljivost +cameraData Focal Length Žižna daljina +cameraData Focal Length (35 mm) Žižna daljina (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktiviraj panel + Change Panel Group Promeni grupu panela + Change Panel Layout Promeni raspored panela + Close Panel Zatvori panel + Dock Panel Usidri panel + Float Panel Odvoji panel + Move Panel Premesti panel + Group with Grupiši sa + Panel menu Meni panela + Resize panel window Promeni veličinu prozora panela + Resize panels Promeni veličinu panela + Use Classic Mode Koristi klasični režim + Auto Center Preview Automatski centriraj pregled + Next Document Sledeći dokument + Previous Document Prethodni dokument + W x H x Resolution Š x V x rezolucija + Front Image Prednja slika + Frame Tool Alatka okvir + Create a new rectangular frame Napravi novi pravougaoni okvir + Create a new elliptical frame Napravi novi eliptični okvir + Include in playback Uključi u reprodukciju + Perspective Crop Tool Alatka za perspektivno opsecanje + Show Grid Prikaži mrežu + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Prevucite okvir ili kliknite četiri ugla · prevucite uglove na ivice iskošenog oblika · {commit} potvrđuje · Esc otkazuje + Artboard Tool Alatka radna tabla + Make Portrait Postavi uspravno + Make Landscape Postavi položeno + Add New Artboard Dodaj novu radnu tablu + Freeform Pen Tool Alatka slobodno pero + Curvature Pen Tool Alatka pero za krive + Curve Fit: Prilagođavanje krive: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Prevlačenje: crtanje slobodnom rukom · {alt}-klik: pravi segment · Otpuštanje na početnoj tački: zatvori + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: glatka tačka · Dvoklik: ugao · Prevlačenje tačke: pomeri · Klik na prvu tačku: zatvori · {key} završi + New Crop Preset… Novi šablon opsecanja… + Delete Crop Preset… Obriši šablon opsecanja… + Select the crop presets to delete: Izaberite šablone opsecanja za brisanje: + Select the crop presets to delete Izaberite šablone opsecanja za brisanje + Enter a name for the crop preset Unesite ime šablona opsecanja + There is no crop size or ratio to save Nema veličine ili odnosa opsecanja za čuvanje + Scale: Razmera: + Snap to Origin Prianjaj uz ishodište + Search settings… Pretraži podešavanja… + No matching settings Nema odgovarajućih podešavanja + Loading fonts… Učitavanje fontova… + Tool Alatka + Simulate Pressure Simuliraj pritisak + Stroke Path… Ocrtaj putanju… + Background Eraser Gumica za pozadinu + Healing Brush Četkica za popravku + Spot Healing Brush Četkica za tačkastu popravku + Smudge Razmazivanje + Sponge Sunđer + Amount: Količina: + Protect: Zaštiti: + Protect skin tones Zaštiti tonove kože + Edit points to modify the curve Uredite tačke da izmenite krivu + Draw to modify the curve Crtajte da izmenite krivu +curves Smooth Izgladi + Smooth the curve values Izgladi vrednosti krive + Drag to draw the curve · {key}-click draws a straight line Prevucite da nacrtate krivu · {key}-klik crta pravu liniju + Grid Size: Veličina mreže: + Vanishing Point: click four corners to create a plane Tačka nestajanja: kliknite četiri ugla da napravite ravan + Vanishing Point is not active Tačka nestajanja nije aktivna + Apply Vanishing Point ({key}) Primeni tačku nestajanja ({key}) + Fix the red plane first: its corners must form a convex quad Prvo ispravite crvenu ravan: njeni uglovi moraju činiti konveksan četvorougao +# Select and Mask view modes (#1754) + Onion Skin Prozirni prikaz + Marching Ants Pokretne crtice + On Black Na crnoj + On White Na beloj + On Layers Na slojevima + Show Edge Prikaži ivicu + Show Original Prikaži original + F cycles the views; hold X to see the image F smenjuje prikaze; držite X da vidite sliku +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Četkica za doterivanje ivice (R) + Expand Detection Area Proširi oblast detekcije + Restore Original Edge Vrati originalnu ivicu + Paint over hair or fur on the image; Alt paints the other mode Slikajte preko kose ili krzna na slici; Alt slika u drugom režimu + Clear Brush Strokes Obriši poteze četkicom + Show rulers Prikaži lenjire + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Kliknite da postavite · Alt-klik ili prevlačenje van platna da uklonite + No document open Nijedan dokument nije otvoren + {n} of {max} sample points {n} od {max} tačaka uzorkovanja +# Print dialog (#2697) + Emulsion Down Emulzija nadole + Functions Funkcije + Print Resolution: Rezolucija štampe: + Use Previous Layer to Create Clipping Mask Koristi prethodni sloj za pravljenje maske isecanja + Fill with {mode}-neutral color (white) Popuni neutralnom bojom za {mode} (bela) + Fill with {mode}-neutral color (black) Popuni neutralnom bojom za {mode} (crna) + Fill with {mode}-neutral color (50% gray) Popuni neutralnom bojom za {mode} (50% siva) + (No neutral color exists for {mode} mode.) (Za režim {mode} ne postoji neutralna boja.) + Apply mask to layer before removing? Primeniti masku na sloj pre uklanjanja? + The layer or its mask changed. Drag the mask onto the Trash again. Sloj ili njegova maska su se promenili. Ponovo prevucite masku u kantu. + Delete the vector mask? Obrisati vektorsku masku? +# View › Guides › New Guide… › Color + Light Blue Svetloplava + Light Red Svetlocrvena + Medium Blue Srednje plava + Light Gray Svetlosiva + Remove after each stroke Ukloni posle svakog poteza + Apply the removal ({key}) Primeni uklanjanje ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Potezi za uklanjanje: {n} (Enter primenjuje, Esc otkazuje) + +# Gradient Editor window, opened from the options bar's gradient swatch. + The current gradient (pick one in Window › Gradients) Tekući gradijent (izaberite ga u Prozor › Gradijenti) + +# Camera Raw: contextual controls and window title +cameraRaw Red Crvena +cameraRaw Orange Narandžasta +cameraRaw Yellow Žuta +cameraRaw Green Zelena +cameraRaw Aqua Akva +cameraRaw Blue Plava +cameraRaw Purple Ljubičasta +cameraRaw Magenta Magenta +cameraRaw Temperature Temperatura + +# Counted status messages and labels (Serbian: one | few | many) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Izvezen je {n} uzorak boje u {file}|Izvezena su {n} uzorka boja u {file}|Izvezeno je {n} uzoraka boja u {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Uvezena je {n} četkica iz {group}|Uvezene su {n} četkice iz {group}|Uvezeno je {n} četkica iz {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Uvezen je {n} uzorak boje iz {file}|Uvezena su {n} uzorka boja iz {file}|Uvezeno je {n} uzoraka boja iz {file} +@plural Played {n} step|Played {n} steps Izvršen je {n} korak|Izvršena su {n} koraka|Izvršeno je {n} koraka +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ažuriran je {n} povezani pametni objekat izmenjen na disku|Ažurirana su {n} povezana pametna objekta izmenjena na disku|Ažurirano je {n} povezanih pametnih objekata izmenjenih na disku +@plural Saved for Web: {n} file|Saved for Web: {n} files Sačuvano za veb: {n} datoteka|Sačuvano za veb: {n} datoteke|Sačuvano za veb: {n} datoteka +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} sloj zabeležen u ovoj kompoziciji je obrisan. Ažurirajte kompoziciju ili uklonite upozorenje.|{n} sloja zabeležena u ovoj kompoziciji su obrisana. Ažurirajte kompoziciju ili uklonite upozorenje.|{n} slojeva zabeleženih u ovoj kompoziciji je obrisano. Ažurirajte kompoziciju ili uklonite upozorenje. +@plural {n} plane|{n} planes {n} ravan|{n} ravni|{n} ravni +@plural {n} preset|{n} presets {n} šablon|{n} šablona|{n} šablona +@plural {n} sample point removed|{n} sample points removed Uklonjena je {n} tačka uzorkovanja|Uklonjene su {n} tačke uzorkovanja|Uklonjeno je {n} tačaka uzorkovanja +@plural {n} slice|{n} slices {n} isečak|{n} isečka|{n} isečaka +@plural {n} step|{n} steps {n} korak|{n} koraka|{n} koraka +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+ još {n} upozorenje za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica) +@plural {what} with {n} warning|{what} with {n} warnings {what} sa {n} upozorenjem|{what} sa {n} upozorenja|{what} sa {n} upozorenja + +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) +@id edit.stroke Ocrtaj… +@id edit.transform.scale Skaliranje +cameraRaw Luminance Luminansa +cameraRaw Color Boja +cameraRaw Vibrance Živost +cameraRaw Color Grading Gradacija boja +cameraRaw Global Globalno +cameraRaw Hue Nijansa +cameraRaw Luminance Detail Detalji luminanse +cameraRaw Color Detail Detalji boje +cameraRaw Roughness Hrapavost +cameraRaw Whites Bele +layerLabel No Color Bez boje + Preferences… Podešavanja… + Actual Pixels Stvarni pikseli + Clipping Mask Maska isecanja + Tool Options Opcije alatke + Color Picker Birač boja + Color Swatches Uzorci boja + Linear Dodge Linearno posvetljivanje + Preview Image Pregled slike + Save As Sačuvaj kao + Remove All Ukloni sve + Unlock Otključaj + Enable Omogući + Disable Onemogući + On Uključeno + Clear Selection Ukloni selekciju + Invert Selection Invertuj selekciju + Select Inverse Izaberi inverzno + Contract Suzi + Sample Size Veličina uzorka + Strength Jačina + Gamma Gama + Input Ulaz + Input Levels Ulazni nivoi + Output Levels Izlazni nivoi + Layer Styles Stilovi slojeva + Blending Mode Režim stapanja + Fill Color Boja popune + Stroke Color Boja obruba + Stroke Width Širina obruba + Shadow Senka + Adjustment Layers Korekcioni slojevi + Text Layer Tekstualni sloj + Documents Dokumenti + File Name Ime datoteke + File Format Format datoteke + Compression Kompresija + Artboards Radne table + Snap to Grid Prianjaj uz mrežu + Full Screen Ceo ekran + Fullscreen Ceo ekran + Workspaces Radni prostori + Show All Prikaži sve + Hidden Skriveno + Duplicate Layer Dupliraj sloj + Ungroup Razgrupiši + Lock Transparent Pixels Zaključaj providne piksele + Lock Image Pixels Zaključaj piksele slike + Lock Position Zaključaj položaj + Lock All Zaključaj sve + Show Rulers Prikaži lenjire + Guide Layout… Raspored vodilja… + Page Setup… Podešavanje stranice… + Reset Preferences Resetuj podešavanja + Documentation Dokumentacija + Check for Updates Proveri ažuriranja + Loading… Učitavanje… + Processing… Obrađivanje… + Saving… Čuvanje… + Exporting… Izvoz u toku… + Please wait… Sačekajte… + No selection Nema selekcije + No layers Nema slojeva + No recent files Nema nedavnih datoteka + Unsupported file format Nepodržan format datoteke + Failed to open file Otvaranje datoteke nije uspelo + Failed to save file Čuvanje datoteke nije uspelo + Operation canceled Operacija je otkazana + Changes have not been saved. Izmene nisu sačuvane. + Are you sure? Da li ste sigurni? + Discard Changes Odbaci izmene + Recent Files Nedavne datoteke + Open File Otvori datoteku + Save File Sačuvaj datoteku + Save Changes Sačuvaj izmene + Rendering… Renderovanje… + Importing… Uvoz u toku… + Ready Spremno + Failed Neuspešno + Warning Upozorenje + Information Informacija + Retry Pokušaj ponovo + Continue Nastavi + Skip Preskoči + Resume Nastavi + Reset All Resetuj sve + Defaults Podrazumevano + Apply to All Primeni na sve + Confirm Potvrdi + Finish Završi + Back Nazad + Next Sledeće + Previous Prethodno + More Više + Less Manje + Manual Ručno + Disabled Onemogućeno + Locked Zaključano + Unlocked Otključano + Delete… Obriši… + Create… Napravi… + Edit… Uredi… + Change… Promeni… + Transformation Transformacija + Scaling Skaliranje + Canvas Size Veličina platna + Percentage Procenat + Degrees Stepeni + Middle Sredina + Diameter Prečnik + Intensity Intenzitet + Sensitivity Osetljivost + Softness Mekoća + Feather Radius Poluprečnik omekšavanja + Library Biblioteka + Layer Properties Svojstva sloja + Groups Grupe + Masks Maske + Add Mask Dodaj masku + Delete Mask Obriši masku + Invert Mask Invertuj masku + Enable Mask Omogući masku + Disable Mask Onemogući masku + Apply Mask Primeni masku + Import Settings Uvezi podešavanja + Export Settings Izvezi podešavanja + Shortcut Prečica + Shortcuts Prečice + Keyboard Tastatura + Mouse Miš + Tablet Tablet + Display Ekran + Panel Panel + Hide Grid Sakrij mrežu + Fit to Window Uklopi u prozor + Live Preview Pregled uživo + Before Pre + After Posle + Compare Uporedi + File Size Veličina datoteke + Filename Ime datoteke + Folder Fascikla + Open Folder Otvori fasciklu + Choose Folder Izaberi fasciklu + Browse Pregledaj + Find Pronađi + Replace Zameni + Date Datum + Learn More Saznajte više + Report a Bug Prijavi grešku + Updates Ažuriranja + Update Ažuriraj + Restart Required Potrebno je ponovno pokretanje + Restart Ponovo pokreni + Quit Izađi + Quit PhotoCraft Izađi iz PhotoCraft-a + Close Window Zatvori prozor + New Window Novi prozor + Open Image Otvori sliku + Save Image Sačuvaj sliku + Export Image Izvezi sliku + Import Image Uvezi sliku + Export As Izvezi kao + Export to Izvezi u + Export Options Opcije izvoza + Import Options Opcije uvoza + No Selection Nema selekcije + No Document Nema dokumenta + No Image Nema slike + No Layers Nema slojeva + No Results Nema rezultata + No Results Found Nije pronađen nijedan rezultat + Nothing to Undo Nema šta da se opozove + Nothing to Redo Nema šta da se ponovi + Changes Saved Izmene su sačuvane + Unsaved Changes Nesačuvane izmene + Save before closing? Sačuvati pre zatvaranja? + Are you sure you want to quit? Da li zaista želite da izađete? + The file could not be opened. Datoteka nije mogla da se otvori. + The file could not be saved. Datoteka nije mogla da se sačuva. + Unsupported format Nepodržan format + Out of memory Nema dovoljno memorije + Permission denied Pristup je odbijen + File not found Datoteka nije pronađena + Operation failed Operacija nije uspela + Operation completed Operacija je završena + Operation cancelled Operacija je otkazana + Sharpen Amount Količina izoštravanja + Sharpen Radius Poluprečnik izoštravanja + Sharpen Detail Detalji izoštravanja + Sharpen Masking Maskiranje izoštravanja + Noise Luminance Šum luminanse + Noise Luminance Detail Detalji šuma luminanse + Noise Color Šum boje + Noise Color Detail Detalji šuma boje + Grain Amount Količina zrna + Grain Size Veličina zrna + Grain Roughness Hrapavost zrna + Vignette Roundness Zaobljenost vinjete + Vignette Feather Omekšavanje vinjete + Vignette Highlights Svetla vinjete + Vignette Style Stil vinjete + Planes Ravni + Plane Ravan + Shadow Amount Količina senki + Shadow Tone Ton senki + Shadow Radius Poluprečnik senki + Highlight Amount Količina svetla + Highlight Tone Ton svetla + Highlight Radius Poluprečnik svetla + Midtone Srednji ton + Black Clip Odsecanje crne + White Clip Odsecanje bele + Fuzziness Rasplinutost + Source Layer Izvorni sloj + Neutralize Neutrališi + Use Selection In Source Koristi selekciju u izvoru + Use Selection In Target Koristi selekciju u cilju + Flame Type Vrsta plamena + Randomize Length Nasumična dužina + Interval Interval + Adjust Interval For Loops Prilagodi interval za petlje + Use Custom Color Koristi prilagođenu boju + Flame Lines Linije plamena + Flame Bottom Alignment Poravnanje dna plamena + Flame Style Stil plamena + Flame Shape Oblik plamena + Randomize Shapes Nasumični oblici + Frame Okvir + Arrangement Raspored + Thickness Debljina + Vine Color Boja loze + Flower Color Boja cveta + Leaf Color Boja lista + Base Tree Type Osnovna vrsta drveta + Light Direction Smer svetla + Leaves Amount Količina lišća + Leaves Size Veličina lišća + Branches Height Visina grana + Branches Thickness Debljina grana + Default Leaves Podrazumevano lišće + Leaves Color Boja lišća + Custom Branch Color Prilagođena boja grana + Branches Color Boja grana + Flat Shading Ravno senčenje + Date and Time Datum i vreme + Scale Units Jedinice razmere + Scale Factor Faktor razmere + Area Površina + Perimeter Obim + Circularity Kružnost + Gray Value (Minimum) Vrednost sive (minimum) + Gray Value (Maximum) Vrednost sive (maksimum) + Gray Value (Mean) Vrednost sive (srednja vrednost) + Gray Value (Median) Vrednost sive (medijana) + Integrated Density Integrisana gustina + Length Dužina + In Black Ulazna crna + In White Ulazna bela + Out Black Izlazna crna + Out White Izlazna bela + Tint Color Boja preliva + Stops Tačke + Center X Centar X + Center Y Centar Y + Distribution Raspodela + Monochromatic Jednobojno + Cell Size Veličina ćelije + Generators Generatori + Wavelength Min Talasna dužina, min. + Wavelength Max Talasna dužina, maks. + Amplitude Min Amplituda, min. + Amplitude Max Amplituda, maks. + Undefined Areas Nedefinisane oblasti + Preserve Očuvaj + Max Radius Maks. poluprečnik + Depth Mode Režim dubine + Solid Front Faces Pune prednje strane + Mask Incomplete Blocks Maskiraj nepotpune blokove + Stylization Stilizacija + Cleanliness Čistoća + Bristle Detail Detalji čekinja + Lighting Osvetljenje + Shine Sjaj + Max Offset Maks. pomak + Map Document Dokument mape + Map Path Putanja mape + Map Layer Sloj mape + Ridges Grebeni + Light Type Vrsta svetla + Light X Svetlo X + Light Y Svetlo Y + Light Z Svetlo Z + Target X Cilj X + Target Y Cilj Y + Hotspot Žarište + Elevation Elevacija + Ambient Ambijentalno + Warmth Toplina + Gloss Sjajnost + Metallic Metalnost + Ambience Ambijent + White Is High Bela je visoko + Reduce Color Noise Smanji šum boje + Sharpen Details Izoštri detalje + Remove Jpeg Artifact Ukloni JPEG artefakte + Blade Curvature Zakrivljenost lamela + Depth Map Mapa dubine + Focal Distance Žižna udaljenost + Focus Fokus + Transition Prelaz + Radius X Poluprečnik X + Radius Y Poluprečnik Y + Pins Čiode + Blur Angle Ugao zamućenja + Speed Brzina + Taper Sužavanje kraja + Start X Početak X + Start Y Početak Y + End X Kraj X + End Y Kraj Y + Kernel Jezgro + Input Mode Režim ulaza + Row Order Redosled redova + Eliminate Eliminiši + Create By Napravi pomoću + List Spisak + Pencil Width Širina olovke + Stroke Pressure Pritisak poteza + Paper Brightness Osvetljenost papira + Number Of Levels Broj nivoa + Edge Simplicity Jednostavnost ivica + Edge Fidelity Vernost ivica + Brush Detail Detalji četkice + Highlight Area Oblast svetla + Glow Size Veličina sjaja + Glow Brightness Osvetljenost sjaja + Brush Type Vrsta četkice + Stroke Size Veličina poteza + Stroke Detail Detalji poteza + Highlight Strength Jačina svetla + Smoothness Glatkoća + Edge Thickness Debljina ivica + Edge Intensity Intenzitet ivica + Posterization Posterizacija + Stroke Length Dužina poteza + Relief Reljef + Definition Definicija + Texture Coverage Pokrivenost teksturom + Shadow Intensity Intenzitet senki + Edge Width Širina ivica + Edge Brightness Osvetljenost ivica + Direction Balance Balans smera + Black Intensity Intenzitet crne + White Intensity Intenzitet bele + Dark Intensity Intenzitet tamnih tonova + Custom Pixel Aspect Ratio Prilagođeni odnos stranica piksela + 32-bit Preview Options Opcije 32-bitnog pregleda + New Guide Nova vodilja + Join the ArtCraft Discord Pridružite se ArtCraft Discord zajednici + Report an Issue Prijavi problem + Load Files into Stack Učitaj datoteke u stek + Statistics Statistika + Duplicate Layers Dupliraj slojeve + Edit Toolbar Uredi traku alatki + Import Brushes Uvezi četkice + Load 3D LUT Učitaj 3D LUT + New document Novi dokument + Press keys Pritisnite tastere + Rename Group Preimenuj grupu + Rename Tool Preset Preimenuj šablon alatke + Search commands, tools and panels Pretraži komande, alatke i panele + Slice Options Opcije isečka + Rename Brush Preimenuj četkicu + Opening Otvaranje + Reading Čitanje + Working Obrada + Accented Edges Naglašene ivice + Angled Strokes Kosi potezi + Anisotropic Anizotropno + Around Center Oko centra + Artistic Umetnički + Bamboo Bambus + Bas Relief Bareljef + Berries Bobice + Blast Udar + Bleach Bypass Preskočeno beljenje + Blocks Blokovi + Brick Cigla + Brush Strokes Potezi četkicom + Burlap Juta + Candle Light Svetlost sveće + Circle Krug + Clumped Zgrudvano + Coarse Dots Krupne tačke + Colored Pencil Bojica + Contrasty Kontrastno + Cool Hladno + Craquelure Krakelir + Cross Krst + Cross Process Unakrsna obrada + Crosshatch Unakrsno šrafiranje + Cutout Isečak od papira + Daisies Bele rade + Dark Rough Tamno grubo + Dark Strokes Tamni potezi + Darken Only Samo zatamni + Day For Night Dan za noć + Deep Blue Tamnoplava + Deep Emerald Tamni smaragd + Deep Red Tamnocrvena + Deep Yellow Tamnožuta + Dot Tačka + Double Line Dvostruka linija + Draft Nacrt + Dry Brush Suva četkica + Duplication Dupliranje + Edge Extension Proširenje ivica + Edge Only Samo ivice + Enlarged Uvećano + Even Fields Parni poluslikovi + Faded Film Izbledeli film + Film Grain Filmsko zrno + Fine Fino + Fine Dots Fine tačke + Flat Ravno + Fresco Freska + From Left Sleva + From Right Zdesna + Frosted Mat staklo + Generic Opšte + Glass Staklo + Glowing Edges Svetleće ivice + Grainy Dots Zrnaste tačke + Hearts Srca + Heptagon Sedmougao + Hexagon Šestougao + Horizontal Only Samo horizontalno + Infinite Beskonačno + Ink Outlines Obrisi mastilom + Ivy Bršljan + Left Diagonal Leva dijagonala + Level Based Prema nivoima + Light Rough Svetlo grubo + Lighten Only Samo posvetli + Long Lines Duge linije + Long Strokes Dugi potezi + Lower Donje + Medium Dots Srednje tačke + Medium Lines Srednje linije + Medium Strokes Srednji potezi + Mono Contrast Jednobojni kontrast + Mosaic Tiles Mozaik pločice + Multiple Flames Along Path Više plamenova duž putanje + Multiple Flames One Direction Više plamenova u jednom smeru + Multiple Flames Path Directions Više plamenova u smerovima putanje + Multiple Flames Various Length Više plamenova različite dužine + Octagon Osmougao + Odd Fields Neparni poluslikovi + One Flame Along Path Jedan plamen duž putanje + Out From Center Od centra ka spolja + Oval Oval + Overlay Edge Ivica preklopa + Palette Knife Slikarska špahtla + Parallel Paralelno + Patchwork Mozaik od krpica + Pentagon Petougao + Point Tačka + Pointed Šiljato + Pond Ripples Talasići na jezeru + Pyramids Piramide + Random Nasumično + Right Diagonal Desna dijagonala + Rope Konopac + Roses Ruže + Sandstone Peščar + Scallops Festoni + Sepia Sepija + Short Lines Kratke linije + Short Strokes Kratki potezi + Simple Jednostavno + Light Intensity Intenzitet svetla + Spray Radius Poluprečnik raspršivanja + Stroke Direction Smer poteza + Graininess Zrnastost + Glow Amount Količina sjaja + Clear Amount Količina čistoće + Distortion Izobličenje + Ripple Size Veličina talasića + Ripple Magnitude Jačina talasića + Charcoal Area Oblast uglja + Chalk Area Oblast krede + Charcoal Thickness Debljina uglja + Light Dark Balance Balans svetlog i tamnog + Foreground Level Nivo prednjeg plana + Background Level Nivo pozadine + Pattern Type Vrsta šare + Image Balance Balans slike + Darkness Tama + Fiber Length Dužina vlakana + Crack Spacing Razmak pukotina + Crack Depth Dubina pukotina + Crack Brightness Osvetljenost pukotina + Grain Type Vrsta zrna + Tile Size Veličina pločice + Grout Width Širina fuge + Lighten Grout Posvetli fugu + Square Size Veličina kvadrata + Border Thickness Debljina oboda + Correct Distortion Ispravi izobličenje + Correct Vignette Ispravi vinjetiranje + Correct C A Ispravi hromatsku aberaciju + Auto Scale Automatsko skaliranje + Red Cyan Crvena–cijan + Blue Yellow Plava–žuta + Vignette Amount Količina vinjete + Vignette Midpoint Središte vinjete + Constraints Ograničenja + Curve Highlights Kriva: svetla + Curve Lights Kriva: svetli tonovi + Curve Darks Kriva: tamni tonovi + Curve Shadows Kriva: senke + Curve Splits Kriva: podele + Point Curve Tačkasta kriva + Point Curve Red Tačkasta kriva, crvena + Point Curve Green Tačkasta kriva, zelena + Point Curve Blue Tačkasta kriva, plava + Hsl Hue HSL nijansa + Hsl Sat HSL zasićenost + Hsl Lum HSL svetlina + Grade Shadows Gradacija: senke + Grade Midtones Gradacija: srednji tonovi + Grade Highlights Gradacija: svetla + Grade Global Gradacija: globalno + Grade Blending Gradacija: stapanje + Grade Balance Gradacija: balans + Sine Sinusoida + Sketch Skica + Snowflakes Pahuljice + Sparkle Iskrenje + Spatter Prskanje + Speckle Pegavost + Spin Okretanje + Sprinkles Mrvice + Squareness Četvrtastost + Stagger Smaknuto + Stained Glass Vitraž + Star Zvezda + Stars Zvezde + Stippled Tačkasto + Stretch Rastegni + Teal Orange Tirkizno-narandžasto + Tiny Lens Sićušni objektiv + To Center Ka centru + Unaltered Neizmenjeno + Underpainting Podslika + Underwater Pod vodom + Uniform Ravnomerno + Upper Gornje + Vertical Only Samo vertikalno + Vine With Flowers Loza sa cvećem + Vine With Leaves Loza sa lišćem + Violent Silovito + Warm Toplo + Watercolor Akvarel + Waves Talasi + Wide Blurry Široko zamućeno + Wide Sharp Široko oštro + Wrap Obmotavanje + Zigzag Cik-cak + Wetness Vlažnost + AI Integrations AI integracije + Add Noise Dodaj šum + Apply Data Set Primeni skup podataka + Apply Image Primeni sliku + Arbitrary Proizvoljno + Artboard from Group Radna tabla od grupe + Artboard from Layers Radna tabla od slojeva + Artboards to Files Radne table u datoteke + Artboards to PDF Radne table u PDF + Assign Profile Dodeli profil + At Na + Auto-Align Layers Automatski poravnaj slojeve + Auto-Blend Layers Automatski stopi slojeve + Batch Grupna obrada + Border Obod + Box Blur Pravougaono zamućenje + Calculations Proračuni + Camera Raw Camera Raw + Camera Raw Filter Camera Raw filter + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Clone Kloniraj + Color Decontaminate Dekontaminacija boja + Color Halftone Polutonski raster u boji + Color Lookup Tables Tabele preslikavanja boja + Color Settings Podešavanja boja + Color Table Tabela boja + Conditional Mode Change Uslovna promena režima + Content-Aware Fill Popuna prema sadržaju + Convert to Linked Pretvori u povezani + Convert to Profile Pretvori u profil + Crystallize Kristalizacija + Data Podaci + Data Sets as Files Skupovi podataka kao datoteke + De-Interlace Uklanjanje preplitanja + Define Brush Preset Definiši šablon četkice + Define Custom Shape Definiši prilagođeni oblik + Define Pattern Definiši šaru + Defringe Ukloni rub + Diffuse Rasipanje + Diffuse Glow Difuzni sjaj + Displace Premeštanje + Do you want to save you made to “{name}” before quitting? Želite li da sačuvate izmene u „{name}“ pre izlaska? + Dust & Scratches Prašina i ogrebotine + Export Contents Izvezi sadržaj + Export Preferences Podešavanja izvoza + Export/Import Presets Izvoz/uvoz šablona + Extrude Istiskivanje + Fibers Vlakna + Field Blur Zamućenje polja + File Info Informacije o datoteci + Filter Gallery Galerija filtera + Find and Replace Text Pronađi i zameni tekst + Fit Image Uklopi sliku + Flame Plamen + Focus Area Oblast fokusa + Gaussian Blur Gausovo zamućenje + Global Light Globalno svetlo + Group from Layers Grupa od slojeva + HDR Toning HDR toniranje + High Pass Visokopropusni filter + Highlight Svetlo + Image Processor Obrada slika + Install Plug-in Instaliraj dodatak + Interpret Footage Tumači snimak + Iris Blur Iris zamućenje + Jag Nazubljenost + Label Oznaka + Layer Comps to Files Kompozicije slojeva u datoteke + Layer Content Options Opcije sadržaja sloja + Layer From Background Sloj od pozadine + Layer from Background Sloj od pozadine + Layers to Files Slojevi u datoteke + Legacy Staro + Lens Blur Zamućenje objektiva + Lens Correction Korekcija objektiva + Lens Flare Odsjaj objektiva + Lighting Effects Efekti osvetljenja + Liquify Tečno izobličavanje + Lock Layers Zaključaj slojeve + Lum Svet. + Match Color Uskladi boje + Merge to HDR Pro Spoji u HDR Pro + Mezzotint Mecotinta + Mosaic Mozaik + Motion Blur Zamućenje pokreta + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Izvorno prevlačenje i otpuštanje datoteka još nije podržano na Wayland-u. Koristite Datoteka › Otvori ili pokrenite PhotoCraft pod XWayland-om pomoću `WAYLAND_DISPLAY= photocraft`. + Neon Glow Neonski sjaj + New Guide Layout Novi raspored vodilja + New Video Layer from File Novi video sloj iz datoteke + Ocean Ripple Okeanski talasi + Oil Paint Uljana boja + Open As Otvori kao + Package Spakuj + Paint Daubs Mrlje boje + Path Blur Zamućenje duž putanje + Paths to Illustrator Putanje u Illustrator + Photomerge Spajanje fotografija + Picture Frame Ram za sliku + Pinch Skupljanje + Place Embedded Postavi ugrađeno + Place Linked Postavi povezano + Place Scale Marker Postavi oznaku razmere + Plastic Wrap Plastična folija + Pointillize Poentilizam + Polar Coordinates Polarne koordinate + Polar To Rectangular Iz polarnih u pravougle + Poster Edges Posterske ivice + Preset Manager Upravljač šablonima + Profile Profil + Radial Blur Radijalno zamućenje + Raw Defaults Podrazumevana Raw podešavanja + Rectangular To Polar Iz pravouglih u polarne + Reduce Noise Smanji šum + Relink to File Ponovo poveži sa datotekom + Render Video Renderuj video + Replace Color Zameni boju + Replace Contents Zameni sadržaj + Replace Footage Zameni snimak + Resolve Missing Fonts Razreši fontove koji nedostaju + Ripple Talasanje + Rough Pastels Grube pastele + Sat Zas. + Save a Copy Sačuvaj kopiju + Save for Web (Legacy) Sačuvaj za veb (staro) + Scale Effects Skaliraj efekte + Script Events Manager Upravljač događajima skripti + Search menus Pretraži menije + Seed Seme + Select and Mask Izaberi i maskiraj + Set Measurement Scale Postavi razmeru merenja + Shadows/Highlights Senke/svetla + Shape Blur Zamućenje oblikom + Shear Smicanje + Smart Blur Pametno zamućenje + Smart Sharpen Pametno izoštravanje + Smudge Stick Štapić za razmazivanje + Solid Color Puna boja + Spherize Sferizacija + Spin Blur Rotaciono zamućenje + Spot Reflektor + Sprayed Strokes Raspršeni potezi + Strokes Potezi + Sumi-e Sumi-e + Surface Blur Površinsko zamućenje + Texturizer Teksturizer + Tiles Pločice + Tilt-Shift Tilt-shift + Trace Contour Praćenje konture + Trap Trepovanje + Tree Drvo + Trim Odseci ivice + Turbulent Turbulentno + Twirl Uvrtanje + Unsharp Mask Maska neoštrine + Use File › Open to open an image. Koristite Datoteka › Otvori da otvorite sliku. + Vanishing Point Tačka nestajanja + Variable Data Sets Promenljivi skupovi podataka + Video Frames to Layers Video kadrovi u slojeve + WIA Support WIA podrška + Warp Text Krivljenje teksta + Wind Vetar + ZigZag Cik-cak + Vertical Type Tool Alatka za vertikalni tekst + About {app} O programu {app} + Hide {app} Sakrij {app} + Quit {app} Zatvori {app} + Services Usluge + Hide Others Sakrij ostale + Lut LUT + Cooling L B B Hlađenje (LBB) + Cooling80 Hlađenje (80) + Cooling82 Hlađenje (82) + Hsb HSB + Hsl HSL + Movie Prime Filmski fiksni objektiv + Prime105 Fiksni objektiv 105 + Prime35 Fiksni objektiv 35 + Rgb RGB + Warming L B A Zagrevanje (LBA) + Warming81 Zagrevanje (81) + Warming85 Zagrevanje (85) +@id filter.sharpen.sharpen Izoštri + Good Dobro + Best Najbolje + Adjustment Properties Svojstva korekcije + Default Foreground and Background Colors Podrazumevane boje prednjeg plana i pozadine + Move Layer Pomeri sloj + Rectangular Selection Pravougaona selekcija + Reorder Layer Promeni redosled sloja + Select Layer Izaberi sloj + Set Colors Postavi boje + Switch Foreground and Background Colors Zameni boje prednjeg plana i pozadine + Target Cilj + +# Path, channel, layer style, status and System Info labels the Czech catalog added + Make Work Path… Napravi radnu putanju… + Create Vector Mask Napravi vektorsku masku + Make Selection… Napravi selekciju… + Fill Path… Popuni putanju… + Clipping Path… Putanja isecanja… + Free Transform Path Slobodna transformacija putanje + Unite Shapes at Overlap Ujedini oblike na preklapanju + Subtract Shapes at Overlap Oduzmi oblike na preklapanju + Copy Fill Kopiraj popunu + Copy Complete Stroke Kopiraj kompletan obrub + Paste Fill Nalepi popunu + Paste Complete Stroke Nalepi kompletan obrub + Make Symmetry Path Napravi putanju simetrije + Disable Symmetry Path Onemogući putanju simetrije + Dark Gray Tamnosiva + Medium Gray Srednje siva + Select Custom Color… Izaberi prilagođenu boju… + Color Picker (Custom Canvas Color) Birač boja (prilagođena boja platna) + Duplicate Channel Dupliraj kanal + Delete Channel Obriši kanal + Rename Channel… Preimenuj kanal… + Merge Spot Channel Spoji kanal spot boje + Convert to Alpha Channel Pretvori u alfa kanal + Exit Quick Mask Izađi iz brze maske + New Channel… Novi kanal… + New Spot Channel… Novi kanal spot boje… + Split Channels Razdvoji kanale + Merge Channels… Spoji kanale… + Color Indicates Masked Areas Boja označava maskirane oblasti + Color Indicates Selected Areas Boja označava izabrane oblasti + ✓ Color Indicates Masked Areas ✓ Boja označava maskirane oblasti + ✓ Color Indicates Selected Areas ✓ Boja označava izabrane oblasti + Up Nagore + To Do + Shallow Plitko + Deep Duboko + Blend Interior Effects as Group Stapaj unutrašnje efekte kao grupu + Blend Clipped Layers as Group Stapaj isečene slojeve kao grupu + Transparency Shapes Layer Providnost oblikuje sloj + Layer Mask Hides Effects Maska sloja skriva efekte + Vector Mask Hides Effects Vektorska maska skriva efekte + General Blending Opšte stapanje + This Layer: Ovaj sloj: + Underlying Layer: Sloj ispod: + Nothing to paste: the clipboard holds no image Nema šta da se nalepi: u ostavi nema slike + Preferences saved Podešavanja su sačuvana + Invalid panel layout; restored the default dock Neispravan raspored panela; vraćen je podrazumevani raspored + Preferences were reset: {error} Podešavanja su resetovana: {error} + Recovered {name} Oporavljeno: {name} + Couldn't save preferences: {error}. Retrying… Čuvanje podešavanja nije uspelo: {error}. Ponovni pokušaj… + Autosave failed: {error} Automatsko čuvanje nije uspelo: {error} + Exported presets to {path} Šabloni su izvezeni u {path} + Ran script {name} Pokrenuta skripta {name} + Sent to the printer ({spooler}) Poslato na štampač ({spooler}) + Printed to {path} Odštampano u {path} + Exported {path} ({size}) Izvezeno: {path} ({size}) + Exported {path} Izvezeno: {path} + Added swatch {name} Dodat uzorak boje {name} + Step {step} ({id}) failed: {error} Korak {step} ({id}) nije uspeo: {error} + Could not rename the action: {error} Preimenovanje radnje nije uspelo: {error} + Action playback failed: {error} Izvršavanje radnje nije uspelo: {error} + Clone source set at {x}, {y} Izvor kloniranja postavljen na {x}, {y} + Color management: {error} Upravljanje bojama: {error} + Opened {url} Otvoreno: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nema prilagođenog oblika „{name}“: izaberite jedan na traci opcija ili u Prozor › Oblici + Saved a copy as {path} Kopija je sačuvana kao {path} + Exported contents to {path} Sadržaj je izvezen u {path} + {label} is not available: {reason} {label} nije dostupno: {reason} + Saved {path}; image assets: {n} in {dir} Sačuvano: {path}; slikovni resursi: {n} u {dir} + {message} (notes: {n}; {warning}) {message} (beleške: {n}; {warning}) + Preferences can't be saved Podešavanja ne mogu da se sačuvaju + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft nastavlja da pokušava; dok čuvanje ne uspe, izmene podešavanja se gube pri zatvaranju. + GPU retry scheduled Zakazan je ponovni pokušaj sa GPU-om + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Sačuvajte svoj rad i ponovo pokrenite PhotoCraft da biste ponovo pokušali GPU ubrzanje. Renderovanje procesorom ostaje aktivno do kraja ove sesije. + GPU device was lost; using the CPU renderer. GPU uređaj je izgubljen; koristi se renderovanje procesorom. + GPU error; using the CPU renderer. Greška GPU-a; koristi se renderovanje procesorom. + Couldn't open {name}: {error} Otvaranje {name} nije uspelo: {error} + Toggle the Brush Settings panel Prikaži/sakrij panel Podešavanja četkice + New Brush Preset Novi šablon četkice + Grid view Prikaz mreže sličica + List view Prikaz spiska + {verb} {mask} {layer} {verb} {mask} ({layer}) + Unlink Razveži + Link Poveži + layer mask masku sloja + vector mask vektorsku masku + Commit transform Potvrdi transformaciju + Cancel transform Otkaži transformaciju + Scroll menu up Pomeri meni nagore + Scroll menu down Pomeri meni nadole + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora da se rasterizuje pre nastavka. Njegov tekst više neće moći da se uređuje. Rasterizovati tekst? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora da se rasterizuje pre nastavka. Više neće moći da se uređuje kao oblik. Rasterizovati oblik? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora da se rasterizuje pre nastavka. Njegov sadržaj više neće moći da se uređuje. Rasterizovati pametni objekat? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj popune mora da se rasterizuje pre nastavka. Njegov sadržaj popune više neće moći da se uređuje. Rasterizovati sloj? + This layer must be rasterized before proceeding. Ovaj sloj mora da se rasterizuje pre nastavka. + Color Picker (Background Color) Birač boja (boja pozadine) + Color Picker (Foreground Color) Birač boja (boja prednjeg plana) + Adwaita (Light) Adwaita (svetla) + Adwaita (Dark) Adwaita (tamna) + Breeze Light Breeze svetla + Breeze Dark Breeze tamna + Solarized Dark Solarized tamna + Open Recent is unavailable on the web Otvori nedavno nije dostupno na vebu + Graphics adapter: {adapter} Grafički adapter: {adapter} + Backend: {backend} ({type}) Pozadinski sistem: {backend} ({type}) + Driver: {driver} Upravljački program: {driver} + GPU backend preference: {backend} Željeni GPU pozadinski sistem: {backend} + Selected at launch: {backend} Izabrano pri pokretanju: {backend} + Image compositor: {processor} Kompozitor slike: {processor} + Fallback: {reason} Rezervno rešenje: {reason} + This session: {reason} Ova sesija: {reason} + Window renderer: {renderer} Renderer prozora: {renderer} + Software adapter Softverski adapter + Graphics adapter Grafički adapter + unknown nepoznato + unknown type nepoznata vrsta + auto automatski + Platform: {os} {arch} Platforma: {os} {arch} + previous reading kept; the last re-read failed: {reason} zadržano je prethodno očitavanje; poslednje ponovno očitavanje nije uspelo: {reason} + Monitor profile: {profile} Profil monitora: {profile} + Display profiles: {reason} Profili ekrana: {reason} + Monitor profile in use on {display}: {profile} Profil monitora u upotrebi na {display}: {profile} + Monitor profile in use: {profile} Profil monitora u upotrebi: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno se prikazuje kao sRGB. Izaberite profil monitora u Uređivanje › Podešavanja boja. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno na {display} se prikazuje kao sRGB. Izaberite profil monitora u Uređivanje › Podešavanja boja. + Display profile not used Profil ekrana se ne koristi + Monitor profile on {display} (main window): {profile} Profil monitora na {display} (glavni prozor): {profile} + Monitor profile on {display}: {profile} Profil monitora na {display}: {profile} + manual ručno + fallback rezervno + The document does not have an embedded colour profile. Working space: {working}. Dokument nema ugrađen profil boja. Radni prostor: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokument ima ugrađen profil boja koji se ne slaže sa radnim prostorom.\nUgrađeni: {embedded}\nRadni: {working} + Importing {what} from {file}… Uvoz ({what}) iz {file}… + brushes četkice + gradients gradijenti +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Uvezena je {n} četkica iz {file}|Uvezene su {n} četkice iz {file}|Uvezeno je {n} četkica iz {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Uvezen je {n} gradijent iz {file}|Uvezena su {n} gradijenta iz {file}|Uvezeno je {n} gradijenata iz {file} + Data Set: {name} ({index}/{total}) Skup podataka: {name} ({index}/{total}) + (none) (nijedan) +navigator Zoom Uvećanje + Apply {option}: {state} Primeni {option}: {state} + on uključeno + off isključeno + This adjustment ({key}) is kept as imported and can't be edited yet. Ova korekcija ({key}) je zadržana onakva kakva je uvezena i još ne može da se uređuje. + Join the ArtCraft Discord ({url}) Pridružite se ArtCraft Discord zajednici ({url}) + {index} of {total} {index} od {total} + {shortcut} assigned. {shortcut} je dodeljena. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} već koristi {commands} i biće uklonjena odatle kada kliknete U redu. + +# Command-id overrides where the plain key is also a dialog button +@id file.close Zatvori +@id layer.layerMask.apply Primeni +@id edit.clear Obriši + +# Layer colour labels distinct from channel and ink names +layerLabel Red Crvena +layerLabel Magenta Magenta + + Drag: draw freehand · Release on the start point: close Prevlačenje: crtanje slobodnom rukom · Otpuštanje na početnoj tački: zatvori + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · {commit} potvrđuje · Esc otkazuje + +# Character, Paragraph and Glyphs panels + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: metrički, optički ili vrednost u 1/1000 em (Alt+←/→ kod kursora) + Basic Latin Osnovna latinica + Latin 1 Latinica 1 + Latin Extended-A Proširena latinica A + Latin Extended-B Proširena latinica B + Greek Grčko pismo + Cyrillic Ćirilica + Numbers Brojevi + Currency Valute + Math Symbols Matematički simboli + Arrows Strelice + + Save Path Sačuvaj putanju + +# Camera Raw labels +cameraRaw Highlights Svetla +cameraRaw Shadows Senke + +# Keyboard Shortcuts and Menus editor + All shortcuts reset to Photoshop defaults. Sve prečice su vraćene na podrazumevane vrednosti Photoshop-a. + Temporary Privremeno + none nijedna + red crvena + orange narandžasta + yellow žuta + green zelena + blue plava + violet ljubičasta + gray siva + +# Color Settings dialog + Working Rgb Radni RGB + Working Cmyk Radni CMYK + Working Gray Radna siva + Policy Rgb Pravilo za RGB + Policy Cmyk Pravilo za CMYK + Policy Gray Pravilo za sivu + Convert Pretvori + Ask On Mismatch Pitaj pri neslaganju + Ask On Paste Pitaj pri lepljenju + Ask On Missing Pitaj kada profil nedostaje + Intent Način konverzije + Bpc Kompenzacija crne tačke + Blend Text Gamma Gama za stapanje teksta + Monitor Profile Profil monitora + Display-p3 Display P3 + Adobe-rgb-compat Adobe RGB (kompatibilno) + Prophoto-compat ProPhoto RGB (kompatibilno) + Linear-srgb Linearni sRGB + Rec2020 Rec. 2020 + Coated-cmyk Premazani CMYK + Sgray sGray + Gray-gamma-2.2 Siva gama 2.2 + + Import Photoshop brushes (.abr) Uvezi Photoshop četkice (.abr) + +# Camera Raw chrome, tone, curve, color mixer, grading, detail and effects +cameraRaw Effects Efekti +cameraRaw Exposure Ekspozicija +cameraRaw Contrast Kontrast +cameraRaw Texture Tekstura +cameraRaw Clarity Jasnoća +cameraRaw Dehaze Uklanjanje izmaglice +cameraRaw Saturation Zasićenost +cameraRaw Curve Kriva +cameraRaw Color Mixer Mešač boja +cameraRaw Midtones Srednji tonovi +cameraRaw Balance Balans +cameraRaw Detail Detalji +cameraRaw Radius Poluprečnik +cameraRaw Feather Omekšavanje +cameraRaw Midpoint Središte +cameraRaw Roundness Zaobljenost +cameraRaw Size Veličina + +# New Document, Home and Image Size + Default Photoshop Size Podrazumevana Photoshop veličina +document Orientation Orijentacija + +# Warp, Liquify, Perspective and Puppet + Arc Lower Donji luk + Arc Upper Gornji luk + Forward Warp Guranje napred + Twirl Clockwise Uvrtanje udesno + Twirl Counterclockwise Uvrtanje ulevo + Pucker Skupljanje + Bloat Naduvavanje + Push Left Guranje ulevo + Freeze Mask Zamrzni masku + Thaw Mask Odmrzni masku + Freeze Lasso Laso za zamrzavanje + Reconstruct All Rekonstruiši sve + Mask None Bez maske + +# Contextual labels +colorRange Invert Invertuj +layers Fill Popuna +layers Fill: Popuna: +shape Appearance Izgled + +# Added on merge with main (spelling, Camera Raw settings files, printing, Object Finder, brush +# controls, actions, gradient presets, Pen Auto Add/Delete) + Add to Dictionary Dodaj u rečnik + No Spelling Suggestions Nema predloga za pravopis + Save Settings… Sačuvaj podešavanja… + Load Settings… Učitaj podešavanja… + Save these settings as a Camera Raw .xmp file Sačuvaj ova podešavanja kao Camera Raw .xmp datoteku + Load settings from a Camera Raw .xmp file Učitaj podešavanja iz Camera Raw .xmp datoteke + Loaded Camera Raw settings from {file} Camera Raw podešavanja su učitana iz {file} + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw podešavanja su učitana iz {file} (nije podržano: {n}) + Manage submitted jobs in the system print queue Upravljajte poslatim poslovima u sistemskom redu za štampu + Pick Color From Image Uzmi boju sa slike + Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje + Object Finder Pronalazač objekata + No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega + Clear Brush Controls Obriši kontrole četkice + Reset All Locked Settings Vrati sva zaključana podešavanja + Copy Texture to Other Tools Kopiraj teksturu u druge alatke + Load Actions… Učitaj radnje… + Save Actions… Sačuvaj radnje… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Učitana je {n} radnja iz {file}|Učitane su {n} radnje iz {file}|Učitano je {n} radnji iz {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} korak još nema ekvivalent u PhotoCraftu|{n} koraka još nemaju ekvivalent u PhotoCraftu|{n} koraka još nema ekvivalent u PhotoCraftu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Sačuvana je {n} radnja u {file}|Sačuvane su {n} radnje u {file}|Sačuvano je {n} radnji u {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Izostavljen je {n} korak bez ekvivalenta u Photoshopu|Izostavljena su {n} koraka bez ekvivalenta u Photoshopu|Izostavljeno je {n} koraka bez ekvivalenta u Photoshopu + Insert Stop… Umetni zaustavljanje… + Save Set… Sačuvaj skup… + Message: Poruka: + Allow Continue Dozvoli nastavak + Show dialog during playback Prikaži dijalog tokom puštanja + Select an action first Prvo izaberite radnju + Action playback stopped Puštanje radnje je zaustavljeno + Step {step} is waiting for its dialog Korak {step} čeka svoj dijalog +@plural {n} action|{n} actions {n} radnja|{n} radnje|{n} radnji + Gradient Preset Options Opcije šablona gradijenta + Import .GRD Uvezi .GRD + Choose a gradient file (.grd). Izaberite datoteku gradijenta (.grd). + Auto Add/Delete Automatsko dodavanje/brisanje + +# Custom print paper (#3820) + Paper Width Širina papira + Paper Height Visina papira + ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela + +# Channel Options and New Spot Channel (#3761) + Channel Color Boja kanala + Color Indicates: Boja označava: + Ink Characteristics Karakteristike mastila + Solidity: Punoća: diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 0b9118386a..49bc3bc79b 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -98,6 +98,8 @@ mod tests { (vec!["nl-BE", "fr-BE"], "nl"), (vec!["hu-HU", "en-US"], "hu"), (vec!["tr-TR", "en-US"], "tr"), + (vec!["sr-Latn-RS", "en-US"], "sr"), + (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3f552460ca..12383b694b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7,6 +7,10 @@ # entries the same way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) # stay as they are; the trailing "…" of dialog commands is kept. # +# Custom paper dimensions in Print Settings. + Paper Width Kağıt Genişliği + Paper Height Kağıt Yüksekliği + Rows Satırlar Number Sayı Top: Üst: @@ -60,6 +64,8 @@ Save Kaydet Save As… Farklı Kaydet… Save a Copy… Bir Kopyasını Kaydet… + {name} can't keep everything in this document {name} bu belgedeki her şeyi koruyamaz + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Photoshop (PSD) gibi her şeyi koruyan bir biçim seçin ya da bir kopyasını kaydedin. Revert Geri Döndür Export Dışa Aktar Quick Export as PNG PNG Olarak Hızlı Dışa Aktarma @@ -97,6 +103,13 @@ Script Events Manager… Komut Dosyası Olay Yöneticisi… Import İthalat Browse… Göz at… + Source Files Kaynak Dosyalar + Use: Kullan: + Files Dosyalar + Add Open Files Açık Dosyaları Ekle + Sort By Name Ada Göre Sırala + Attempt to Automatically Align Source Images Kaynak Görüntüleri Otomatik Hizalamayı Dene + Create Smart Object after Loading Layers Katmanları Yükledikten Sonra Akıllı Nesne Oluştur Reveal in Finder Finder'da Göster Show in Explorer Explorer'da Göster Show in Folder Klasörde Göster @@ -121,6 +134,8 @@ Search… Ara… Check Spelling… Yazımı Denetle… Find and Replace Text… Metni Bul ve Değiştir… + Add to Dictionary Sözlüğe Ekle + No Spelling Suggestions Yazım Önerisi Yok Fill… Doldur… Stroke… Kontur… Content-Aware Fill… İçeriğe Uygun Dolgu… @@ -135,6 +150,8 @@ Transform Again on a Copy Bir Kopyada Yeniden Dönüştür Scale Ölçekle Rotate Döndür + Set horizontal skew Yatay eğmeyi ayarla + Set vertical skew Dikey eğmeyi ayarla Skew Eğ Distort Çarpıt Perspective Perspektif @@ -700,6 +717,10 @@ Breeze Dark Theme Esinti Koyu Tema Breeze light Esinti ışığı Breeze Light Theme Esinti Işık Teması + Soft dark Yumuşak koyu + Soft light Yumuşak açık + Soft Dark Theme Yumuşak koyu tema + Soft Light Theme Yumuşak açık tema Adwaita Light Theme Adwaita Işık Teması Adwaita Dark Theme Adwaita Karanlık Tema Adwaita Adwaita @@ -792,7 +813,7 @@ Alt Alternatif Always use pressure for opacity Opaklık için her zaman baskıyı kullanın Always use pressure for size Her zaman boyuta göre baskı kullanın - Amount Tutar + Amount Miktar Anchor: Çapa: Angle Açı Angle Gradient Açı Gradyanı @@ -802,6 +823,9 @@ Appearance Görünüm Appearance (Layer Style) Görünüm (Katman Stili) Apply Layer Mask Katman Maskesini Uygula + Add Mask to Selection Maskeyi Seçime Ekle + Subtract Mask from Selection Maskeyi Seçimden Çıkar + Intersect Mask with Selection Maskeyi Seçimle Kesiştir Apply Per Tip İpucu Başına Uygula Apply next selected layer comp Sonraki seçilen katman kompozisyonunu uygula Apply previous selected layer comp Önceki seçilen katman kompozisyonunu uygula @@ -2043,6 +2067,11 @@ Rename Brush… Fırçayı Yeniden Adlandır… Delete Brush Fırçayı Sil New Brush Preset… Yeni Fırça Ön Ayarı… + Capture Brush Size in Preset Fırça Boyutunu Ön Ayara Kaydet + Include Tool Settings Araç Ayarlarını Dahil Et + Include Color Rengi Dahil Et + Enter a name for the brush preset Fırça ön ayarı için bir ad girin + A brush preset with this name already exists Bu adda bir fırça ön ayarı zaten var Brush Preset Options Fırça Ön Ayarı Seçenekleri Group name Grup adı Brush name Fırça adı @@ -2314,6 +2343,7 @@ Color Picker (Stroke Color) Renk Seçici (Kontur Rengi) Stroke color Kontur rengi Pick screen color Ekran rengini seç + Pick Color From Image Görüntüden Renk Seç Sample a pixel anywhere on the screen Ekranın herhangi bir yerinde bir pikseli örnekleyin Click to pick · Esc to cancel Seçmek için tıklayın · İptal etmek için Esc Pick a screen pixel or press Esc to cancel Bir ekran pikseli seçin veya iptal etmek için Esc tuşuna basın @@ -2671,7 +2701,7 @@ cameraRaw Color Renk Alternative Alternatif Extra Light Ekstra Işık Extra Bold Ekstra Kalın - Sharpen Amount Keskinleştirme Tutarı + Sharpen Amount Keskinleştirme Miktarı Sharpen Radius Yarıçapı Keskinleştir Sharpen Detail Detayı Keskinleştir Sharpen Masking Maskelemeyi Keskinleştir @@ -2692,7 +2722,7 @@ cameraRaw Color Renk Shadow Amount Gölge Miktarı Shadow Tone Gölge Tonu Shadow Radius Gölge Yarıçapı - Highlight Amount Tutarı Vurgula + Highlight Amount Vurgu Miktarı Highlight Tone Vurgu Tonu Highlight Radius Yarıçapı Vurgula Midtone Orta ton @@ -2722,7 +2752,7 @@ cameraRaw Color Renk Leaf Color Yaprak Rengi Base Tree Type Temel Ağaç Türü Light Direction Işık Yönü - Leaves Amount Yaprak Tutarı + Leaves Amount Yaprak Miktarı Leaves Size Yaprak Boyutu Branches Height Dal Yüksekliği Branches Thickness Dal Kalınlığı @@ -3097,7 +3127,7 @@ cameraRaw Color Renk Stroke Direction Strok Yönü Graininess Taneciklilik Glow Amount Işıma Miktarı - Clear Amount Tutarı Temizle + Clear Amount Temizleme Miktarı Distortion Bozulma Ripple Size Dalgalanma Boyutu Ripple Magnitude Dalgalanma Büyüklüğü @@ -3126,7 +3156,7 @@ cameraRaw Color Renk Auto Scale Otomatik Ölçeklendirme Red Cyan Kırmızı Camgöbeği Blue Yellow Mavi Sarı - Vignette Amount Vinyet Tutarı + Vignette Amount Vinyet Miktarı Vignette Midpoint Vinyet Orta Noktası Constraints Kısıtlamalar Curve Highlights Eğrinin Öne Çıkan Noktaları @@ -3784,6 +3814,9 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + Clear Brush Controls Fırça Denetimlerini Temizle + Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla + Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Eylemleri Yükle… @@ -3805,3 +3838,23 @@ cameraData Lens Lens Action playback stopped Eylem oynatma durduruldu Step {step} is waiting for its dialog {step}. adım iletişim kutusunu bekliyor @plural {n} action|{n} actions {n} eylem + Object Finder Nesne Bulucu + No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin + Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın + Gradient Preset Options Gradyan Ön Ayarı Seçenekleri + Import .GRD .GRD İçe Aktar + Choose a gradient file (.grd). Bir gradyan dosyası (.grd) seçin. + Save Settings… Ayarları Kaydet… + Load Settings… Ayarları Yükle… + Save these settings as a Camera Raw .xmp file Bu ayarları Camera Raw .xmp dosyası olarak kaydet + Load settings from a Camera Raw .xmp file Ayarları bir Camera Raw .xmp dosyasından yükle + Loaded Camera Raw settings from {file} Camera Raw ayarları {file} dosyasından yüklendi + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw ayarları {file} dosyasından yüklendi ({n} desteklenmiyor) + Auto Add/Delete Otomatik Ekle/Sil + ICO is limited to 256 × 256 pixels ICO 256 × 256 piksel ile sınırlıdır + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanal Rengi + Color Indicates: Rengin Gösterdiği: + Ink Characteristics Mürekkep Özellikleri + Solidity: Doluluk: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f7feba19a7..a9bae9eb27 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8,9 +8,19 @@ # No proprietary translation resources were used. # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. +# Channel Options and New Spot Channel (#3761) + Channel Color Колір каналу + Color Indicates: Кольором позначено: + Solidity: Щільність: + Ink Characteristics Характеристики фарби + # Menus # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Ширина паперу + Paper Height Висота паперу Rows Рядки Number Кількість Top: Зверху: @@ -69,6 +79,8 @@ Save Зберегти Save As… Зберегти як… Save a Copy… Зберегти копію… + {name} can't keep everything in this document {name} не може зберегти весь вміст цього документа + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Виберіть формат, який збереже все, наприклад Photoshop (PSD), або збережіть копію. Save… Зберегти… Revert Повернути до збереженого Import Імпорт @@ -189,6 +201,9 @@ Enable Layer Mask Увімкнути маску шару Disable Layer Mask Вимкнути маску шару Apply Layer Mask Застосувати маску шару + Add Mask to Selection Додати маску до виділення + Subtract Mask from Selection Відняти маску від виділення + Intersect Mask with Selection Перетнути маску з виділенням Link Layer Mask Зв'язати маску шару Unlink Layer Mask Відв'язати маску шару Vector Mask Векторна маска @@ -248,6 +263,8 @@ Check Spelling Перевірити правопис Language Options Мовні параметри Find and Replace Text… Знайти та замінити текст… + Add to Dictionary Додати до словника + No Spelling Suggestions Немає варіантів написання Paste Lorem Ipsum Вставити Lorem Ipsum Glyphs Panel Панель гліфів Type Styles Текстові стилі @@ -996,9 +1013,17 @@ Rename Brush… Перейменувати пензель… Delete Brush Видалити пензель Import Brushes… Імпортувати пензлі… + Gradient Preset Options Параметри наборів градієнтів + Import .GRD Імпортувати .GRD + Choose a gradient file (.grd). Виберіть файл градієнтів (.grd). Brush Name Назва пензля Brush Stroke Мазок пензля New Brush Preset… Новий набір пензля… + Capture Brush Size in Preset Зберегти розмір пензля в наборі + Include Tool Settings Включити налаштування інструмента + Include Color Включити колір + Enter a name for the brush preset Введіть назву набору пензля + A brush preset with this name already exists Набір пензля з такою назвою вже існує Brush Preset Options Параметри набору пензля New Brush Новий пензель Create new brush from these settings Створити новий пензель із цими параметрами @@ -1160,6 +1185,8 @@ Offset Зсув Reset Transform Скинути трансформацію Rotate Повернути + Set horizontal skew Задати горизонтальний скіс + Set vertical skew Задати вертикальний скіс Flip Horizontal Віддзеркалити горизонтально Flip Vertical Віддзеркалити вертикально Frame Sync Синхронізація кадрів @@ -1407,6 +1434,10 @@ Breeze Dark Theme Темна тема Breeze Breeze light Breeze світла Breeze Light Theme Світла тема Breeze + Soft dark М'яка темна + Soft light М'яка світла + Soft Dark Theme М'яка темна тема + Soft Light Theme М'яка світла тема Adwaita Light Theme Світла тема Adwaita Adwaita Dark Theme Темна тема Adwaita Adwaita Adwaita @@ -1659,6 +1690,12 @@ Show in Explorer Показати у Провіднику Show in Folder Показати в теці Before (Y) До (Y) + Save Settings… Зберегти налаштування… + Load Settings… Завантажити налаштування… + Save these settings as a Camera Raw .xmp file Зберегти ці налаштування у файл Camera Raw .xmp + Load settings from a Camera Raw .xmp file Завантажити налаштування з файлу Camera Raw .xmp + Loaded Camera Raw settings from {file} Налаштування Camera Raw завантажено з {file} + Loaded Camera Raw settings from {file} ({n} not supported) Налаштування Camera Raw завантажено з {file} (не підтримується: {n}) After (X) Після (X) Original Оригінал Current Поточний @@ -1810,6 +1847,14 @@ Border… Межа… Bottom Edges Нижні краї Browse… Огляд… + Source Files Вихідні файли + Use: Використовувати: + Files Файли + Folder Папка + Add Open Files Додати відкриті файли + Sort By Name Сортувати за назвою + Attempt to Automatically Align Source Images Спробувати автоматично вирівняти вихідні зображення + Create Smart Object after Loading Layers Створити смартоб'єкт після завантаження шарів Canvas Guides Напрямні полотна Character Styles Panel Панель стилів символів Clear Canvas Guides Очистити напрямні полотна @@ -2564,6 +2609,7 @@ cameraRaw Roughness Шорсткість Click to pick · Esc to cancel Клацніть для вибору · Esc для скасування Pick a screen pixel or press Esc to cancel Виберіть піксель екрана або натисніть Esc для скасування Scroll to zoom Прокрутіть для масштабування + Drag to pan · Scroll to zoom · Double-click to fit Перетягніть для панорамування · Прокрутіть для масштабування · Двічі клацніть, щоб умістити Show/Hide All Panels Показати/приховати всі панелі Show/Hide Panels Показати/приховати панелі Could not open Не вдалося відкрити @@ -3747,9 +3793,14 @@ curves Smooth Згладити Light Red Світло-червоний Medium Blue Середньо-синій Light Gray Світло-сірий + Object Finder Пошук об’єктів + No object found there — drag a rectangle around it Там не знайдено об’єкта — обведіть його прямокутником Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Clear Brush Controls Очистити параметри пензля + Reset All Locked Settings Скинути всі заблоковані параметри + Copy Texture to Other Tools Скопіювати текстуру в інші інструменти # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Завантажити операції… @@ -3771,3 +3822,5 @@ curves Smooth Згладити Action playback stopped Відтворення операції зупинено Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій + Auto Add/Delete Автододавання/видалення + ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 890533bab2..fc652eff83 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -20,7 +20,17 @@ # (`plural_none` in i18n/mod.rs). +# Channel Options and New Spot Channel (#3761) + Channel Color Màu kênh + Color Indicates: Màu biểu thị: + Solidity: Độ đặc: + Ink Characteristics Đặc tính mực + # Phase 3.1 — top-level menus, File menu, Edit menu +# Custom paper dimensions in Print Settings. + Paper Width Chiều rộng giấy + Paper Height Chiều cao giấy + Again Lặp lại AI Integrations… Tích hợp AI… All Tất cả @@ -32,6 +42,14 @@ Automate Tự động hóa Batch… Hàng loạt… Browse… Duyệt… + Source Files Tệp nguồn + Use: Dùng: + Files Tệp + Folder Thư mục + Add Open Files Thêm các tệp đang mở + Sort By Name Sắp xếp theo tên + Attempt to Automatically Align Source Images Thử tự căn các ảnh nguồn + Create Smart Object after Loading Layers Tạo đối tượng thông minh sau khi tải lớp Check Spelling… Kiểm tra chính tả… Clear Xóa Clear Recent Files Xóa tệp gần đây @@ -73,6 +91,8 @@ Fill… Tô… Filter Bộ lọc Find and Replace Text… Tìm và thay chữ… + Add to Dictionary Thêm vào từ điển + No Spelling Suggestions Không có gợi ý chính tả Fit Image… Vừa khung ảnh… Flatten All Layer Effects Làm phẳng mọi hiệu ứng lớp Flatten All Masks Làm phẳng mọi mặt nạ @@ -135,11 +155,15 @@ Restore Khôi phục Revert Hoàn nguyên Rotate Xoay + Set horizontal skew Đặt độ nghiêng ngang + Set vertical skew Đặt độ nghiêng dọc Rotate 180° Xoay 180° Rotate 90° Clockwise Xoay 90° theo chiều kim đồng hồ Rotate 90° Counter Clockwise Xoay 90° ngược chiều kim đồng hồ Save Lưu Save a Copy… Lưu bản sao… + {name} can't keep everything in this document {name} không thể giữ mọi thứ trong tài liệu này + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Hãy chọn một định dạng giữ được mọi thứ, như Photoshop (PSD), hoặc lưu bản sao. Save As… Lưu thành… Save for Web (Legacy)… Lưu cho Web (cũ)… Scale Tỷ lệ @@ -932,6 +956,9 @@ Duplicate Layers… Nhân bản các lớp… Add Layer Mask Thêm mặt nạ lớp Apply Layer Mask Áp dụng mặt nạ lớp + Add Mask to Selection Thêm mặt nạ vào vùng chọn + Subtract Mask from Selection Trừ mặt nạ khỏi vùng chọn + Intersect Mask with Selection Giao mặt nạ với vùng chọn Delete Layer Mask Xóa mặt nạ lớp Disable Layer Mask Tắt mặt nạ lớp Release Clipping Mask Nhả mặt nạ cắt @@ -1272,6 +1299,12 @@ Aqua Xanh ngọc Balance Cân bằng Before (Y) Trước (Y) + Save Settings… Lưu cài đặt… + Load Settings… Tải cài đặt… + Save these settings as a Camera Raw .xmp file Lưu các cài đặt này thành tệp .xmp Camera Raw + Load settings from a Camera Raw .xmp file Tải cài đặt từ tệp .xmp Camera Raw + Loaded Camera Raw settings from {file} Đã tải cài đặt Camera Raw từ {file} + Loaded Camera Raw settings from {file} ({n} not supported) Đã tải cài đặt Camera Raw từ {file} ({n} không được hỗ trợ) Blacks Điểm đen Blending Pha trộn Blue Xanh dương @@ -2008,6 +2041,11 @@ layers Fill: Độ tô: Move effect layer down Di chuyển lớp hiệu ứng xuống Move effect layer up Di chuyển lớp hiệu ứng lên New Brush Preset… Thiết lập sẵn cọ mới… + Capture Brush Size in Preset Lưu kích thước cọ vào thiết lập sẵn + Include Tool Settings Bao gồm thiết lập công cụ + Include Color Bao gồm màu + Enter a name for the brush preset Nhập tên cho thiết lập sẵn cọ + A brush preset with this name already exists Đã có thiết lập sẵn cọ với tên này New effect layer Lớp hiệu ứng mới New Swatch Group… Nhóm mẫu màu mới… New Swatch… Mẫu màu mới… @@ -2025,6 +2063,7 @@ layers Fill: Độ tô: Option-click to define a source point to clone from Option+nhấp để chỉ định điểm nguồn nhân bản Out of gamut for printing: click for the nearest printable colour Ngoài gamut in: nhấp để lấy màu in được gần nhất Pick screen color Lấy màu màn hình + Pick Color From Image Lấy màu từ hình ảnh Point Sample Mẫu điểm Pupil Size Kích thước đồng tử Quick Selection found nothing there Chọn nhanh không tìm thấy gì @@ -2038,6 +2077,7 @@ layers Fill: Độ tô: Sample Size: Kích thước mẫu: Save As Preset Lưu thành thiết lập sẵn Scroll to zoom Cuộn để thu phóng + Drag to pan · Scroll to zoom · Double-click to fit Kéo để xem toàn cảnh · Cuộn để thu phóng · Bấm đúp để vừa khung Selection Preview: Xem trước vùng chọn: Set additional Crop options Đặt tùy chọn Cắt bổ sung Set Black Point Đặt điểm đen @@ -2343,6 +2383,10 @@ shape Appearance Diện mạo Bottom: Dưới: Breeze Dark Theme Chủ đề Breeze tối Breeze Light Theme Chủ đề Breeze sáng + Soft dark Dịu tối + Soft light Dịu sáng + Soft Dark Theme Giao diện dịu tối + Soft Light Theme Giao diện dịu sáng Caps Đầu nét Center Columns Căn giữa các cột Change Panel Group Đổi nhóm bảng @@ -3533,9 +3577,14 @@ curves Smooth Làm mượt Stroke Path… Đi nét đường dẫn… Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa? Delete the vector mask? Xóa mặt nạ vectơ? + Object Finder Trình tìm đối tượng + No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + Clear Brush Controls Xóa điều khiển cọ + Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa + Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Nạp hành động… @@ -3557,3 +3606,8 @@ curves Smooth Làm mượt Action playback stopped Đã dừng phát hành động Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó @plural {n} action|{n} actions {n} hành động + Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc + Import .GRD Nhập .GRD + Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). + Auto Add/Delete Tự động thêm/xóa + ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2f13bc3bfb..fb6083985c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -11,7 +11,20 @@ # Licensed MIT OR Apache-2.0, as the code. # Product and technology names retain their spelling. See docs/localization-zh-hans.md. +# Channel Options and New Spot Channel (#3761) + Channel Color 通道颜色 + Color Indicates: 色彩指示: + Solidity: 密度: + Masked Areas 被蒙版区域 + Selected Areas 所选区域 + Spot Color 专色 + Ink Characteristics 油墨特性 + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 纸张宽度 + Paper Height 纸张高度 Columns 列 Rows 行 Number 数量 @@ -69,6 +82,8 @@ Save 保存 Save As… 另存为… Save a Copy… 保存副本… + {name} can't keep everything in this document {name} 无法保留此文档中的所有内容 + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. 请选择能保留全部内容的格式,例如 Photoshop (PSD),或存储副本。 Revert 恢复 Export 导出 Quick Export as PNG 快速导出为 PNG @@ -106,6 +121,14 @@ Script Events Manager… 脚本事件管理器… Import 导入 Browse… 浏览… + Source Files 源文件 + Use: 使用: + Files 文件 + Folder 文件夹 + Add Open Files 添加打开的文件 + Sort By Name 按名称排序 + Attempt to Automatically Align Source Images 尝试自动对齐源图像 + Create Smart Object after Loading Layers 载入图层后创建智能对象 Reveal in Finder 在 Finder 中显示 Show in Explorer 在文件资源管理器中显示 Show in Folder 在文件夹中显示 @@ -130,6 +153,8 @@ Search… 搜索… Check Spelling… 检查拼写… Find and Replace Text… 查找和替换文本… + Add to Dictionary 添加到词典 + No Spelling Suggestions 没有拼写建议 Fill… 填充… Stroke… 描边… Content-Aware Fill… 内容识别填充… @@ -144,6 +169,8 @@ Transform Again on a Copy 复制并再次变换 Scale 缩放 Rotate 旋转 + Set horizontal skew 设置水平斜切 + Set vertical skew 设置垂直斜切 Skew 斜切 Distort 扭曲 Perspective 透视 @@ -728,6 +755,10 @@ Breeze Dark Theme Breeze 深色主题 Breeze light Breeze 浅色 Breeze Light Theme Breeze 浅色主题 + Soft dark 柔和深色 + Soft light 柔和浅色 + Soft Dark Theme 柔和深色主题 + Soft Light Theme 柔和浅色主题 Next Theme 下一代主题 Actions 动作 Brush Settings 画笔设置 @@ -828,6 +859,9 @@ Appearance 外观 Appearance (Layer Style) 外观(图层样式) Apply Layer Mask 应用图层蒙版 + Add Mask to Selection 添加蒙版到选区 + Subtract Mask from Selection 从选区中减去蒙版 + Intersect Mask with Selection 蒙版与选区交叉 Apply Per Tip 应用于每个笔尖 Apply next selected layer comp 应用下一个选中的图层复合 Apply previous selected layer comp 应用上一个选中的图层复合 @@ -848,6 +882,12 @@ Baseline shift 基线偏移 Basic 基本 Before (Y) 调整前(Y) + Save Settings… 存储设置… + Load Settings… 载入设置… + Save these settings as a Camera Raw .xmp file 将这些设置存储为 Camera Raw .xmp 文件 + Load settings from a Camera Raw .xmp file 从 Camera Raw .xmp 文件载入设置 + Loaded Camera Raw settings from {file} 已从 {file} 载入 Camera Raw 设置 + Loaded Camera Raw settings from {file} ({n} not supported) 已从 {file} 载入 Camera Raw 设置({n} 项不支持) Begin recording 开始录制 Bend: 弯曲: Bicubic 双三次 @@ -1152,6 +1192,9 @@ Ignore All 全部忽略 Image Size 图像大小 Import Brushes… 导入画笔… + Gradient Preset Options 渐变预设选项 + Import .GRD 导入 .GRD + Choose a gradient file (.grd). 请选择渐变文件 (.grd)。 Import brushes (.abr) 导入画笔(.abr) Import Presets 导入预设 Imported 已导入 @@ -2148,6 +2191,11 @@ cameraRaw Blending 混合 Rename Brush… 重命名画笔… Delete Brush 删除画笔 New Brush Preset… 新建画笔预设… + Capture Brush Size in Preset 在预设中存储画笔大小 + Include Tool Settings 包含工具设置 + Include Color 包含颜色 + Enter a name for the brush preset 请输入画笔预设的名称 + A brush preset with this name already exists 已存在同名的画笔预设 Brush Preset Options 画笔预设选项 Group name 组名称 Brush name 画笔名称 @@ -2330,6 +2378,7 @@ cameraRaw Blending 混合 Click to pick · Esc to cancel 单击选取 · Esc 取消 Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消 Scroll to zoom 滚动以缩放 + Drag to pan · Scroll to zoom · Double-click to fit 拖动以平移 · 滚动以缩放 · 双击以适应窗口 Show/Hide All Panels 显示/隐藏所有面板 Show/Hide Panels 显示/隐藏面板 Could not open 无法打开 @@ -2654,9 +2703,14 @@ curves Smooth 平滑 Light Red 浅红色 Medium Blue 中蓝色 Light Gray 浅灰色 + Object Finder 对象查找程序 + No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Clear Brush Controls 清除画笔控件 + Reset All Locked Settings 复位所有锁定的设置 + Copy Texture to Other Tools 将纹理复制到其他工具 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 载入动作… @@ -2678,3 +2732,5 @@ curves Smooth 平滑 Action playback stopped 动作播放已停止 Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 @plural {n} action|{n} actions {n} 个动作 + Auto Add/Delete 自动添加/删除 + ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b72aa4509b..760e390812 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -19,7 +19,20 @@ # kept. Full-width punctuation (,。:) inside sentences; half-width parentheses and the " · " # separators as in the English. +# Channel Options and New Spot Channel (#3761) + Channel Color 色版顏色 + Color Indicates: 色彩指示: + Solidity: 硬度: + Masked Areas 遮色片區域 + Selected Areas 選取區域 + Spot Color 特別色 + Ink Characteristics 油墨特性 + # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 紙張寬度 + Paper Height 紙張高度 Columns 欄 Rows 列 Number 數量 @@ -77,6 +90,8 @@ Save 儲存 Save As… 另存新檔… Save a Copy… 儲存拷貝… + {name} can't keep everything in this document {name} 無法保留此文件中的所有內容 + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. 請選擇能保留全部內容的格式,例如 Photoshop (PSD),或儲存拷貝。 Revert 回復 Export 匯出 Quick Export as PNG 快速匯出為 PNG @@ -114,6 +129,14 @@ Script Events Manager… 指令碼事件管理員… Import 匯入 Browse… 瀏覽… + Source Files 來源檔案 + Use: 使用: + Files 檔案 + Folder 資料夾 + Add Open Files 增加開啟的檔案 + Sort By Name 依名稱排序 + Attempt to Automatically Align Source Images 嘗試自動對齊來源影像 + Create Smart Object after Loading Layers 載入圖層後建立智慧型物件 Reveal in Finder 在 Finder 中顯示 Show in Explorer 在檔案總管中顯示 Show in Folder 在資料夾中顯示 @@ -138,6 +161,8 @@ Search… 搜尋… Check Spelling… 拼字檢查… Find and Replace Text… 尋找並取代文字… + Add to Dictionary 加入字典 + No Spelling Suggestions 沒有拼字建議 Fill… 填滿… Stroke… 筆畫… Content-Aware Fill… 內容感知填滿… @@ -152,6 +177,8 @@ Transform Again on a Copy 複製並再次變形 Scale 縮放 Rotate 旋轉 + Set horizontal skew 設定水平傾斜 + Set vertical skew 設定垂直傾斜 Skew 傾斜 Distort 扭曲 Perspective 透視 @@ -733,6 +760,10 @@ Breeze Dark Theme Breeze 深色主題 Breeze light Breeze 淺色 Breeze Light Theme Breeze 淺色主題 + Soft dark 柔和深色 + Soft light 柔和淺色 + Soft Dark Theme 柔和深色主題 + Soft Light Theme 柔和淺色主題 Next Theme 下一個主題 Actions 動作 Brush Settings 筆刷設定 @@ -831,6 +862,9 @@ Appearance 外觀 Appearance (Layer Style) 外觀 (圖層樣式) Apply Layer Mask 套用圖層遮色片 + Add Mask to Selection 增加遮色片至選取範圍 + Subtract Mask from Selection 從選取範圍中減去遮色片 + Intersect Mask with Selection 遮色片與選取範圍相交 Apply Per Tip 依筆尖套用 Apply next selected layer comp 套用下一個選取的圖層構圖 Apply previous selected layer comp 套用上一個選取的圖層構圖 @@ -851,6 +885,12 @@ Baseline shift 基線位移 Basic 基本 Before (Y) 之前 (Y) + Save Settings… 儲存設定… + Load Settings… 載入設定… + Save these settings as a Camera Raw .xmp file 將這些設定儲存為 Camera Raw .xmp 檔案 + Load settings from a Camera Raw .xmp file 從 Camera Raw .xmp 檔案載入設定 + Loaded Camera Raw settings from {file} 已從 {file} 載入 Camera Raw 設定 + Loaded Camera Raw settings from {file} ({n} not supported) 已從 {file} 載入 Camera Raw 設定({n} 項不支援) Begin recording 開始記錄 Bend: 彎曲: Bicubic 雙三次 @@ -1153,6 +1193,9 @@ Ignore All 全部略過 Image Size 影像尺寸 Import Brushes… 匯入筆刷… + Gradient Preset Options 漸層預設選項 + Import .GRD 匯入 .GRD + Choose a gradient file (.grd). 請選擇漸層檔案 (.grd)。 Import brushes (.abr) 匯入筆刷 (.abr) Import Presets 匯入預設集 Imported 已匯入 @@ -2084,6 +2127,11 @@ Rename Brush… 重新命名筆刷… Delete Brush 刪除筆刷 New Brush Preset… 新增筆刷預設集… + Capture Brush Size in Preset 在預設集中擷取筆刷尺寸 + Include Tool Settings 包含工具設定 + Include Color 包含顏色 + Enter a name for the brush preset 請輸入筆刷預設集的名稱 + A brush preset with this name already exists 已有相同名稱的筆刷預設集 Brush Preset Options 筆刷預設集選項 Group name 群組名稱 Brush name 筆刷名稱 @@ -2300,6 +2348,7 @@ cameraRaw Blending 混合 Click to pick · Esc to cancel 按一下選取 · Esc 取消 Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消 Scroll to zoom 捲動以縮放 + Drag to pan · Scroll to zoom · Double-click to fit 拖曳以平移 · 捲動以縮放 · 按兩下以符合視窗 Show/Hide All Panels 顯示/隱藏所有面板 Show/Hide Panels 顯示/隱藏面板 Could not open 無法開啟 @@ -2639,9 +2688,14 @@ curves Smooth 平滑 Light Red 淺紅色 Medium Blue 中藍色 Light Gray 淺灰色 + Object Finder 物件尋找器 + No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Clear Brush Controls 清除筆刷控制項 + Reset All Locked Settings 重設所有鎖定的設定 + Copy Texture to Other Tools 複製紋理至其他工具 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 載入動作… @@ -2663,3 +2717,5 @@ curves Smooth 平滑 Action playback stopped 動作播放已停止 Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 @plural {n} action|{n} actions {n} 個動作 + Auto Add/Delete 自動增加/刪除 + ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index dc0bc0b500..4839fdfc76 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -54,6 +54,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("folder-plus", include_bytes!("../../../assets/icons/folder-plus.svg")), ("frame-placeholder", include_bytes!("../../../assets/icons/frame-placeholder.svg")), ("gauge", include_bytes!("../../../assets/icons/gauge.svg")), + // PhotoCraft original: the Gradient tool glyph (a rectangle filled with a linear ramp). + ("gradient", include_bytes!("../../../assets/icons/gradient.svg")), ("grid-2x2", include_bytes!("../../../assets/icons/grid-2x2.svg")), ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), ("hand", include_bytes!("../../../assets/icons/hand.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0b3b58dd04..215e5994e8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,7 +111,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Slice => "slice-knife", Tool::SliceSelect => "square-dashed-mouse-pointer", Tool::Frame => "frame-placeholder", - Tool::Gradient => "blend", + Tool::Gradient => "gradient", Tool::PaintBucket => "paint-bucket", Tool::Type | Tool::VerticalType => "type", Tool::HorizontalTypeMask => "type-mask", @@ -177,6 +177,12 @@ pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tool /// Square icon button with an explicit glyph size, independent of its hit area. pub fn button_with_icon_size(ui: &mut egui::Ui, name: &str, box_size: f32, icon_size: f32, selected: bool, tooltip: &str) -> Response { + button_with_icon_size_tip(ui, name, box_size, icon_size, selected, tooltip, true) +} + +/// [`button_with_icon_size`] whose tooltip shows only when `show_tip`; the tooltip text still +/// names the button (for screen readers and tests) when it is hidden. +pub fn button_with_icon_size_tip(ui: &mut egui::Ui, name: &str, box_size: f32, icon_size: f32, selected: bool, tooltip: &str, show_tip: bool) -> Response { let t = Tokens::get(ui.ctx()); let style = IconButtonStyle { normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: egui::Stroke::NONE, icon: t.icon }, @@ -190,7 +196,7 @@ pub fn button_with_icon_size(ui: &mut egui::Ui, name: &str, box_size: f32, icon_ let resp = IconButton::new(label, Vec2::splat(box_size), &style).selected(selected).show(ui, |ui, rect, tint| { paint(ui, rect, name, icon_size, tint); }); - if tooltip.is_empty() { resp } else { resp.on_hover_text(label) } + if tooltip.is_empty() || !show_tip { resp } else { resp.on_hover_text(label) } } /// Rail toggle: "on" gets a quiet filled background and full-strength icon (no accent). @@ -218,6 +224,28 @@ mod tests { for t in Tool::ALL { assert!(exists(tool_icon(t)), "{t:?}"); } + assert_eq!(tool_icon(Tool::Gradient), "gradient"); + assert_ne!(tool_icon(Tool::Gradient), tool_icon(Tool::PaintBucket)); + } + + #[test] + fn gradient_tool_icon_rasterizes_left_to_right_ramp() { + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(tool_icon(Tool::Gradient)) else { + panic!("icon source must be embedded bytes"); + }; + let image = egui_extras::image::load_svg_bytes_with_size( + bytes.as_ref(), + egui::load::SizeHint::Size { width: 24, height: 24, maintain_aspect_ratio: true }, + &Default::default(), + ) + .unwrap(); + let left_alpha = image[(6, 12)].a(); + let mid_alpha = image[(12, 12)].a(); + let right_alpha = image[(18, 12)].a(); + assert!( + left_alpha > mid_alpha && mid_alpha > right_alpha, + "interior ramp should fade from left ({left_alpha}) through middle ({mid_alpha}) to right ({right_alpha})" + ); } #[test] diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 956a9de9ba..3754e821fc 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -504,10 +504,12 @@ fn type_tool_alt_arrows_kern_the_pair() { h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::ArrowRight); h.run_steps(2); assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((3, 6))); - // With a selection Alt+→ moves by word instead of kerning; at the text end there's no pair. + // With a selection Alt+→ tracks it instead (see `type_tool_format_keys_*`); at the text end + // there's no pair to kern. let before = kern(&h); - h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); + h.key_press(Key::ArrowRight); h.run_steps(2); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((6, 6))); h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); h.run_steps(2); assert_eq!(kern(&h), before); @@ -518,6 +520,245 @@ fn type_tool_alt_arrows_kern_the_pair() { assert_eq!(kern(&h).1, metric + 120.0); } +/// Starts a type editing session on `layer` with `anchor..caret` selected. +fn edit_type(h: &mut Harness<'static, PhotocraftApp>, layer: u64, anchor: usize, caret: usize) { + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer, + caret, + anchor, + session: "format-keys".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + h.run_steps(2); +} + +fn type_layer(h: &Harness<'static, PhotocraftApp>, id: u64) -> photocraft_doc::TextLayer { + match &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Text(t) => t.clone(), + _ => panic!("not text"), + } +} + +/// The character style of character `c` of a type layer. +fn char_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::CharStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.char_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte || t.text.is_empty() + }) + .unwrap() + .style +} + +/// The paragraph style of the paragraph holding character `c`. +fn para_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::ParagraphStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.paragraph_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte + }) + .unwrap() + .style +} + +fn history(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().history.entries() +} + +/// Photoshop's ⇧⌘K/U/B/I/: toggle All Caps, Underline, Faux Bold, Faux Italic and Strikethrough +/// on the selected characters while typing, one step per press, and never reach the global +/// ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse). +#[test] +fn type_tool_format_keys_toggle_character_styles() { + use photocraft_doc::text::{Caps, CharStyle}; + let mut h = harness(); + // A selection, so Select ▸ Inverse would be enabled if the key reached it. + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + type Has = fn(&CharStyle) -> bool; + let keys: [(Key, Has); 6] = [ + (Key::K, |s| s.caps == Caps::AllCaps), + (Key::U, |s| s.underline), + (Key::B, |s| s.faux_bold), + (Key::I, |s| s.faux_italic), + (Key::Slash, |s| s.strikethrough), + (Key::Questionmark, |s| s.strikethrough), + ]; + for (key, has) in keys { + let before = history(&h).len(); + let was = has(&char_style(&h, id, 0)); + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + assert_eq!(has(&char_style(&h, id, 0)), !was, "{key:?} toggles the selection"); + assert_eq!(has(&char_style(&h, id, 4)), !was, "{key:?}"); + assert!(!has(&char_style(&h, id, 6)), "{key:?} leaves the unselected characters"); + let steps = history(&h); + assert_eq!(steps.len(), before + 1, "{key:?}: one history step"); + assert_eq!(steps.last().map(String::as_str), Some("Set Type Style"), "{key:?}"); + assert!(h.state().ui.dialogs.is_empty(), "{key:?} must not open Color Settings"); + assert!(h.state().ui.text_edit.is_some()); + } + // The second strikethrough key turned it off again; press the rest again: all off. + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + let s = char_style(&h, id, 2); + assert!(s.caps == Caps::Normal && !s.underline && !s.faux_bold && !s.faux_italic && !s.strikethrough); + // Partly underlined: the key underlines the whole selection rather than flipping each part. + h.state_mut().run("type.setStyle", json!({"layer": id, "range": [0, 2], "underline": true})).unwrap(); + h.key_press_modifiers(cmd_shift, Key::U); + h.run_steps(2); + assert!((0..5).all(|c| char_style(&h, id, c).underline)); + // Undo takes back one press. + assert!(h.state_mut().session.undo()); + h.run_steps(1); + assert!(char_style(&h, id, 0).underline && !char_style(&h, id, 3).underline); + // A collapsed caret in non-empty text: nothing changes, and nothing global runs. + edit_type(&mut h, id, 3, 3); + let before = history(&h); + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + assert_eq!(history(&h), before); + assert!(h.state().ui.dialogs.is_empty()); + // Empty text: the key sets the style the next characters typed get. + let empty = h.state_mut().run("type.create", json!({"x": 20, "y": 160, "text": "", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, empty, 0, 0); + h.key_press_modifiers(cmd_shift, Key::B); + h.run_steps(2); + h.event(egui::Event::Text("ab".into())); + h.run_steps(2); + assert_eq!(type_layer(&h, empty).text, "ab"); + assert!(char_style(&h, empty, 0).faux_bold && char_style(&h, empty, 1).faux_bold); +} + +/// Outside text editing ⇧⌘K and ⇧⌘I keep their global meanings. +#[test] +fn global_cmd_shift_keys_still_work_outside_type_editing() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none()); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + h.key_press_modifiers(cmd_shift, Key::I); + h.run_steps(2); + assert_eq!(history(&h).last().map(String::as_str), Some("Inverse")); + h.key_press_modifiers(cmd_shift, Key::K); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1, "Color Settings opens"); + assert!(!char_style(&h, id, 0).faux_italic && char_style(&h, id, 0).caps == photocraft_doc::text::Caps::Normal); +} + +/// ⌥↑/↓ leading (2 pt, 10 with ⌘), ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking over a selection +/// (20, 100 with ⌘): one step per press, from each character's own value, the selection kept. +#[test] +fn type_tool_format_keys_step_leading_baseline_and_tracking() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let before = history(&h).len(); + let press = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, k: Key| { + h.key_press_modifiers(m, k); + h.run_steps(2); + }; + // Auto leading is 120% of 10 pt: ⌥↓ makes it 14. + press(&mut h, Modifiers::ALT, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(14.0)); + press(&mut h, Modifiers::ALT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 4).leading_pt, Some(12.0)); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(22.0)); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, 2.0); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, -8.0); + press(&mut h, Modifiers::ALT, Key::ArrowRight); + assert_eq!(char_style(&h, id, 0).tracking, 20.0); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowLeft); + assert_eq!(char_style(&h, id, 4).tracking, -80.0); + assert_eq!(history(&h).len(), before + 7, "one history step per press"); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((0, 5)), "the selection stays"); + let rest = char_style(&h, id, 7); + assert_eq!((rest.leading_pt, rest.baseline_shift_pt, rest.tracking), (None, 0.0, 0.0)); + // A collapsed caret: ⌥↑/↓ change nothing and don't move the caret. + edit_type(&mut h, id, 8, 8); + let steps = history(&h).len(); + press(&mut h, Modifiers::ALT, Key::ArrowDown); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(history(&h).len(), steps); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((8, 8))); +} + +/// ⌥⇧⌘H toggles Hyphenate for the paragraphs the selection or caret touches. +#[test] +fn type_tool_format_keys_toggle_hyphenate() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"box": [10, 10, 300, 200], "text": "one\ntwo\nthree", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let keys = Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT; + // The caret in the second paragraph. + edit_type(&mut h, id, 5, 5); + let before = history(&h).len(); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, true, false]); + // A selection over the first two: not all hyphenate, so both do; again, neither. + edit_type(&mut h, id, 1, 5); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [true, true, false]); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, false, false]); + assert_eq!(history(&h).len(), before + 3); +} + +/// The formatting keys never panic: an edit of a layer that has gone, or with no document. +#[test] +fn type_tool_format_keys_survive_hostile_state() { + let mut h = harness(); + edit_type(&mut h, 9999, 0, 3); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::U); + h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none(), "an edit of a missing layer ends"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for ed in [None, Some(1)] { + app.ui.text_edit = ed.map(|layer| crate::state::TextEdit { + layer, + caret: 4, + anchor: 0, + session: "x".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + crate::type_tool::toggle_char_style(&mut app, crate::type_tool::CharToggle::AllCaps); + crate::type_tool::step_char_metric(&mut app, "leadingBy", 2.0); + crate::type_tool::step_char_metric(&mut app, "trackingBy", f32::NAN); + crate::type_tool::toggle_hyphenate(&mut app); + } + assert!(app.session.active().is_none()); +} + /// The Character panel's kerning field reads and parses Photoshop's values. #[test] fn kerning_field_values() { @@ -669,6 +910,35 @@ fn esc_ends_a_pen_path_and_keeps_it() { assert_eq!(h.state().ui.selected_path.as_deref(), Some("work")); } +/// Over the first anchor of a Pen path the canvas shows the close-path pointer (#4596), a bitmap +/// of its own; anywhere else the Pen keeps its crosshair. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn pen_shows_the_close_path_pointer_over_the_first_anchor() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::Pen; + h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3]], ..Default::default() }); + let screen = |h: &Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let v = &h.state().ui.views[0]; + let c = h.state().last_canvas_rect.center(); + pos2(c.x + (x - v.center[0]) * v.zoom, c.y + (y - v.center[1]) * v.zoom) + }; + let pointer = |h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let p = screen(h, x, y); + h.hover_at(p); + h.run_steps(2); + let out = &h.output().platform_output; + (out.cursor_icon, out.cursor_image.as_ref().map(|i| i.rgba.clone())) + }; + let close = pointer(&mut h, 50.0, 50.0); + assert_eq!(close.0, egui::CursorIcon::Crosshair, "the fallback under the bitmap"); + assert!(close.1.is_some(), "a bitmap"); + let elsewhere = pointer(&mut h, 200.0, 200.0); + assert_ne!(elsewhere.1, close.1, "empty canvas: the plain Pen pointer"); + assert_ne!(pointer(&mut h, 150.0, 50.0).1, close.1, "the last anchor doesn't close"); + assert_eq!(pointer(&mut h, 50.0, 50.0), close, "back on the first anchor"); +} + /// Esc with no active operation deselects (#2674), like ⌘D. #[test] fn escape_deselects_when_nothing_else_needs_cancelling() { @@ -782,3 +1052,38 @@ fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() { let d1 = doc_at(&h, p); assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}"); } + +/// #4658: with the Move tool's Show Transform Controls, the band just outside the box shows the +/// curved rotate arrow, not a plain pointer, and one drag from there rotates the layer. +#[test] +fn move_tool_controls_rotate_from_just_outside_the_box() { + let mut h = harness(); + h.state_mut().run("shape.create", json!({"kind": "rect", "rect": [150, 100, 100, 100], "fill": "#ff0000"})).unwrap(); + h.state_mut().ui.tool = crate::state::Tool::Move; + h.state_mut().ui.tool_options.move_show_transform = true; + h.run_steps(3); + let screen = |h: &Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let v = &h.state().ui.views[0]; + let c = h.state().last_canvas_rect.center(); + pos2(c.x + (x - v.center[0]) * v.zoom, c.y + (y - v.center[1]) * v.zoom) + }; + let zoom = h.state().ui.views[0].zoom; + // Just outside the top edge, a quarter of the way along: the rotation band. + let at = screen(&h, 175.0, 100.0) - vec2(0.0, 12.0); + h.hover_at(at); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the drawn rotate arrow replaces the pointer"); + press(&mut h, at, true); + let centre = screen(&h, 200.0, 150.0); + // A quarter turn around the box's centre. + for k in 1..=10 { + let a = std::f32::consts::FRAC_PI_2 * k as f32 / 10.0; + let (dx, dy) = (at.x - centre.x, at.y - centre.y); + let q = centre + vec2(dx * a.cos() - dy * a.sin(), dx * a.sin() + dy * a.cos()); + h.hover_at(q); + h.run_steps(1); + } + let end = h.state().ui.transform.as_ref().map(|t| t.quad).expect("the drag opened Free Transform"); + let tilt = (end[1][1] - end[0][1]).atan2(end[1][0] - end[0][0]).to_degrees(); + assert!((tilt - 90.0).abs() < 8.0 || (tilt + 270.0).abs() < 8.0, "rotated about a quarter turn, got {tilt}° (zoom {zoom})"); +} diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 5dc1cba41d..99479f4e32 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -449,6 +449,8 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { true } }); + // A Free Transform commit's session ends with its job (then reported like any job). + crate::transform_tool::on_job_event(app, &e); if crate::prefs_ui::on_recovery_event(app, &e) { return; } @@ -538,6 +540,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu app.opened_from(p); } app.sync_views(); + let warnings = crate::file_open::with_missing_fonts(app, warnings); app.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); app.ui.status_error = false; // A raw opens in Camera Raw first; the dialog says how it was developed. diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index bc29064512..f2b7d0fbc0 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -704,3 +704,81 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { } } } + +fn layer_count(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.layers.len() +} + +/// Free Transform the active layer to the right half of the canvas, then ↩. +fn transform_and_commit(h: &mut Harness<'static, PhotocraftApp>, copy: bool) { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + if copy { crate::transform_tool::begin_copy(app, &ctx) } else { crate::transform_tool::begin(app, &ctx) }.unwrap(); + app.ui.transform.as_mut().unwrap().quad = [[160.0, 0.0], [320.0, 0.0], [320.0, 120.0], [160.0, 120.0]]; + crate::transform_tool::commit(app); +} + +#[test] +fn a_free_transform_commit_runs_in_the_background_and_keeps_its_preview() { + let mut h = app_harness(); + let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len()); + transform_and_commit(&mut h, true); + let app = h.state(); + // The warp runs as a job; the moving pixels stay on screen until it lands, and the box's + // history is closed meanwhile. + let job = crate::transform_tool::committing(app).expect("the commit runs as a job"); + assert!(app.session.job(job).is_some_and(|j| j.label == "Free Transform")); + assert!(app.ui.transform.is_some() && app.transform_preview.is_some()); + assert_eq!(crate::transform_tool::is_enabled(app, "edit.undo"), Some(false)); + assert!(h.state_mut().run("edit.undo", json!({})).is_err()); + crate::transform_tool::commit(h.state_mut()); + let t = Instant::now(); + while h.state().session.has_jobs() && t.elapsed() < Duration::from_secs(10) { + h.step(); + } + h.run_steps(2); + let app = h.state(); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the session ends with the job"); + // The copy and its transform are one step, as when the commit ran inline. + let st = app.session.active().unwrap(); + assert_eq!((layer_count(app), st.history.past_len()), (layers + 1, steps + 1)); + assert_eq!(st.history.undo_label(), Some("Free Transform")); + let moved = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!((moved.x0, moved.y0, moved.x1, moved.y1), (160, 0, 320, 120)); +} + +#[test] +fn esc_cancels_a_background_free_transform_commit() { + let mut h = app_harness(); + let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len()); + let before = h.state().session.active().unwrap().doc.clone(); + transform_and_commit(&mut h, true); + let job = crate::transform_tool::committing(h.state()).expect("the commit runs as a job"); + // Esc reaches the transform tool first (shortcuts), which cancels the job. + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(3); + let app = h.state(); + assert!(app.session.job(job).is_none()); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); + // The copy is taken back with the transform, leaving nothing to redo. + let st = app.session.active().unwrap(); + assert_eq!((layer_count(app), st.history.past_len()), (layers, steps)); + assert!(!st.history.can_redo()); + assert_eq!(st.doc.layers.len(), before.layers.len()); +} + +/// #4559: a background open (the desktop app's) also warns, when its document arrives, about +/// type in a font that isn't installed. +#[test] +fn background_open_warns_about_missing_fonts() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + s.execute("type.create", json!({"text": "Hi", "x": 2, "y": 12, "size": 10, "font": "NoSuchFamily4559"})).unwrap(); + let doc = (*s.active().unwrap().doc).clone(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // What the open job leaves behind: the decoded document in the session, then its result. + let (index, _) = app.session.open_document(doc, None); + super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "warnings": []}), None).unwrap(); + let want = vec!["font 'NoSuchFamily4559' is not installed; text may render using a fallback face".to_string()]; + assert!(app.ui.notices.last().is_some_and(|n| n.title.contains("poster.psd") && n.lines == want), "{:?}", app.ui.notices); +} diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index 5be01c750a..beb3e9bc32 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -157,6 +157,41 @@ pub fn show(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, on_set: bo }) } +/// The menu of `l`'s layer-mask thumbnail (#4575), in Photoshop's order. It acts on `l`, which it +/// selects first like the row menu. Photoshop's Mask Options… (the overlay colour) has no +/// command yet, so it is left out. +pub fn show_mask(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, actions: &mut Vec<(String, Value)>) { + let Some(mask) = &l.mask else { return }; + crate::widgets::menu_scroll(ui, |ui| { + ui.set_min_width(220.0); + let edits = vec![ + Some((tl!(mask_toggle_label(mask.enabled)), "layer.layerMask.enabled")), + Some((tl!("Delete Layer Mask"), "layer.layerMask.delete")), + Some((tl!("Apply Layer Mask"), "layer.layerMask.apply")), + ]; + items(app, ui, l, false, edits, actions); + ui.separator(); + // ⌘⇧-, ⌘⌥- and ⌘⇧⌥-clicking the thumbnail, as menu items. + let loads = + [(tl!("Add Mask to Selection"), "add"), (tl!("Subtract Mask from Selection"), "subtract"), (tl!("Intersect Mask with Selection"), "intersect")]; + for (label, operation) in loads { + if ui.button(label).clicked() { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + actions.push(("select.loadSelection".into(), json!({"channel": "mask", "layer": l.id.0, "operation": operation}))); + ui.close(); + } + } + ui.separator(); + if ui.add_enabled(crate::menus::is_enabled(app, "select.selectAndMask"), egui::Button::new(tl!("Select and Mask…"))).clicked() { + // Like double-clicking the thumbnail with that preference on: it refines the mask. + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + actions.push(("ui.maskTarget".into(), json!(true))); + actions.push(("select.selectAndMask".into(), Value::Null)); + ui.close(); + } + }); +} + /// Render the layer style menu of `l`'s effect rows ([`effects_entries`]); the actions select `l` /// first when it isn't the active layer. pub fn show_effects(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, actions: &mut Vec<(String, Value)>) { @@ -434,6 +469,60 @@ mod tests { } } + /// #4575: right-clicking a layer-mask thumbnail opens Photoshop's mask menu, not the layer + /// menu, and its selection items load the clicked layer's mask (not the active layer's). + #[test] + fn mask_thumbnail_context_menu_acts_on_the_clicked_mask() { + use crate::PhotocraftApp; + use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; + use egui_kittest::{Harness, kittest::Queryable}; + + fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, button: PointerButton) { + h.hover_at(at); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + } + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 100, "height": 60})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Masked"})).unwrap(); + s.execute("select.rect", json!({"x": 60, "y": 0, "width": 40, "height": 60})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + let masked = s.active().unwrap().active_layer.unwrap(); + // Another layer is active, and the selection doesn't overlap the mask. + s.execute("layer.new.layer", json!({"name": "Other active layer"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 60})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(8); + let (thumbs, _) = crate::mask_thumbs_ui::recorded(&h.ctx, masked.0).expect("mask thumbnail drawn"); + let at = thumbs.iter().find(|(k, _)| *k == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1.center(); + click(&mut h, at, PointerButton::Secondary); + assert!(h.query_by_label("Blending Options…").is_none(), "the mask menu, not the layer menu"); + for item in + ["Disable Layer Mask", "Delete Layer Mask", "Apply Layer Mask", "Subtract Mask from Selection", "Intersect Mask with Selection", "Select and Mask…"] + { + h.get_by_label(item); + } + let at = h.get_by_label("Add Mask to Selection").rect().center(); + click(&mut h, at, PointerButton::Primary); + let st = h.state().session.active().unwrap(); + assert_eq!(st.active_layer, Some(masked)); + let sel = st.doc.selection.as_ref().expect("a selection"); + assert!(sel.sample_channel(10, 30, 0) > 0.99, "the selection is kept"); + assert!(sel.sample_channel(80, 30, 0) > 0.99, "the clicked layer's mask is added"); + assert!(sel.sample_channel(40, 30, 0) < 0.01, "nothing else is selected"); + } + #[test] fn a_long_layer_menu_stays_inside_a_short_window_and_scrolls() { let mut s = photocraft_engine::Session::new(); diff --git a/crates/ui-egui/src/layer_pick_ui.rs b/crates/ui-egui/src/layer_pick_ui.rs index 654751b953..b75fe2b47b 100644 --- a/crates/ui-egui/src/layer_pick_ui.rs +++ b/crates/ui-egui/src/layer_pick_ui.rs @@ -2,8 +2,8 @@ //! with any tool, lists the layers with visible pixels under the pointer, topmost first, as //! Photoshop does. Choosing one selects it. //! -//! The hit test is `layer.pickAt` with `"list": true` (non-transparent pixels, layer masks, hidden -//! layers and hidden groups skipped), and choosing runs `layer.select`, so the menu is the same +//! The hit test is `layer.pickAt` with `"list": true` (non-transparent pixels, text bounds, +//! layer masks, hidden layers and hidden groups), and choosing runs `layer.select`, so the menu is the same //! two commands an agent would run. Painting tools keep their plain right-click (the Brush //! Preset picker, #268); the ⌘/Ctrl variant reaches the layer list from them too. @@ -27,7 +27,7 @@ pub fn is_gesture(tool: Tool, mods: Modifiers) -> bool { tool == Tool::Move || mods.command } -/// The layers with pixels at document point (x, y), topmost first. +/// The layers at document point (x, y), topmost first, including text between glyphs. pub fn layers_at(app: &mut PhotocraftApp, x: f64, y: f64) -> Vec<(u64, String)> { if !(x.is_finite() && y.is_finite()) { return Vec::new(); diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index a0d2d7f058..c2ea848c5e 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -124,7 +124,6 @@ const LINK_W: f32 = 20.0; /// sections (Character/Paragraph/Type Options for type, Appearance/Shape for shapes), then Quick /// Actions, all with the same collapsible headers (#155). pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { - let t = Tokens::get(ui.ctx()); let mut run: Vec<(String, Value)> = Vec::new(); if let Some(s) = layer.surface() { let b = app.cached_bounds(layer.id.0, s); @@ -167,38 +166,8 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if gradient { crate::gradient_ui::properties(app, ui, layer); } - if !gradient && section(ui, "align", tl!("Align and Distribute")) { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - for (i, kind) in ["leftEdges", "horizontalCenters", "rightEdges", "topEdges", "verticalCenters", "bottomEdges"].into_iter().enumerate() { - if i == 3 { - ui.add_space(8.0); - } - let id = format!("layer.align.{kind}"); - let on = app.session.is_enabled(&id); - let label = photocraft_engine::commands::find(&id).map_or(kind, |c| c.label); - let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), if on { Sense::click() } else { Sense::hover() }); - if resp.hovered() && on { - ui.painter().rect_filled(r, t.radius_sm, t.hover); - } - align_glyph(ui, r, kind, if on { t.icon } else { t.text_faint }); - if resp.on_hover_text(crate::i18n::fmt(tl!("Align {what}"), &[("what", tl!(&label))])).clicked() { - run.push((id, json!({}))); - } - } - }); - // The engine already supports equal-gap distribution in both axes. Make those - // existing commands discoverable here as well as through Layer > Distribute (#2349). - // Three or more selected layers are required; the engine remains authoritative. - ui.horizontal_wrapped(|ui| { - for (kind, text) in [("horizontally", tl!("Horizontally")), ("vertically", tl!("Vertically"))] { - let id = format!("layer.distribute.{kind}"); - if ui.add_enabled(app.session.is_enabled(&id), egui::Button::new(text)).clicked() { - run.push((id, json!({}))); - } - } - }); - ui.add_space(ROW_GAP); + if !gradient { + align_and_distribute(app, ui, &mut run); } for (id, p) in run.drain(..) { if let Err(e) = app.run(&id, p) { @@ -222,6 +191,61 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { } } +/// The Align and Distribute section: its buttons run `layer.align.*` and `layer.distribute.*` on +/// the selected layers (pushed onto `run`), greyed where the engine says they can't. +fn align_and_distribute(app: &PhotocraftApp, ui: &mut egui::Ui, run: &mut Vec<(String, Value)>) { + let t = Tokens::get(ui.ctx()); + if !section(ui, "align", tl!("Align and Distribute")) { + return; + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (i, kind) in ["leftEdges", "horizontalCenters", "rightEdges", "topEdges", "verticalCenters", "bottomEdges"].into_iter().enumerate() { + if i == 3 { + ui.add_space(8.0); + } + let id = format!("layer.align.{kind}"); + let on = app.session.is_enabled(&id); + let label = photocraft_engine::commands::find(&id).map_or(kind, |c| c.label); + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), if on { Sense::click() } else { Sense::hover() }); + if resp.hovered() && on { + ui.painter().rect_filled(r, t.radius_sm, t.hover); + } + align_glyph(ui, r, kind, if on { t.icon } else { t.text_faint }); + let tip = crate::i18n::fmt(tl!("Align {what}"), &[("what", tl!(&label))]); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, &tip)); + if resp.on_hover_text(tip).clicked() { + run.push((id, json!({}))); + } + } + }); + // The engine already supports equal-gap distribution in both axes. Make those + // existing commands discoverable here as well as through Layer > Distribute (#2349). + // Three or more selected layers are required; the engine remains authoritative. + ui.horizontal_wrapped(|ui| { + for (kind, text) in [("horizontally", tl!("Horizontally")), ("vertically", tl!("Vertically"))] { + let id = format!("layer.distribute.{kind}"); + if ui.add_enabled(app.session.is_enabled(&id), egui::Button::new(text)).clicked() { + run.push((id, json!({}))); + } + } + }); + ui.add_space(ROW_GAP); +} + +/// Several layers selected with the Background active: Photoshop's Properties keeps Align and +/// Distribute for the selection there, above the Document properties (#4608). +pub fn selection_align(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let mut run = Vec::new(); + align_and_distribute(app, ui, &mut run); + for (id, p) in run { + if let Err(e) = app.run(&id, p) { + app.ui.status = e; + app.ui.status_error = true; + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index fb229fcbc8..7ac725a7cb 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -71,7 +71,7 @@ pub fn layout(row: Rect, name_left: f32, items: &[(Indicator, f32)]) -> (Vec<(In /// Width the always-shown indicators of `l` take at the row's right (blend label excluded). pub fn reserved_width(l: &Layer) -> f32 { let mut w = RIGHT_PAD; - if l.locks.transparency || l.locks.position || l.locks.all { + if l.locks.any() { w += ICON_W + GAP; } if !l.effects.items.is_empty() { @@ -113,7 +113,7 @@ pub fn indicators( actions: &mut Vec<(String, Value)>, ) -> (f32, Vec<(Indicator, Rect)>, bool) { let t = Tokens::get(ui.ctx()); - let locked = l.locks.transparency || l.locks.position || l.locks.all; + let locked = l.locks.any(); let fx_font = theme::semibold(11.0); let blend_font = FontId::proportional(10.5); let has_fx = !l.effects.items.is_empty(); diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index f2ac633ba2..6ec1241b46 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -3,7 +3,7 @@ use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use photocraft_doc::{Effect, LayerContent}; use serde_json::json; @@ -171,7 +171,7 @@ fn the_eyes_on_the_effects_rows_hide_and_show_effects() { let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); let eye = |h: &Harness<'_, PhotocraftApp>, row: &str| { let r = h.get_by_label(row).rect(); - pos2(r.left() + 15.0, r.center().y) + pos2(r.left() + 45.0, r.center().y) }; let (settings, n) = (h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().effects.items.clone(), steps(&h)); assert_eq!(fx(&h), (true, vec![true, true])); @@ -536,3 +536,74 @@ fn press_drag_release_on_the_fx_button_opens_that_style() { let effects = open[0].fields["effects"].as_array().unwrap(); assert!(effects.iter().any(|e| e["kind"] == json!("dropShadow")), "on Drop Shadow: {effects:?}"); } + +/// One layer per lock, each with only that lock set (#4158). +fn one_lock_each() -> (photocraft_engine::Session, Vec<(&'static str, u64)>) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let mut ids = Vec::new(); + for key in ["transparency", "pixels", "position", "artboard", "all"] { + let id = s.execute("layer.new.layer", json!({"name": key})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locks": {key: true}})).unwrap(); + ids.push((key, id)); + } + (s, ids) +} + +#[test] +fn every_lock_shows_the_lock_icon_on_the_row() { + // #4158: "Lock image pixels" and the artboard lock left the row without its lock icon. + // Photoshop shows it whenever any lock is set. + let (s, ids) = one_lock_each(); + let doc = &s.active().unwrap().doc; + for &(key, id) in &ids { + let l = doc.layers.iter().find(|l| l.id.0 == id).unwrap(); + assert_eq!(super::reserved_width(l), super::RIGHT_PAD + super::ICON_W + super::GAP, "{key}: room for the lock icon"); + } + let h = harness(s, 1.0, "promedium", 290.0); + let rows = recorded(&h.ctx); + for &(key, id) in &ids { + let r = rows.iter().find(|r| r.layer == id).unwrap_or_else(|| panic!("{key}: row drawn")); + assert!(r.indicators.iter().any(|(k, _)| *k == Indicator::Lock), "{key}: lock icon drawn ({:?})", r.indicators); + } +} + +#[test] +fn the_studio_lock_button_shows_every_lock() { + // #4158: the simple themes' lock button drew "unlocked" for a pixels or artboard lock, though + // clicking it then unlocked the layer. + for key in ["transparency", "pixels", "position", "artboard", "all"] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": key})).unwrap(); + s.execute("layer.setProps", json!({"locks": {key: true}})).unwrap(); + let h = harness(s, 1.0, "studio", 290.0); + assert_eq!(h.get_by_label("Lock layer").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{key}"); + } +} + +#[test] +fn clicking_the_lock_icon_on_a_row_unlocks_the_layer() { + // #4723: the row's lock icon was only painted. In Photoshop a click on it clears every lock, + // and on the Background makes it a normal layer. + let (s, ids) = one_lock_each(); + let bg = s.active().unwrap().doc.background().unwrap().id; + let mut h = harness(s, 1.0, "promedium", 290.0); + let active = h.state().session.active().unwrap().active_layer; + let lock_icon = |h: &Harness<'_, PhotocraftApp>, id: u64| { + let row = recorded(&h.ctx).into_iter().find(|r| r.layer == id).unwrap(); + row.indicators.into_iter().find(|(k, _)| *k == Indicator::Lock).unwrap_or_else(|| panic!("lock icon on {id}")).1.center() + }; + for &(key, id) in &ids { + let p = lock_icon(&h, id); + click(&mut h, p); + let d = h.state().session.active().unwrap(); + assert!(!d.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.any(), "{key}: unlocked"); + assert_eq!(d.active_layer, active, "{key}: the lock icon doesn't select"); + } + let p = lock_icon(&h, bg.0); + click(&mut h, p); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.background().is_none(), "the Background became a normal layer"); + assert!(!doc.layer(bg).unwrap().locks.any()); +} diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index b350cef521..d3606e30b7 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -14,7 +14,7 @@ //! the page-only `blendIfChannel`; `channelNames`: the document's colour channels. use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; -use photocraft_doc::effects::{FxPaint, StrokePosition}; +use photocraft_doc::effects::{FxPaint, GlowSource, StrokePosition}; use photocraft_doc::{Effect, Layer}; use serde_json::{Map, Value, json}; @@ -247,6 +247,9 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "contour", photocraft_engine::layer_style::contour_param(&g.contour)); set(&mut v, "antiAlias", json!(g.anti_alias)); set(&mut v, "noise", json!((g.noise * 100.0).round())); + if matches!(e, Effect::InnerGlow(_)) { + set(&mut v, "source", json!(if g.source == GlowSource::Center { "center" } else { "edge" })); + } } Effect::Stroke(s) => { set(&mut v, "blend", json!(s.common.blend.label())); @@ -512,6 +515,16 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, f)) } +/// Double-click on an effect row of the Layers panel: open Layer Style for that row's layer. A +/// row of another layer selects it first, as its right-click menu does (Photoshop does the same). +pub fn open_for_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId, select: Option<&str>) -> Option { + let active = app.session.active().is_some_and(|s| s.active_layer == Some(layer)); + if !active { + app.run("layer.select", json!({"layer": layer.0})).ok()?; + } + open(app, select) +} + /// `[[id, name], …]` of the patterns a style can use (document's, then the library's). pub fn pattern_list(app: &PhotocraftApp) -> Value { let mut out: Vec = Vec::new(); @@ -1380,6 +1393,30 @@ mod tests { assert_eq!(rows, KINDS.len(), "one empty checkbox per effect row"); } + /// Double-clicking an effect row of a layer that isn't active selects that layer first, so + /// the dialog holds its effects (and OK edits it), not the active layer's. + #[test] + fn effect_row_of_another_layer_opens_that_layers_style() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let with_fx = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerStyle.replace", json!({"layer": with_fx.0, "effects": [{"kind": "dropShadow", "params": defaults("dropShadow")}]})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let plain = app.session.active().unwrap().active_layer.unwrap(); + assert_ne!(plain, with_fx); + assert!(app.session.active().unwrap().doc.layer(plain).unwrap().effects.items.is_empty()); + + let id = open_for_layer(&mut app, with_fx, Some("dropShadow")).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(with_fx), "the row's layer becomes active"); + let d = app.ui.dialogs.iter().find(|d| d.id == id).unwrap(); + assert_eq!(d.fields["layer"], json!(with_fx.0)); + let fx = effects_of(&d.fields); + assert_eq!(fx.len(), 1); + assert_eq!(fx[0]["kind"], json!("dropShadow")); + assert!(fx[0]["fx"].is_object(), "the existing drop shadow, not a new instance"); + } + #[test] fn every_kind_has_spec_and_defaults() { for &(kind, _) in KINDS { @@ -1730,6 +1767,30 @@ mod tests { assert_eq!(fx[1]["params"]["spread"], json!(12.0)); } + /// OK with no edits used to turn a Center-source Inner Glow into an Edge one: the dialog never + /// loaded the source, so it showed the default and applied it. + #[test] + fn inner_glow_center_source_survives_ok() { + let mut s = session(); + s.execute("layer.layerStyle.innerGlow", json!({"source": "center"})).unwrap(); + let source = |s: &photocraft_engine::Session| { + let d = s.active().unwrap(); + let id = d.active_layer.unwrap(); + d.doc.layer(id).unwrap().effects.items.iter().find_map(|e| match e { + Effect::InnerGlow(g) => Some(g.source), + _ => None, + }) + }; + assert_eq!(source(&s), Some(GlowSource::Center)); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + let glow = effects_of(&f).iter().find(|e| e["kind"] == json!("innerGlow")).unwrap(); + assert_eq!(glow["params"]["source"], json!("center"), "the dialog shows the effect's source"); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + assert_eq!(source(&s), Some(GlowSource::Center), "OK keeps the source"); + } + #[test] fn drop_shadow_angle_reaches_the_effect() { // #350: the Angle slider was dead — the dialog never sent `useGlobalLight`, so the engine diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 73cc9b61d0..6bffa8133f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,19 +30,23 @@ mod brand; pub(crate) mod brush_cursor; pub mod brush_panel; pub mod brush_picker; +pub mod brush_preset_dialog; pub mod brush_preview; pub mod brush_resize; pub mod brush_sections; pub mod brushes_tab; +pub(crate) mod cam_preview; mod cam_transform; mod camera_raw_detail_ui; mod camera_raw_preview_ui; mod camera_raw_scope_ui; pub mod camera_raw_ui; pub mod canvas; +pub mod canvas_autoscroll; mod canvas_popup; pub mod canvas_tool_menu; pub mod cas_transform; +pub mod channel_options_ui; pub mod channel_view; pub mod channels_panel; pub mod chrome_ui; @@ -71,6 +75,7 @@ pub mod distort_ui; pub mod doc_props_ui; pub mod dock; pub mod duplicate_layer_ui; +pub mod edit_history; pub mod egui_fonts; pub mod enable_rules; pub mod eraser_ui; @@ -131,6 +136,7 @@ pub mod new_guide_ui; pub mod new_layer_ui; pub mod notices; mod numeric_expression; +pub mod object_finder; mod opacity_keys; pub mod outline; pub mod paint_mouse; @@ -162,6 +168,7 @@ pub mod rotate_view; pub mod rulers; pub mod screen_picker; pub mod scrollbars; +mod scrub_wrap; pub mod select_mask_view; pub mod served_fonts; pub mod shape_dialog; @@ -175,6 +182,8 @@ pub mod slice_ui; pub mod smart_ui; pub mod snap_ui; pub(crate) mod solid_fill_ui; +pub mod spring_tools; +pub mod stack_dialog; pub mod state; pub mod stroke_constraint; pub mod stroke_trail; @@ -188,13 +197,16 @@ pub mod targa_options_ui; pub mod text_field_menu; pub mod theme; pub mod tiff_options_ui; +mod tile_overlay; mod timeline_ui; mod titlebar; +pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn}; pub mod tone; mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; mod tool_reset; +mod tool_shortcuts; pub mod transform_fx; pub mod transform_tex; pub mod transform_tool; @@ -373,6 +385,8 @@ pub type CapsLockFn = Box bool>; /// Keeps an undecorated window's OS frame from offsetting its content and pointer (#2246). /// Called once per frame; `None` when the window is decorated or the platform needs no fixing. pub type WindowFrameFn = Box; +/// The caption buttons' current style ([`Services::caption_style`]). +pub type CaptionStyleFn = Box CaptionStyle>; /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. @@ -449,6 +463,10 @@ pub struct Services { /// (Windows custom title bar, #2246). Called once per frame; `None` when the window is /// decorated or the platform needs no fixing. pub window_frame: Option, + /// How the custom title bar's caption buttons look and where they sit (Linux: the desktop's + /// style and button layout). Read every frame, with the context to wake when it changes; + /// `None` keeps Windows' buttons at the right end. + pub caption_style: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only unless the shell attached a store before startup. @@ -481,6 +499,9 @@ pub struct PhotocraftApp { canvases: HashMap<(DocId, u32), canvas::CanvasCache>, /// Navigator textures belong to open documents, not the lifetime of the egui context. navigator_textures: HashMap, + /// `canvas::display_doc`'s live-adjust and Duotone documents per document: (snapshot, + /// preview key, the document shown). + display_memo: HashMap, u64, std::sync::Arc)>, /// Display profile readings (#569). monitors: monitor_status::State, checker: Option, @@ -509,10 +530,14 @@ pub struct PhotocraftApp { pub(crate) move_preview: Option, /// Move tool › Show Highlight on Rollover: the last hover answer (`move_hover`). pub(crate) move_hover: Option, + /// Object Selection › Object Finder: the hover preview (`object_finder`). + pub(crate) object_finder: Option, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). pub(crate) blend_preview: Option, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). pub(crate) patch_preview: Option, + /// Content-Aware Move drag or box shown with its result (`cam_preview`). + pub(crate) cam_preview: Option, /// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`). pub(crate) magnetic: magnetic_lasso_ui::Runtime, /// The next tool `Down` is a right-button drag that erases (see `paint_mouse`). @@ -644,6 +669,8 @@ pub struct PhotocraftApp { pub(crate) raw_redevelop: Option, /// Filter › Adaptive Wide Angle dialog (wide_angle_ui). pub(crate) wide_angle: Option, + /// ⌘Z history of each open parameter dialog (edit_history). + pub(crate) dialog_edits: edit_history::DialogEdits, /// The filter a "Rasterize?" prompt answered for, run after the dialog closes, where the /// egui context is available again (rasterize_prompt). pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>, @@ -657,6 +684,10 @@ pub struct PhotocraftApp { pub(crate) clip_read_for_paste: bool, /// Pointer position over the canvas (document px), for the Info panel and status bar. pub(crate) hover_doc: Option<[f64; 2]>, + /// Layer hovered in the Layers panel, outlined on the canvas. + pub(crate) hover_layer: Option, + /// Layer hovered on the canvas, highlighted in the Layers panel. + pub(crate) canvas_hover_layer: Option, pub(crate) clone_preview: Option, /// Info panel sample cache: ((x, y, revision, Sample Size), composite RGBA). info_sample: Option<(InfoSampleKey, [f32; 4])>, @@ -723,6 +754,7 @@ impl PhotocraftApp { services, canvases: HashMap::new(), navigator_textures: HashMap::new(), + display_memo: HashMap::new(), monitors: Default::default(), checker: None, drag: None, @@ -737,8 +769,10 @@ impl PhotocraftApp { menu_cache: None, move_preview: None, move_hover: None, + object_finder: None, blend_preview: None, patch_preview: None, + cam_preview: None, magnetic: Default::default(), secondary_erase: false, held_erase: false, @@ -796,6 +830,8 @@ impl PhotocraftApp { crop: Default::default(), pcrop: Default::default(), hover_doc: None, + hover_layer: None, + canvas_hover_layer: None, clone_preview: None, info_sample: None, os_clip_sig: None, @@ -814,6 +850,7 @@ impl PhotocraftApp { pending_raw_open: None, raw_redevelop: None, wide_angle: None, + dialog_edits: Default::default(), pending_after_prompt: None, tone_hist: None, doc_hist: None, @@ -970,12 +1007,17 @@ impl PhotocraftApp { let params = self.with_mask_target(id, params); let params = vector_ui::with_active_path(self, id, params); let path_mask = vector_ui::takes_path_mask(id) && params.get("path").is_some_and(|v| !v.is_null()); + let on_active_layer = self + .session + .active() + .is_some_and(|st| st.active_layer.is_some_and(|layer| params.get("layer").and_then(Value::as_u64).is_none_or(|target| target == layer.0))); let creates_active_mask = matches!(id, "layer.layerMask.revealAll" | "layer.layerMask.hideAll" | "layer.layerMask.revealSelection" | "layer.layerMask.hideSelection") - && self - .session - .active() - .is_some_and(|st| st.active_layer.is_some_and(|layer| params.get("layer").and_then(Value::as_u64).is_none_or(|target| target == layer.0))); + && on_active_layer; + let creates_active_vector_mask = matches!( + id, + "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" + ) && on_active_layer; let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { @@ -1016,6 +1058,15 @@ impl PhotocraftApp { self.ui.mask_target = true; self.ui.vector_mask_target = false; } + if r.is_ok() && creates_active_vector_mask { + // Likewise a new vector mask (#4728): Properties shows its Density and Feather, and a + // mask made from a path becomes the active path. + self.ui.vector_mask_target = true; + self.ui.mask_target = false; + if matches!(id, "layer.vectorMask.currentPath" | "layer.vectorMask.fromPath") { + self.ui.selected_path = Some("layer".into()); + } + } if r.is_ok() && matches!(id, "edit.copy" | "edit.cut" | "edit.copyMerged") { self.clip_external = false; self.export_os_clipboard(); @@ -1170,6 +1221,7 @@ impl PhotocraftApp { st.source_read_only = photocraft_io::affinity::is_affinity(bytes); } self.sync_views(); + let warnings = file_open::with_missing_fonts(self, warnings); self.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); self.ui.status_error = false; if camera_raw_ui::wants_open_dialog(self, &warnings) { @@ -1247,21 +1299,29 @@ impl PhotocraftApp { return self.save_to(path, tga_bits); } let st = self.session.active().ok_or("no document")?; - // PDN imports default to our native format, which preserves Paint.NET's blend modes. + // PDN imports default to our native format, which preserves Paint.NET's blend modes; + // Krita imports to OpenRaster, which Krita opens with its layers. // Other files keep their format only when it can retain their layers. A plain raster // (including an unlocked transparent PNG) can keep its flat format; editable contents, // masks and layer appearance settings need a layered format even with just one layer. let flat = plain_raster(&st.doc).is_some(); let ext = st.path.as_deref().and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()); + // An OpenRaster file only when it holds the whole document (#3806): else PSD is offered. + let holds_ora = st.path.as_deref().is_some_and(|p| photocraft_engine::file_cmds::holds_at(&st.doc, p)); let writable = ext.is_some_and(|e| { - matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb" | "tif" | "tiff" | "ora") + matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb" | "tif" | "tiff") + || (e == "ora" && holds_ora) || (flat && photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)) }); let suggested = match &st.path { Some(p) if writable => p.clone(), p => { let source = p.as_deref().unwrap_or(&st.doc.name); - let ext = if photocraft_engine::file_cmds::extension(source).as_deref() == Some("pdn") { "pcraft" } else { "psd" }; + let ext = match photocraft_engine::file_cmds::extension(source).as_deref() { + Some("pdn") => "pcraft", + Some("kra" | "krz") => "ora", + _ => "psd", + }; std::path::Path::new(source).with_extension(ext).to_string_lossy().into_owned() } }; @@ -1276,7 +1336,7 @@ impl PhotocraftApp { // which records the step once answered. if tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { let copy = self.saves_copy(&path, None)?; - targa_options_ui::park(self, path.clone(), copy, true)?; + targa_options_ui::park(self, path.clone(), copy, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } // A layered TIFF asks about its layers first (Preferences › File Handling); the save @@ -1313,25 +1373,27 @@ impl PhotocraftApp { Ok(r) } - /// Replays a recorded `file.save` / `file.saveAs` step: no dialog or prompt, and the + /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or prompt, and the /// recorded TIFF or Targa answer (if any) stands in for the TIFF or Targa Options prompt. pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result { if self.save_options_open() { return Err("Answer TIFF or Targa Options before starting another save".into()); } if id == "file.save" { - let path = self - .session - .active() - .ok_or("no document")? - .path - .clone() - .filter(|p| photocraft_engine::file_cmds::saves_in_place(p)) - .ok_or("Save writes back only to the document's own PSD, PSB or .pcraft file")?; + let st = self.session.active().ok_or("no document")?; + let path = + st.path.clone().filter(|p| photocraft_engine::file_cmds::holds_at(&st.doc, p)).ok_or( + "Save writes back only to the document's own PSD, PSB, .pcraft or OpenRaster file, and to OpenRaster only when it holds everything", + )?; return self.save_in_place(path); } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; let tga_bits = targa_options_ui::bits_param(params, id)?; + if id == "file.saveACopy" { + // A copy: the document keeps its own file and unsaved state, as when recorded (#4373). + let doc = self.session.active().ok_or("no document")?.doc.clone(); + return crate::view_cmds::write_copy(self, &doc, path, tga_bits); + } self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), tga_bits) } @@ -1361,10 +1423,13 @@ impl PhotocraftApp { // A flat file that can't hold the document (its layers, or the layered file it lives in) // is written as a copy, as in Photoshop: the document keeps its file, so Save still // writes the layered original and the edits stay unsaved (#2550). + // An OpenRaster file that can't hold everything (adjustment layers, styles…) is a copy too, + // so the editable document isn't marked saved after losing them (#3806). let st = self.session.active().ok_or("no document")?; - let layered = - |p: &str| photocraft_engine::file_cmds::saves_in_place(p) || matches!(photocraft_engine::file_cmds::extension(p).as_deref(), Some("tif" | "tiff")); - Ok(tiff_layers == Some(false) || (!layered(path) && (plain_raster(&st.doc).is_none() || st.path.as_deref().is_some_and(layered)))) + let tiff = |p: &str| matches!(photocraft_engine::file_cmds::extension(p).as_deref(), Some("tif" | "tiff")); + let holds = photocraft_engine::file_cmds::holds_at(&st.doc, path) || tiff(path); + let lives_layered = st.path.as_deref().is_some_and(|p| photocraft_engine::file_cmds::saves_in_place(p) || tiff(p)); + Ok(tiff_layers == Some(false) || (!holds && (plain_raster(&st.doc).is_none() || lives_layered))) } /// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer: @@ -1416,7 +1481,10 @@ impl PhotocraftApp { // copy, as in the headless server (#2579). let (hooks, arm) = match kind { SaveKind::User => (true, true), - SaveKind::Automation => (false, photocraft_engine::file_cmds::extension(path).is_some_and(|e| photocraft_engine::file_cmds::layered_extension(&e))), + SaveKind::Automation => { + let holds = self.session.documents().iter().find(|st| st.doc.id == doc).is_some_and(|st| photocraft_engine::file_cmds::holds_at(&st.doc, path)); + (false, holds) + } }; self.ui.status = crate::i18n::fmt(tl!("Saved {name}"), &[("name", path)]); if !copy { @@ -1443,6 +1511,11 @@ impl PhotocraftApp { } Ok(()) }); + // The file the document now lives in goes to the top of Open Recent and the Home + // screen, as opening it does (#4400). Automation saves don't touch the user's list. + if hooks { + self.push_recent(path); + } } self.ui.status_error = false; let what = crate::i18n::fmt(tl!("Saved {name}"), &[("name", file_open::display_name(path).as_str())]); @@ -1478,8 +1551,9 @@ impl PhotocraftApp { let target = { let state = self.session.active().ok_or("no document")?; // As File › Save: without `path` only a layered file is written back (#416). - path.or_else(|| state.path.clone().filter(|p| photocraft_engine::file_cmds::saves_in_place(p))) - .ok_or("pass `path`: a save without one writes back only to the document's own PSD, PSB or .pcraft file")? + path.or_else(|| state.path.clone().filter(|p| photocraft_engine::file_cmds::holds_at(&state.doc, p))).ok_or( + "pass `path`: a save without one writes back only to the document's own PSD, PSB, .pcraft or OpenRaster file, and to OpenRaster only when it holds everything (adjustment layers, styles and masks don't fit)", + )? }; let reply = |path: &str, warnings: &[String]| json!({"path": path, "warnings": warnings}); // #2017: the File › Save worker path, so the encode doesn't block the frame loop. @@ -1509,8 +1583,8 @@ impl PhotocraftApp { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { let reply = req.reply.clone(); - if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - let _ = reply.send(serde_json::json!({"ok": false, "error": control::timeout_error()})); + if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) || !req.dispatch.start() { + let _ = reply.send(serde_json::json!({"ok": false, "error": control::expired_error()})); continue; } match control::handle(self, ctx, &req) { @@ -1667,12 +1741,16 @@ impl eframe::App for PhotocraftApp { menu.raw_input(raw_input); } shortcuts::clipboard_keys(ctx, ctx.text_edit_focused() || self.ui.text_edit.is_some(), raw_input); - // Windows sends a touchpad pinch as Ctrl + wheel; make it a pinch again (wheel_nav.rs) — - // unless the pinch preference is off, leaving those fractions to scroll sideways. - if cfg!(target_os = "windows") && self.session.prefs().enhanced_controls.zoom_with_trackpad_pinch { + // Windows precision touchpads and some Linux Wayland compositors send pinches + // as fractional Ctrl + wheel rather than a native Zoom event (#4566). + // Leave Ctrl+whole-notch mouse scrolling alone, and honour the pinch preference. + if cfg!(any(target_os = "windows", target_os = "linux")) && self.session.prefs().enhanced_controls.zoom_with_trackpad_pinch { wheel_nav::fold_legacy_pinch(ctx, raw_input); } raw_input.events.extend(self.take_synthetic_step()); + // A pen held still is no touch screen's press-and-hold right click (stylus.rs). + let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); + stylus::pen_hold_is_no_right_click(ctx, pen_down); if self.custom_titlebar { titlebar::release_after_os_resize(ctx, raw_input); } @@ -1680,6 +1758,7 @@ impl eframe::App for PhotocraftApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); + self.hover_layer = None; i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); // Fonts registered via set_fonts only take effect next frame; named families would panic now. if !self.fonts_ready { @@ -1805,6 +1884,13 @@ fn read_dropped(f: &(dyn egui::DroppedFile + Send + Sync)) -> Result, St f.bytes() } +/// The active layer is an adjustment layer. It has no pixels, so painting and filling it edit its +/// mask even when its icon thumbnail, not the mask, is the Layers panel's target, as in +/// Photoshop (#4298). +pub(crate) fn adjustment_layer_active(st: &photocraft_engine::DocState) -> bool { + st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Adjustment(_))) +} + /// On the web, dropped-file bytes arrive asynchronously; the web shell handles drops itself. #[cfg(target_arch = "wasm32")] fn read_dropped(_f: &dyn egui::DroppedFile) -> Result, String> { @@ -1829,9 +1915,9 @@ impl PhotocraftApp { } } - /// The active layer's mask is what pixel commands edit: the Layers panel targets it, or every - /// colour channel is off and that mask is the picture. An alpha channel or Quick Mask wins, - /// as the engine routes those itself. + /// The active layer's mask is what pixel commands edit: the Layers panel targets it, the layer + /// is an adjustment layer (no pixels of its own), or every colour channel is off and that mask + /// is the picture. An alpha channel or Quick Mask wins, as the engine routes those itself. pub fn layer_mask_targeted(&self) -> bool { let Some(st) = self.session.active() else { return false }; let layer_mask = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask; @@ -1840,7 +1926,7 @@ impl PhotocraftApp { if !composite || !has_mask { return false; } - if self.ui.mask_target || layer_mask { + if self.ui.mask_target || layer_mask || adjustment_layer_active(st) { return true; } let colors_off = st.channel_view.visible_colors(photocraft_engine::channel_cmds::color_count(&st.doc)) == 0; @@ -2128,7 +2214,20 @@ impl PhotocraftApp { // identity against address reuse without retaining the document's pixel data. let snapshot = std::sync::Arc::downgrade(&doc); let show_color = self.session.prefs().interface.show_channels_in_color; - if !matches!(&self.channel_thumbs, Some((d, old, in_color, _)) if *d == id && old.ptr_eq(&snapshot) && *in_color == show_color) { + // A step that changes only the selection (a marquee, Deselect) makes a new snapshot with + // the same pixels: keep the thumbnails, a full composite at 56 px costs ~0.7 s on a + // layered 20 MP document (#4518). + let current = match &mut self.channel_thumbs { + Some((d, old, in_color, _)) if *d == id && *in_color == show_color => { + let same = old.ptr_eq(&snapshot) || old.upgrade().is_some_and(|prev| same_but_selection(&prev, &doc)); + if same { + *old = snapshot.clone(); + } + same + } + _ => false, + }; + if !current { let comp = photocraft_compose::thumbnail(&doc, 56); let (w, h) = (comp.width as usize, comp.height as usize); let side = w.max(h); @@ -2141,7 +2240,7 @@ impl PhotocraftApp { px[(y + oy) * side + x + ox] = f(&comp.pixels[o..o + 4]); } } - ctx.load_texture(format!("chan-{name}"), egui::ColorImage::new([side, side], px), egui::TextureOptions::LINEAR) + (format!("chan-{name}"), egui::ColorImage::new([side, side], px)) }; let fmt = doc.pixel_format(); let colors = fmt.mode.color_channels(); @@ -2174,14 +2273,37 @@ impl PhotocraftApp { px[(y + oy) * side + x + ox] = egui::Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8); } } - texs.push(ctx.load_texture(format!("chan-a{i}"), egui::ColorImage::new([side, side], px), egui::TextureOptions::LINEAR)); + texs.push((format!("chan-a{i}"), egui::ColorImage::new([side, side], px))); } - self.channel_thumbs = Some((id, snapshot, show_color, texs)); + // Update the textures already uploaded rather than allocating new ones. + let mut handles = match self.channel_thumbs.take() { + Some((d, _, _, h)) if d == id => h, + _ => Vec::new(), + }; + handles.truncate(texs.len()); + for (i, (name, image)) in texs.into_iter().enumerate() { + match handles.get_mut(i) { + Some(h) => h.set(image, egui::TextureOptions::LINEAR), + None => handles.push(ctx.load_texture(name, image, egui::TextureOptions::LINEAR)), + } + } + self.channel_thumbs = Some((id, snapshot, show_color, handles)); } self.channel_thumbs.as_ref().map(|(_, _, _, t)| t.iter().map(|t| t.id()).collect()).unwrap_or_default() } } +/// Whether `new` differs from `old` at most in its selection, so every composite and channel +/// thumbnail of it is unchanged. Tiles compare by pointer first, so this costs little. +fn same_but_selection(old: &Document, new: &Document) -> bool { + if old.selection == new.selection { + return old == new; + } + let mut o = old.clone(); + o.selection = new.selection.clone(); + o == *new +} + /// Layer › Layer Mask commands that add a layer mask: Photoshop targets the new mask (#2166). const ADDS_LAYER_MASK: [&str; 5] = [ "layer.layerMask.revealAll", @@ -2303,7 +2425,7 @@ impl PhotocraftApp { let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes); surface.prune(); - self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None }); + self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None }); self.os_clip_sig = Some(sig); self.clip_external = true; true @@ -2352,9 +2474,15 @@ mod move_auto_select_tests; #[cfg(test)] mod new_group_button_tests; +#[cfg(test)] +mod color_tab_theme_tests; + #[cfg(test)] mod new_layer_button_tests; +#[cfg(test)] +mod smart_filter_rows_tests; + #[cfg(test)] mod move_outline_tests; @@ -2376,6 +2504,9 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod options_bar_mode_wheel_tests; + #[cfg(test)] mod dropdown_press_tests; @@ -2531,9 +2662,12 @@ mod clipboard_tests { let d = &app.session.active().unwrap().doc; assert_eq!((d.size.width, d.size.height, d.layers.len()), (5, 3, 1)); assert_eq!(app.ui.views.len(), 1, "the new document has a view"); + assert!(app.has_unsaved_work(), "closing after Paste must ask to save"); // New from Clipboard with a document open adds another. crate::menus::invoke(&mut app, &ctx, "file.newFromClipboard", serde_json::json!({})).unwrap(); assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + assert!(crate::discard_ui::intercept(&mut app, "file.close", &serde_json::json!({})), "Close must ask before discarding clipboard pixels"); } #[test] diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 3e3a33e8aa..c5a04556af 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -151,6 +151,8 @@ pub struct LiquifyDialog { zoom: f32, center: [f64; 2], hover: Option<[f64; 2]>, + /// Alt+right-drag (or Control+Alt-drag) resizing the brush: screen anchor, starting size, starting density. + brush_resize: Option<(Pos2, f32, f32)>, last_dab: f64, /// Last dab + proxy update time (ms), shown in the footer. pub dab_ms: f64, @@ -286,6 +288,9 @@ impl LiquifyDialog { fn rebuild(&mut self) { self.field = LiquifyField::from_strokes(self.canvas, self.cell, &self.strokes); self.dirty = Some([0, 0, self.proxy.w, self.proxy.h]); + // Undo rebuilds the whole field. The zoomed viewport is a separate buffer; leaving it + // clean kept the pre-undo pixels on screen until the next zoom (#4100). + self.view_dirty = self.view_bounds; self.mask_dirty = true; self.render_dirty(); } @@ -479,6 +484,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> zoom: 0.0, center, hover: None, + brush_resize: None, last_dab: 0.0, dab_ms: 0.0, preview_ms: 0.0, @@ -566,6 +572,21 @@ fn alt_tool(t: LiquifyTool) -> LiquifyTool { } } +/// Alt+right-drag, and Control+Alt-drag, change the Liquify brush instead of painting (#4702). +fn brush_resize_gesture(mods: egui::Modifiers, secondary: bool) -> bool { + (secondary && mods.alt) || (mods.ctrl && mods.alt && !secondary) +} + +/// Horizontal motion sets the diameter (the circle's edge follows the pointer). Vertical motion +/// sets density, Liquify's edge falloff, over the same 200 px travel the canvas uses for hardness. +fn resized_brush(start_size: f32, start_density: f32, dx: f32, dy: f32) -> (f32, f32) { + let dx = if dx.is_finite() { dx } else { 0.0 }; + let dy = if dy.is_finite() { dy } else { 0.0 }; + let size = (start_size + dx).round().clamp(1.0, 15000.0); + let density = (start_density + dy * 100.0 / crate::brush_resize::HARDNESS_TRAVEL).clamp(0.0, 100.0); + (size, (density * 10.0).round() / 10.0) +} + /// Pointer in document coordinates (from the preview or the control channel). Alt at /// pointer-down subtracts from the freeze mask (lasso), reverses a twirl and swaps Pucker/Bloat; /// Shift adds to the mask, same as no modifier. @@ -605,7 +626,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { } } -pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { +/// Ctrl+Z / Ctrl+Shift+Z inside Liquify, including while a slider's text field is focused. +pub fn history_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(d) = app.distort.liquify.as_mut() else { return }; let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { @@ -613,6 +635,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { d.undo(); } +} + +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + history_keys(app, ctx); + let Some(d) = app.distort.liquify.as_mut() else { return }; // Ctrl+H (Hide Extras) toggles the red freeze-mask overlay; the mask itself stays active. if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { d.opts.show_mask = !d.opts.show_mask; @@ -625,10 +652,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { d.global(LiquifyTool::ThawAll, None); } if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) { - d.opts.size = (d.opts.size * 0.9).max(1.0); + // At least one pixel, or 2 px * 0.9 rounds back to 2 and `[` never reaches 1. + d.opts.size = (d.opts.size * 0.9).round().min(d.opts.size - 1.0).clamp(1.0, 15000.0); } if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) { - d.opts.size = (d.opts.size * 1.1).min(15000.0); + d.opts.size = (d.opts.size * 1.1).round().max(d.opts.size + 1.0).min(15000.0); } let tools = [ (egui::Key::W, LiquifyTool::ForwardWarp), @@ -902,9 +930,26 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { d.hover = Some(to_doc(d, area, hp)); } clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - // Mouse → strokes (Space or middle button pans). - let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()); - if pan && resp.dragged() { + // Mouse → strokes (Space or middle button pans). Alt+right-drag resizes the brush (#4702). + let (pan, secondary, mods_now) = + ui.input(|i| (i.key_down(egui::Key::Space) || i.pointer.middle_down(), i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); + let resize = d.brush_resize.is_some() || (resp.drag_started() && brush_resize_gesture(mods_now, secondary)); + if resize { + if d.brush_resize.is_none() + && let Some(p) = resp.interact_pointer_pos() + { + d.brush_resize = Some((p, d.opts.size, d.opts.density)); + } + if let (Some((anchor, size0, dens0)), Some(p)) = (d.brush_resize, resp.interact_pointer_pos()) { + let delta = p - anchor; + let (size, density) = resized_brush(size0, dens0, delta.x, delta.y); + d.opts.size = size; + d.opts.density = density; + } + if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + d.brush_resize = None; + } + } else if pan && resp.dragged() { let dl = resp.drag_delta(); d.center = [d.center[0] - f64::from(dl.x / d.zoom), d.center[1] - f64::from(dl.y / d.zoom)]; } else if let Some(pp) = resp.interact_pointer_pos() { @@ -1010,6 +1055,18 @@ fn draw_mesh(d: &LiquifyDialog, painter: &egui::Painter, area: ERect) { mod tests { use super::*; + #[test] + fn alt_right_drag_resizes_the_liquify_brush_and_does_not_paint() { + assert!(brush_resize_gesture(egui::Modifiers::ALT, true)); + assert!(brush_resize_gesture(egui::Modifiers::CTRL | egui::Modifiers::ALT, false)); + assert!(!brush_resize_gesture(egui::Modifiers::ALT, false), "Alt+left stays the tool's opposite"); + assert!(!brush_resize_gesture(egui::Modifiers::NONE, true)); + let (size, density) = resized_brush(40.0, 50.0, 25.0, crate::brush_resize::HARDNESS_TRAVEL); + assert_eq!((size, density), (65.0, 100.0)); + let (size, density) = resized_brush(40.0, 50.0, -100.0, 0.0); + assert_eq!((size, density), (1.0, 50.0)); + } + fn app_with_layer() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.session.execute("file.new", json!({"width": 120, "height": 80, "depth": 8})).unwrap(); @@ -1203,6 +1260,27 @@ mod tests { assert_eq!(d.out, expected, "dirty upload source must match the rebuilt field"); } + #[test] + fn undo_dirties_the_zoomed_preview() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap(); + for ev in [ToolEvent::Down { x: 35.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 55.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 55.0, y: 40.0 }] + { + pointer(&mut app, ev, egui::Modifiers::NONE); + } + { + let d = app.distort.liquify.as_mut().unwrap(); + d.zoom = 2.0; + d.view_bounds = Some(Rect::new(0, 0, 20, 20)); + d.view_dirty = None; + } + control(&mut app, &json!({"undo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.view_dirty, d.view_bounds); + } + #[test] fn liquify_redo_restores_undone_stroke_and_a_new_stroke_clears_redo() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 241f5aa048..cf4832904a 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -792,7 +792,10 @@ fn m_cycles_only_the_rectangular_and_elliptical_marquee() { let mut seen = Vec::new(); for _ in 0..4 { let raw = egui::RawInput { - events: vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }], + events: vec![ + egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::SHIFT }, + ], ..Default::default() }; ctx.begin_pass(raw); diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index d02af4f257..1cde904682 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -213,3 +213,43 @@ fn refine_row_opens_the_select_and_mask_and_color_range_dialogs() { } } } + +/// A Studio-layout theme's floating Properties card, with no mask targeted. +fn card_harness(new_layer: bool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + if new_layer { + session.execute("layer.new.layer", json!({})).unwrap(); + } + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.panels.properties = true; + app.last_canvas_rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + properties_window(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h +} + +#[test] +fn floating_properties_card_shows_document_properties_for_the_background() { + for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::BreezeDark] { + let h = card_harness(false, theme); + assert!(h.query_all_by_label("Document").next().is_some(), "{theme:?}: Document properties in the card"); + } +} + +#[test] +fn floating_properties_card_shows_pixel_layer_properties() { + for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::BreezeDark] { + let h = card_harness(true, theme); + assert!(h.query_all_by_label("Opacity").next().is_some(), "{theme:?}: layer controls in the card"); + assert!(h.query_all_by_label("Document").next().is_none()); + } +} diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index ad0e70ca84..50ff96f083 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -169,6 +169,29 @@ fn a_new_mask_is_targeted_so_the_next_footer_trash_keeps_its_layer() { assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(shape)).is_some()); } +/// #4728: with a path selected in the Paths panel, the footer's "Add vector mask" makes the vector +/// mask from that path (Layer › Vector Mask › Current Path) and targets it, so Properties shows +/// its Density and Feather. +#[test] +fn add_vector_mask_uses_the_selected_path_and_targets_the_new_mask() { + let (s, _, _) = session(); + let mut h = harness(s, 0, 1.0, 290.0); + h.state_mut().run("layer.new.layer", json!({"name": "Spock"})).unwrap(); + h.state_mut().run("layer.layerMask.revealAll", json!({})).unwrap(); + let id = h.state().session.active().unwrap().active_layer.unwrap(); + let path = json!({"subpaths": [{"closed": true, "knots": [[30, 20], [90, 20], [90, 80], [30, 80]]}]}); + h.state_mut().run("path.set", json!({"name": "work", "path": path})).unwrap(); + h.state_mut().ui.selected_path = Some("work".into()); + let work = h.state().session.active().unwrap().doc.work_path.clone().unwrap(); + h.run_steps(3); + let at = h.get_by_label("Add vector mask").rect().center(); + click_with(&mut h, at, Modifiers::NONE); + let l = h.state().session.active().unwrap().doc.layer(id).unwrap().clone(); + assert_eq!(l.vector_mask.map(|v| v.path), Some(work), "the vector mask is the selected path, not Reveal All"); + assert!(h.state().ui.vector_mask_target && !h.state().ui.mask_target, "the new vector mask is targeted"); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("layer"), "the mask's path becomes the active path"); +} + #[test] fn dragging_a_layer_row_to_trash_still_deletes_the_layer_with_a_mask_targeted() { let (s, masked, _) = session(); @@ -629,12 +652,13 @@ fn command_i_inverts_the_targeted_mask() { let mask = &l.mask.as_ref().unwrap().surface; assert_eq!((mask.sample_channel(50, 50, 0), mask.sample_channel(150, 10, 0)), (1.0, 0.0), "the mask inverted"); assert!(l.content == pixels, "the layer's pixels are untouched"); - // An adjustment layer's mask: Invert is enabled only through the mask target. + // An adjustment layer's mask: with no pixels of its own, Invert edits the mask whether the + // panel targets the mask or the icon thumbnail (#4298). let app = h.state_mut(); app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); app.run("layer.layerMask.revealAll", json!({})).unwrap(); app.ui.mask_target = false; - assert!(!crate::menus::is_enabled(app, "image.adjustments.invert")); + assert!(crate::menus::is_enabled(app, "image.adjustments.invert")); app.ui.mask_target = true; assert!(crate::menus::is_enabled(app, "image.adjustments.invert")); app.run("image.adjustments.invert", json!({})).unwrap(); diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 2dafc1c5c1..1643e2765b 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -563,9 +563,61 @@ fn submenus_stay_above_the_taskbar() { } } +/// #3737 on X11 (Linux Mint, 1920 × 1080 px): the window manager maximized the window to the whole +/// monitor without keeping it clear of the bottom panel, so the window's position is known but the +/// panel covers its bottom. At 1× and at a 150% UI scale (1280 × 720 pt). +fn maximized_under_a_panel(scale: f32) -> Harness<'static, PhotocraftApp> { + let size = egui::vec2(1920.0, 1080.0) / scale; + let mut h = harness((1920.0, 1080.0, scale)); + let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + v.monitor_size = Some(size); + v.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), size)); + v.maximized = Some(true); + h.run_steps(3); + h +} + +#[test] +fn menus_of_a_maximized_window_stay_above_a_panel_it_runs_under() { + let mut scrolled = 0; + for scale in [1.0_f32, 1.5] { + for menu in LONGEST.iter().copied().chain(["View", "Window"]) { + let mut h = maximized_under_a_panel(scale); + let bottom = taskbar_bottom(&h); + let what = format!("{scale}x {menu}"); + assert!((bottom - (1080.0 / scale - crate::work_area::TASKBAR)).abs() < 1.0, "{what}: visible bottom {bottom}"); + open(&mut h, menu); + for r in popups(&h) { + assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})"); + } + let last = rows(&h, 0).len() - 1; + let in_view = |h: &Harness<'static, PhotocraftApp>| { + let nav = Nav::current(&h.ctx); + nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5 + }; + let Some(down) = h.query_by_label("Scroll menu down").map(|n| n.rect()) else { + assert!(in_view(&h), "{what}: fits, so its last row is in view"); + continue; + }; + scrolled += 1; + assert!(down.bottom() <= bottom, "{what}: ▼ at {down:?} is under the panel"); + h.hover_at(down.center()); + for _ in 0..400 { + h.run_steps(1); + if in_view(&h) { + break; + } + } + assert!(in_view(&h), "{what}: ▼ scrolls to the last row, above the panel"); + } + } + assert!(scrolled >= 3, "at 150% the long menus overflow above the panel: {scrolled}"); +} + #[test] fn a_maximized_window_or_an_unknown_monitor_uses_the_whole_window() { - let mut h = taskbar_harness((1366.0, 728.0), 0.0, 1.0); + // Maximized inside the work area (the window manager kept it clear of the taskbar). + let mut h = taskbar_harness((1366.0, 720.0), 0.0, 1.0); h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true); h.run_steps(2); assert_eq!(crate::work_area::visible_rect(&h.ctx), h.ctx.content_rect()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 161c64c0c2..1e86407af3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -89,6 +89,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), + ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), + ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), ("window.theme.system", "Sync with system", &["Window", "Theme"], None), ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), @@ -161,6 +163,10 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va /// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it. pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + if let Some(tool) = crate::tool_shortcuts::tool(id) { + app.ui.tool = tool; + return Ok(json!({"tool": tool})); + } if id == "ui.symmetryTransform" { return crate::symmetry_ui::begin(app).map(|_| Value::Null); } @@ -300,14 +306,28 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string())) } "file.save" => { - // Writes back only to a layered file; a flat one goes through Save As. + // Writes back only to a layered file that holds the whole document; anything else + // goes through Save As, so the original is never overwritten with less (#3806). let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?; match params.get("path").and_then(Value::as_str) { Some(path) => app.save_as_with(Some(path.to_string()), tga_bits), - None => match app.session.active().and_then(|d| d.path.clone()).filter(|p| photocraft_engine::file_cmds::saves_in_place(p)) { - Some(path) => app.save_in_place(path), - None => app.save_as_with(None, tga_bits), - }, + None => { + let own = app.session.active().and_then(|d| d.path.clone().map(|p| (photocraft_engine::file_cmds::holds_at(&d.doc, &p), p, d))); + match own { + Some((true, path, _)) => app.save_in_place(path), + Some((false, path, d)) if photocraft_engine::file_cmds::saves_in_place(&path) => { + // Its own file is layered but can't hold what the document has now. + let losses = photocraft_engine::file_cmds::losses_at(&d.doc, &path); + let name = crate::file_open::display_name(&path); + let title = crate::i18n::fmt(tl!("{name} can't keep everything in this document"), &[("name", name.as_str())]); + let mut lines = losses; + lines.push(tl!("Choose a format that keeps it, such as Photoshop (PSD), or save a copy.").to_string()); + crate::notices::post(app, title, lines, false, None); + app.save_as_with(None, tga_bits) + } + _ => app.save_as_with(None, tga_bits), + } + } } } "file.exit" => { @@ -370,7 +390,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: | "window.theme.adwaita" | "window.theme.adwaitaDark" | "window.theme.breezeLight" - | "window.theme.breezeDark" => { + | "window.theme.breezeDark" + | "window.theme.softLight" + | "window.theme.softDark" => { let k = crate::theme::ThemeKind::from_name(&id["window.theme.".len()..]).unwrap_or_default(); app.set_theme(ctx, k); Ok(Value::Null) @@ -465,6 +487,23 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } Ok(json!({"dialog": crate::stroke_ui::open(app)})) } + // Channels › Channel Options… without params: the dialog on the targeted alpha/spot + // channel, or Quick Mask Options while in Quick Mask (with params: the engine directly). + crate::channel_options_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { + let st = app.session.active().ok_or("no document is open")?; + let channel = match st.channel_view.target { + _ if st.doc.quick_mask.is_some() => json!("quickMask"), + photocraft_engine::channel_cmds::ChannelTarget::Alpha(i) => json!(i), + _ => return Err("target an alpha or spot channel first".into()), + }; + let dialog = crate::channel_options_ui::open(app, &channel).ok_or("no such channel")?; + Ok(json!({"dialog": dialog})) + } + // Channels › New Spot Channel… without params: its dialog (with params: the engine). + crate::channel_options_ui::NEW_SPOT if params.as_object().is_none_or(|o| o.is_empty()) => { + let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } // Photoshop's "Select and Mask…" is the engine's select.refineEdge. // Select › Color Range… from the menu: the dialog (with params: the engine directly). "select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})), @@ -660,6 +699,9 @@ fn open_path(app: &mut PhotocraftApp, path: &str) -> Result { } pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { + if crate::tool_shortcuts::tool(id).is_some() { + return true; + } if id == "ui.symmetryTransform" { return crate::symmetry_ui::can_transform(app); } @@ -890,6 +932,7 @@ pub struct MenuItem { pub fn is_live(id: &str) -> bool { static UI_IDS: std::sync::OnceLock> = std::sync::OnceLock::new(); photocraft_engine::commands::find(id).is_some() + || crate::tool_shortcuts::tool(id).is_some() || UI_IDS.get_or_init(|| UI_COMMANDS.iter().map(|c| c.0).collect()).contains(id) || panel_alias(id).is_some() || workspace_name(id).is_some() diff --git a/crates/ui-egui/src/move_hover.rs b/crates/ui-egui/src/move_hover.rs index 01e6a40f3a..0db1a5c80d 100644 --- a/crates/ui-egui/src/move_hover.rs +++ b/crates/ui-egui/src/move_hover.rs @@ -19,6 +19,7 @@ pub(crate) struct Hover { revision: u64, at: [i32; 2], group: bool, + target_layer: Option, bounds: Option, } @@ -36,18 +37,26 @@ pub(crate) fn target(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: boo || app.ui.tool_options.move_auto_select == command || crate::move_ui::moves_selected_pixels_with(app, Tool::Move) { + app.canvas_hover_layer = None; return None; } - let at = at.filter(|p| p[0].is_finite() && p[1].is_finite())?; + let Some(at) = at.filter(|p| p[0].is_finite() && p[1].is_finite()) else { + app.canvas_hover_layer = None; + return None; + }; let at = [at[0].floor() as i32, at[1].floor() as i32]; let group = app.ui.tool_options.move_target == "group"; let st = app.session.active()?; let (doc, revision) = (st.doc.id, st.revision); if let Some(h) = app.move_hover.as_ref().filter(|h| h.doc == doc && h.revision == revision && h.at == at && h.group == group) { + app.canvas_hover_layer = h.target_layer; return h.bounds; } - let bounds = photocraft_engine::pick_cmds::auto_select_target(&st.doc, at[0], at[1], group).and_then(|id| outline(&st.doc, id)); - app.move_hover = Some(Hover { doc, revision, at, group, bounds }); + let target_layer = photocraft_engine::pick_cmds::auto_select_target(&st.doc, at[0], at[1], group); + let bounds = target_layer.and_then(|id| outline(&st.doc, id)); + let target_layer = if bounds.is_some() { target_layer } else { None }; + app.move_hover = Some(Hover { doc, revision, at, group, target_layer, bounds }); + app.canvas_hover_layer = target_layer; bounds } @@ -79,15 +88,39 @@ fn bounds(doc: &Document, l: &Layer) -> Rect { } } +/// The document-space bounds to outline: either a layer hovered in the Layers panel, or what a +/// click at `at` would pick with the Move tool. +pub(crate) fn active_outline(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: bool, pressed: bool) -> Option { + if !app.session.prefs().tools.show_highlight_on_rollover { + app.canvas_hover_layer = None; + return None; + } + // A layer hovered in the Layers panel takes precedence over canvas rollover. + if let Some(id) = app.hover_layer + && app.drag.is_none() + && app.guide_drag.is_none() + && app.ui.transform.is_none() + && let Some(st) = app.session.active() + { + return outline(&st.doc, id); + } + target(app, at, command, pressed) +} + /// Draws the highlight on the active document's canvas, in the theme's accent colour. pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option) { if app.session.active_index() != Some(idx) { return; } + let prev_canvas_hover = app.canvas_hover_layer; let (mods, pressed) = painter.ctx().input(|i| (i.modifiers, i.pointer.any_down())); let command = crate::workspace_ui::sticky_mods(app, mods).command; let at = hover.map(|p| xf.to_doc(p)); - let Some(b) = target(app, at, command, pressed) else { return }; + let outline_rect = active_outline(app, at, command, pressed); + if app.canvas_hover_layer != prev_canvas_hover { + painter.ctx().request_repaint(); + } + let Some(b) = outline_rect else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; painter.rect_stroke(xf.doc_rect(b), 0.0, Stroke::new(1.5, accent), egui::StrokeKind::Outside); } @@ -125,4 +158,57 @@ mod tests { app.session.edit_prefs(|p| p.tools.show_highlight_on_rollover = false); assert_eq!(target(&mut app, Some([10.5, 12.0]), true, false), None); } + + #[test] + fn layer_panel_hover_outlines_layer_on_canvas() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let target_layer = app.session.active().unwrap().active_layer.unwrap(); + let a = Rect::new(8, 8, 24, 24); + + // Even with a different tool (e.g. Brush) and no canvas hover, hovering the layer in the panel outlines it: + app.ui.tool = Tool::Brush; + assert_eq!(active_outline(&mut app, None, false, false), None); + app.hover_layer = Some(target_layer); + assert_eq!(active_outline(&mut app, None, false, false), Some(a)); + + // When a drag or transform is in progress, no outline: + app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0 }); + assert_eq!(active_outline(&mut app, None, false, false), None); + app.guide_drag = None; + + // When rollover preference is disabled, no outline: + app.session.edit_prefs(|p| p.tools.show_highlight_on_rollover = false); + assert_eq!(active_outline(&mut app, None, false, false), None); + } + + #[test] + fn canvas_hover_sets_canvas_hover_layer() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let target_layer = app.session.active().unwrap().active_layer.unwrap(); + app.ui.tool = Tool::Move; + + // Hovering over painted layer sets canvas_hover_layer: + assert!(target(&mut app, Some([10.5, 12.0]), false, false).is_some()); + assert_eq!(app.canvas_hover_layer, Some(target_layer)); + + // Hovering empty area clears canvas_hover_layer: + assert!(target(&mut app, Some([40.0, 40.0]), false, false).is_none()); + assert_eq!(app.canvas_hover_layer, None); + } } diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index fe37bd0d21..0d27908835 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -38,6 +38,8 @@ pub const CATEGORIES: &[(&str, &[Preset])] = &[ ("A4", 2480, 3508, 300.0), ("A3", 3508, 4961, 300.0), ("A5", 1748, 2480, 300.0), + ("B3", 4169, 5906, 300.0), + ("B4", 2953, 4169, 300.0), ], ), ( @@ -171,6 +173,33 @@ fn is_print(p: &Preset) -> bool { p.3 >= 300.0 } +/// A built-in preset turned to the orientation picked with the Orientation buttons (#4668), so the +/// cards and what they apply follow it. Square presets and the Clipboard preset stay as they are. +fn oriented(p: &Preset, f: &Map) -> Preset { + let portrait = match f.get("__orientation").and_then(Value::as_str) { + Some("portrait") => true, + Some("landscape") => false, + _ => return *p, + }; + if p.0 == CLIPBOARD || p.1 == p.2 || (p.2 > p.1) == portrait { + return *p; + } + (p.0, p.2, p.1, p.3) +} + +/// The unit a built-in preset shows and applies: a print preset in the physical unit picked for +/// Width/Height (#4668), else in inches; a screen preset in pixels. +fn preset_unit(p: &Preset, f: &Map) -> String { + let unit = get_s(f, "__unit", "px"); + if !is_print(p) { + "px".into() + } else if unit != "px" && UNITS.iter().any(|u| u.0 == unit) { + unit + } else { + "in".into() + } +} + /// A built-in preset at Preferences › Units & Rulers › New Document Preset Resolutions, as in /// Photoshop: a print preset keeps its size in inches at the Print Resolution (Letter at 200 ppi is /// 1700 x 2200 px), a screen preset keeps its pixels at the Screen Resolution. The Clipboard preset @@ -193,12 +222,13 @@ pub fn at_pref_resolutions(p: &Preset, units: &UnitsAndRulers) -> Preset { /// dialog fields. pub fn apply_preset(f: &mut Map, p: &Preset, units: &UnitsAndRulers) { let (name, w, h, ppi) = at_pref_resolutions(p, units); + let unit = preset_unit(p, f); f.remove("__savedPreset"); f.insert("width".into(), json!(w)); f.insert("height".into(), json!(h)); f.insert("resolution".into(), json!(ppi)); f.insert("__preset".into(), json!(name)); - f.insert("__unit".into(), json!(if is_print(p) { "in" } else { "px" })); + f.insert("__unit".into(), json!(unit)); } /// Fields `file.new` takes (drops the dialog's `__` UI keys). @@ -324,18 +354,17 @@ impl Card { } } - /// 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 { + /// The size line, in the unit the preset applies (a recent one: the unit it was made in). + fn size_line(&self, units: &UnitsAndRulers, f: &Map) -> 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(), + Card::Builtin(p) => preset_unit(p, f), + Card::Recent(s) => s.unit.clone(), }; 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))); + 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))) } } @@ -450,7 +479,9 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map c.1.iter().copied().map(Card::Builtin).collect(), _ => recent_cards(f, &app.session.presets.recent_documents, &units), }; + let presets: Vec = presets + .into_iter() + .map(|c| match c { + Card::Builtin(p) => Card::Builtin(oriented(&p, f)), + recent => recent, + }) + .collect(); let chosen = get_s(f, "__preset", ""); ui.horizontal_top(|ui| { // Left: preset grid. @@ -509,7 +547,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map w) == portrait, if portrait { "Portrait" } else { "Landscape" }).clicked() && (h > w) != portrait { - swap_size(f); + if icons::button(ui, icon, 24.0, (h > w) == portrait, if portrait { "Portrait" } else { "Landscape" }).clicked() { + // The pick also turns the presets (`oriented`), even when the size already has it. + f.insert("__orientation".into(), json!(if portrait { "portrait" } else { "landscape" })); + if (h > w) != portrait { + swap_size(f); + } } } }); @@ -724,6 +766,22 @@ mod tests { /// Units & Rulers › New Document Preset Resolutions: print presets keep their inches at the /// Print Resolution, screen presets keep their pixels at the Screen Resolution. + #[test] + fn iso_b_paper_presets_use_correct_dimensions_and_selected_units() { + let presets = CATEGORIES.iter().find(|c| c.0 == "Print").unwrap().1; + let mut f = crate::state::UiState::new_document_fields(); + f.insert("__unit".into(), json!("mm")); + for (name, w, h) in [("B3", 4169, 5906), ("B4", 2953, 4169)] { + let preset = presets.iter().find(|p| p.0 == name).unwrap(); + assert_eq!((preset.1, preset.2, preset.3), (w, h, 300.0)); + assert_eq!(preset_unit(preset, &f), "mm"); + apply_preset(&mut f, preset, &Default::default()); + let params = command_params(&f); + assert_eq!((params["width"].as_u64(), params["height"].as_u64()), (Some(u64::from(w)), Some(u64::from(h)))); + assert_eq!(params["resolution"], 300.0); + } + } + #[test] fn presets_follow_the_preset_resolution_preferences() { let find = |cat: &str, name: &str| *CATEGORIES.iter().find(|c| c.0 == cat).unwrap().1.iter().find(|p| p.0 == name).unwrap(); @@ -805,7 +863,7 @@ mod tests { 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(); + let titles: Vec<(&str, String)> = cards.iter().map(|c| (c.title(), c.size_line(&UnitsAndRulers::default(), &f))).collect(); assert_eq!( titles, [ @@ -1317,5 +1375,59 @@ mod tests { enter(&mut h); assert_eq!(created(&h), (256, 512, 72.0)); } + + /// #4668: once an orientation is picked, the presets turn to it, on the cards and when applied. + #[test] + fn picked_orientation_turns_the_presets() { + let mut h = harness(); + h.get_by_label("Print").click(); + h.run_steps(2); + // The default 1920 x 1080 is already landscape: Landscape swaps nothing, but is picked. + let label = h.get_by_label("Orientation").rect(); + let gap = h.ctx.global_style().spacing.item_spacing.x; + click_at(&mut h, egui::pos2(label.right() + gap + 24.0 + gap + 12.0, label.center().y)); + assert_eq!((fields(&h)["width"].as_u64(), fields(&h)["height"].as_u64()), (Some(1920), Some(1080))); + let _ = preset_title_at(&h, "11.7 x 8.3 in @ 300 ppi"); + let at = preset_title_at(&h, "A4"); + click_at(&mut h, at); + enter(&mut h); + assert_eq!(created(&h), (3508, 2480, 300.0)); + } + + /// #4668: a physical unit picked for Width/Height is the one print presets show and keep. + #[test] + fn picked_physical_unit_is_the_print_presets_unit() { + let mut h = harness(); + let mut f = fields(&h); + f.insert("__unit".into(), serde_json::json!("mm")); + set_fields(&mut h, f); + h.get_by_label("Print").click(); + h.run_steps(2); + let _ = preset_title_at(&h, "210 x 297 mm @ 300 ppi"); + let at = preset_title_at(&h, "A4"); + click_at(&mut h, at); + assert_eq!(fields(&h)["__unit"], "mm"); + // Screen presets stay in pixels. + h.get_by_label("Web").click(); + h.run_steps(2); + let _ = preset_title_at(&h, "1024 x 768 px @ 72 ppi"); + enter(&mut h); + assert_eq!(created(&h), (2480, 3508, 300.0)); + } + + #[test] + fn hovering_preset_tabs_shows_pointing_hand_not_text_cursor() { + let mut h = harness(); + for tab in ["Recent", "Saved", "Photo", "Print", "Art & Illustration", "Web", "Mobile", "Film & Video"] { + let rect = h.get_by_label(tab).rect(); + h.hover_at(rect.center()); + h.run_steps(2); + assert_eq!( + h.output().platform_output.cursor_icon, + egui::CursorIcon::PointingHand, + "hovering preset tab {tab:?} must show PointingHand instead of text caret" + ); + } + } } } diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs new file mode 100644 index 0000000000..e219b0ddff --- /dev/null +++ b/crates/ui-egui/src/object_finder.rs @@ -0,0 +1,331 @@ +//! Object Selection › Object Finder (#4087): with the Object Selection tool active, resting the +//! pointer on the canvas highlights the object a click there would select, as Photoshop's Object +//! Finder does. A click (without dragging) commits that object (`select.object` with `"point"`; +//! ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode. +//! +//! The lookup is `photocraft_engine::smartselect_cmds::find_object`, run after the pointer has +//! been still for [`DEBOUNCE`] seconds on a worker thread (inline on the web), so the UI never +//! waits for it. Results go into the session's `object_finder` cache, which the click reuses and +//! which serves every point of an object already found; it is keyed by the document's pixels, +//! so an edit drops it. The preview itself is keyed by document revision, sampled layer and +//! Sample All Layers, and clears when the pointer leaves the canvas, the tool changes, a button +//! goes down or the option is off. Quality of the object found is out of scope here (#2788). + +use std::sync::Arc; + +use egui::{Color32, TextureOptions, pos2}; +use photocraft_algo::selection::Region; +use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_engine::smartselect_cmds::{find_object, sampled_target}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +/// Seconds the pointer must rest before an object is looked up. +pub const DEBOUNCE: f64 = 0.12; +/// Longest side of the overlay texture (the overlay is a preview, not the selection). +const TEX_MAX: usize = 768; + +/// A lookup running off the UI thread. +struct Job { + at: [i32; 2], + doc: Arc, + rx: std::sync::mpsc::Receiver>, +} + +/// The hover state of the active document (`PhotocraftApp::object_finder`). +pub(crate) struct Finder { + doc: DocId, + revision: u64, + target: Option, + all_layers: bool, + /// The pointer pixel and when it got there. + at: [i32; 2], + since: f64, + job: Option, + /// Points already looked up that found nothing (the background). + misses: Vec<[i32; 2]>, + shown: Option>, + texture: Option<(usize, egui::TextureHandle)>, +} + +/// What [`update`] decided. +#[derive(Clone, Debug, Default)] +pub(crate) struct Update { + /// The object to highlight. + pub shown: Option>, + /// A lookup is pending or running: repaint soon to pick it up. + pub busy: bool, +} + +/// Whether the preview applies now (`pressed`: a pointer button is down). +fn active(app: &PhotocraftApp, pressed: bool) -> bool { + app.ui.tool == Tool::ObjectSelection + && app.ui.tool_options.object_finder + && !pressed + && app.drag.is_none() + && app.ui.transform.is_none() + && app.session.active().is_some() +} + +/// Advances the hover preview with the pointer at `at` (document px; `None` = off the canvas) +/// at time `now` (seconds). +pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: bool, now: f64) -> Update { + let at = at.filter(|p| p[0].is_finite() && p[1].is_finite()); + let (Some(at), true) = (at, active(app, pressed)) else { + app.object_finder = None; + return Update::default(); + }; + let Some(st) = app.session.active() else { return Update::default() }; + let all_layers = app.ui.tool_options.sample_all_layers; + let target = sampled_target(&st.doc, st.active_layer, all_layers); + let (doc, revision, layer) = (st.doc.id, st.revision, st.active_layer); + let px = [at[0].floor() as i32, at[1].floor() as i32]; + let fresh = app.object_finder.as_ref().is_some_and(|f| f.doc == doc && f.revision == revision && f.target == target && f.all_layers == all_layers); + if !fresh { + app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None }); + } + let doc_arc = Arc::clone(&st.doc); + let Some(f) = app.object_finder.as_mut() else { return Update::default() }; + if f.at != px { + f.at = px; + f.since = now; + } + // A finished lookup: remember it (only for the pixels it was computed from). + if let Some(job) = &f.job { + match job.rx.try_recv() { + Ok(found) => { + if let Some(r) = found { + if photocraft_engine::layer_multi_cmds::same_pixels(&job.doc, &doc_arc) { + app.session.object_finder.insert(&doc_arc, target, Arc::new(r)); + } + } else { + f.misses.push(job.at); + if f.misses.len() > 64 { + f.misses.remove(0); + } + } + f.job = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => f.job = None, + } + } + let inside = |r: &Region| r.at(px[0], px[1]) >= 0.5; + if !f.shown.as_deref().is_some_and(inside) { + f.shown = app.session.object_finder.lookup(&doc_arc, target, at[0] as f32, at[1] as f32); + } + let missed = f.misses.iter().any(|m| (m[0] - px[0]).abs() <= 2 && (m[1] - px[1]).abs() <= 2); + if f.shown.is_none() && f.job.is_none() && !missed && now - f.since >= DEBOUNCE && doc_arc.bounds().contains(px[0], px[1]) { + f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at)); + } + let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE); + Update { shown: f.shown.clone(), busy } +} + +fn spawn(doc: Arc, layer: Option, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job { + let (tx, rx) = std::sync::mpsc::channel(); + let work = { + let doc = Arc::clone(&doc); + move || { + let _ = tx.send(find_object(&doc, layer, all_layers, at[0] as f32, at[1] as f32)); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + let spawned = std::thread::Builder::new().name("object-finder".into()).spawn(work); + if spawned.is_err() { + // No thread: the receiver reports Disconnected and the next rest retries. + } + } + #[cfg(target_arch = "wasm32")] + work(); + Job { at: px, doc, rx } +} + +/// Clears the preview (a tool switch or an edit made elsewhere is caught by [`update`] too). +pub(crate) fn clear(app: &mut PhotocraftApp) { + app.object_finder = None; +} + +/// The overlay colour: the theme accent, translucent (Photoshop tints the object it finds). +fn overlay(accent: Color32, cov: f32, edge: bool) -> Color32 { + let a = if edge { 0.95 } else { 0.42 * cov.clamp(0.0, 1.0) }; + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (a * 255.0).round() as u8) +} + +/// The overlay image of `r`: translucent fill with an opaque one-texel outline, at most +/// [`TEX_MAX`] texels on its longest side. +pub(crate) fn overlay_image(r: &Region, accent: Color32) -> egui::ColorImage { + let (w, h) = (r.bbox.width().max(1) as usize, r.bbox.height().max(1) as usize); + let step = w.max(h).div_ceil(TEX_MAX).max(1); + let (tw, th) = (w.div_ceil(step), h.div_ceil(step)); + let cov = |tx: usize, ty: usize| r.at(r.bbox.x0 + (tx * step) as i32, r.bbox.y0 + (ty * step) as i32); + let mut px = Vec::with_capacity(tw * th); + for ty in 0..th { + for tx in 0..tw { + let c = cov(tx, ty); + let on = c >= 0.5; + let edge = on + && (tx == 0 + || ty == 0 + || tx + 1 == tw + || ty + 1 == th + || cov(tx - 1, ty) < 0.5 + || cov(tx + 1, ty) < 0.5 + || cov(tx, ty - 1) < 0.5 + || cov(tx, ty + 1) < 0.5); + px.push(if c <= 0.0 { Color32::TRANSPARENT } else { overlay(accent, c, edge) }); + } + } + egui::ColorImage::new([tw, th], px) +} + +/// Updates the preview for this frame and draws the highlight on the active document's canvas. +pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option) { + if app.session.active_index() != Some(idx) { + return; + } + let ctx = painter.ctx().clone(); + let (pressed, now) = ctx.input(|i| (i.pointer.any_down(), i.time)); + let up = update(app, hover.map(|p| xf.to_doc(p)), pressed, now); + if up.busy { + ctx.request_repaint_after(std::time::Duration::from_millis(40)); + } + let Some(r) = up.shown else { return }; + let Some(f) = app.object_finder.as_mut() else { return }; + let key = Arc::as_ptr(&r) as usize; + if f.texture.as_ref().is_none_or(|(k, _)| *k != key) { + let accent = crate::theme::Tokens::get(&ctx).accent; + let tex = ctx.load_texture("object-finder", overlay_image(&r, accent), TextureOptions::LINEAR); + f.texture = Some((key, tex)); + } + let Some((_, tex)) = &f.texture else { return }; + let b = r.bbox; + let mut mesh = egui::Mesh::with_texture(tex.id()); + let corners = [(b.x0, b.y0, 0.0, 0.0), (b.x1, b.y0, 1.0, 0.0), (b.x1, b.y1, 1.0, 1.0), (b.x0, b.y1, 0.0, 1.0)]; + for (x, y, u, v) in corners { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: Color32::WHITE }); + } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// A 200 × 160 document: a red disc (centre 100, 80, radius 45) on blue. + pub(crate) fn disc_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 160})).unwrap(); + app.session + .edit("paint", |doc, a| { + let s = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + s.fill_rect(photocraft_geom::Rect::new(0, 0, 200, 160), &[0.2, 0.4, 0.8, 1.0]); + for y in 0..160 { + for x in 0..200 { + let (dx, dy) = (x as f32 + 0.5 - 100.0, y as f32 + 0.5 - 80.0); + if dx * dx + dy * dy <= 45.0 * 45.0 { + s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &[0.9, 0.2, 0.1, 1.0]); + } + } + } + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::ObjectSelection; + app + } + + /// Hovers at `at` until the lookup settles (time advances past the debounce). + pub(crate) fn hover(app: &mut PhotocraftApp, at: [f64; 2]) -> Update { + let mut t = 100.0; + loop { + let u = update(app, Some(at), false, t); + if !u.busy || t > 130.0 { + return u; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + t += 0.05; + } + } + + #[test] + fn hovering_an_object_highlights_it_after_a_rest() { + let mut app = disc_app(); + // Not before the pointer has rested. + let u = update(&mut app, Some([100.0, 80.0]), false, 50.0); + assert!(u.shown.is_none() && u.busy); + let u = hover(&mut app, [100.0, 80.0]); + let r = u.shown.expect("the disc is highlighted"); + assert!(r.at(100, 80) >= 0.5 && r.at(60, 80) >= 0.5 && r.at(5, 5) < 0.5); + // Elsewhere on the same object: served from the cache, no new lookup. + let u = update(&mut app, Some([120.0, 90.0]), false, 200.0); + assert!(u.shown.is_some_and(|s| Arc::ptr_eq(&s, &r)) && !u.busy); + assert_eq!(app.session.object_finder.len(), 1); + // The background highlights nothing. + assert!(hover(&mut app, [4.0, 4.0]).shown.is_none()); + } + + #[test] + fn preview_clears_on_leave_tool_switch_button_option_and_edits() { + let mut app = disc_app(); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + assert!(update(&mut app, None, false, 300.0).shown.is_none()); + assert!(app.object_finder.is_none()); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + assert!(update(&mut app, Some([100.0, 80.0]), true, 300.0).shown.is_none()); + app.ui.tool = Tool::Lasso; + assert!(update(&mut app, Some([100.0, 80.0]), false, 300.0).shown.is_none()); + app.ui.tool = Tool::ObjectSelection; + app.ui.tool_options.object_finder = false; + assert!(update(&mut app, Some([100.0, 80.0]), false, 300.0).shown.is_none()); + app.ui.tool_options.object_finder = true; + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + // A pixel edit: the cached object is dropped and the preview starts over. + app.session.execute("paint.pencil", json!({"points": [[5, 5], [30, 5]], "size": 4, "color": "#000000"})).unwrap(); + let u = update(&mut app, Some([100.0, 80.0]), false, 400.0); + assert!(u.shown.is_none() && u.busy); + } + + #[test] + fn click_commits_the_highlighted_object_with_the_modifiers() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = disc_app(); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + let click = |app: &mut PhotocraftApp, m: egui::Modifiers| { + tool_event(app, ToolEvent::Down { x: 100.0, y: 80.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: 100.0, y: 80.0 }, m); + }; + let cov = |app: &PhotocraftApp, x: i32, y: i32| app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)); + click(&mut app, egui::Modifiers::NONE); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("select.object")); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 60, 80) >= 0.5 && cov(&app, 5, 5) < 0.5); + app.run("select.all", json!({})).unwrap(); + click(&mut app, egui::Modifiers::ALT); + assert!(cov(&app, 100, 80) < 0.5 && cov(&app, 5, 5) >= 0.5); + click(&mut app, egui::Modifiers::SHIFT); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 5, 5) >= 0.5); + click(&mut app, egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 5, 5) < 0.5); + // Off: a click keeps the Quick Selection fallback. + app.ui.tool_options.object_finder = false; + click(&mut app, egui::Modifiers::NONE); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("select.quick")); + } + + #[test] + fn overlay_image_tints_the_object_and_outlines_it() { + let r = Region { bbox: photocraft_geom::Rect::new(0, 0, 4, 4), mask: vec![255; 16] }; + let img = overlay_image(&r, Color32::from_rgb(0, 120, 255)); + assert_eq!(img.size, [4, 4]); + assert!(img.pixels[0].a() > 200, "edge"); + assert!(img.pixels[5].a() > 0 && img.pixels[5].a() < 200, "fill"); + let big = Region { bbox: photocraft_geom::Rect::new(0, 0, 3000, 10), mask: vec![255; 30_000] }; + assert!(overlay_image(&big, Color32::WHITE).size[0] <= TEX_MAX); + } +} diff --git a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs new file mode 100644 index 0000000000..068561b4a0 --- /dev/null +++ b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs @@ -0,0 +1,83 @@ +//! #2061: the scroll wheel over the options bar's Mode dropdown (Brush, Pencil, Gradient) steps +//! through the blend modes, as over the Layers panel's (#1747) and as in Photoshop. + +use egui::accesskit::Role; +use egui::{Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::BlendMode; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// The app with a new document and `tool` chosen. +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.ui.tool = tool; + app + }); + h.run_steps(8); + h +} + +/// The options bar's Mode dropdown: the highest one showing `Normal` (the Layers panel's is lower). +fn mode_dropdown(h: &Harness<'_, PhotocraftApp>) -> Rect { + h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Normal")).map(|n| n.rect()).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap() +} + +/// `lines` wheel lines at `at` in one event: negative is down. +fn wheel(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, lines: f32) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, lines), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(3); +} + +fn brush_mode(h: &Harness<'_, PhotocraftApp>) -> BlendMode { + h.state().session.tools.brush.mode +} + +#[test] +fn the_wheel_over_the_brush_mode_dropdown_steps_the_painting_mode() { + let mut h = harness(Tool::Brush); + let at = mode_dropdown(&h).center(); + assert_eq!(brush_mode(&h), BlendMode::Normal); + wheel(&mut h, at, -1.0); + assert_eq!(brush_mode(&h), BlendMode::Dissolve); + wheel(&mut h, at, -1.0); + assert!(h.state().ui.tool_options.brush_clear, "Clear follows Dissolve, as in Photoshop"); + wheel(&mut h, at, -1.0); + assert!(!h.state().ui.tool_options.brush_clear); + assert_eq!(brush_mode(&h), BlendMode::Darken); + wheel(&mut h, at, 3.0); + assert_eq!(brush_mode(&h), BlendMode::Normal, "up steps back and stops at the first mode"); +} + +#[test] +fn the_wheel_over_the_pencil_mode_dropdown_steps_the_painting_mode() { + let mut h = harness(Tool::Pencil); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -1.0); + assert_eq!(brush_mode(&h), BlendMode::Dissolve); + wheel(&mut h, at, 1.0); + assert_eq!(brush_mode(&h), BlendMode::Normal); +} + +#[test] +fn the_wheel_over_the_gradient_mode_dropdown_steps_its_blend_mode() { + let mut h = harness(Tool::Gradient); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -2.0); + assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Darken); + wheel(&mut h, at, 1.0); + assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Dissolve); +} diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index bb2d6653d2..c49055e13a 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -433,8 +433,8 @@ mod tests { assert!(h.state().ui.brush_picker.is_some()); } - /// The gear menu's New Brush Preset… saves the brush and asks for its name in the picker's - /// rename bar; Enter there renames it and leaves the picker open (Enter closes it otherwise). + /// The gear menu's New Brush Preset… opens the New Brush Preset dialog (#4490), which saves + /// the preset under the typed name on OK. #[test] fn picker_gear_menu_saves_and_names_a_new_preset() { use egui_kittest::kittest::Queryable; @@ -444,19 +444,20 @@ mod tests { h.run_steps(2); h.get_by_label("Brush Preset Options").click(); h.run_steps(2); + let before = h.state().session.tools.presets.len(); h.get_by_label("New Brush Preset…").click(); h.run_steps(3); - let saved = h.state().ui.brush_picker_list.renaming.clone().expect("the rename bar asks for a name"); - assert!(h.state().session.tools.presets.iter().any(|p| p.name == saved.name)); - assert!(h.state().ui.brush_picker.is_some(), "the menu's click didn't close the picker"); - h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - h.event(egui::Event::Text("Inky".into())); - h.run_steps(1); - h.key_press(egui::Key::Enter); - h.run_steps(3); - assert!(h.state().session.tools.presets.iter().any(|p| p.name == "Inky"), "renamed"); - assert!(h.state().ui.brush_picker_list.renaming.is_none()); - assert!(h.state().ui.brush_picker.is_some(), "Enter ended the rename, not the picker"); + let dialog = h.state().ui.dialogs.iter().find(|d| crate::brush_preset_dialog::owns(&d.fields)).map(|d| d.id).expect("the dialog asks for a name"); + assert_eq!(h.state().session.tools.presets.len(), before, "nothing is saved before OK"); + assert!(h.state().ui.brush_picker.is_none(), "the dialog takes the picker's place"); + // This harness draws the canvas only: OK as automation does (the dialog's own tests type). + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("name".into(), json!("Inky")); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + h.run_steps(2); + assert!(h.state().session.tools.presets.iter().any(|p| p.name == "Inky"), "saved under the typed name"); + assert_eq!(h.state().session.tools.current_preset.as_deref(), Some("Inky")); + h.state_mut().ui.brush_picker = Some([c.x, c.y]); + h.run_steps(2); // The gear's boxes pick the card parts; the last one on can't be turned off. h.get_by_label("Brush Preset Options").click(); h.run_steps(2); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 4aff73c01e..de56d6714c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -404,7 +404,9 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { style.tab_height = if t.pro { 28.0 } else { 40.0 }; style.min_pane = 0.0; style.background = t.card; - style.tab_background = t.tab_strip; + // Studio themes leave the strip transparent over the dock's card; a floating group has no + // card behind it, so the canvas showed through its titles (#4509). + style.tab_background = if t.tab_strip.a() == 0 { t.card } else { t.tab_strip }; style.active_background = t.card; style.text = t.text; style.inactive_text = t.text_dim; @@ -843,6 +845,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + "brushSettings" | "brush_settings" => { + crate::brush_panel::panel_menu(app, ui); + ui.separator(); + } "actions" => { crate::actions::panel_menu(app, ui); ui.separator(); diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 657232f044..735274eb85 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -162,6 +162,37 @@ fn capture_custom_panel_docking_visual_fixtures() { } } +#[test] +fn floating_group_titles_are_opaque_in_studio_themes() { + // Studio's tab strip is transparent over the dock's card; a floating group must not show the canvas (#4509). + let mut app = app(); + app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60,"width":300,"height":300})).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).wgpu().build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::Studio); + app.ui.theme = crate::theme::ThemeKind::Studio; + ready = true; + return; + } + crate::panels::right_dock(app, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(255, 0, 255))).show(ui, |_| {}); + }, + app, + ); + h.run_steps(5); + let area = h.ctx.memory(|m| m.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"swatches".to_string())))).unwrap(); + let image = h.render().unwrap(); + let ppp = image.width() as f32 / 1280.0; + let strip = egui::Rect::from_min_size(area.min, vec2(area.width(), 40.0)).shrink(2.0); + let magenta = (strip.left() as u32..strip.right() as u32) + .flat_map(|x| (strip.top() as u32..strip.bottom() as u32).map(move |y| (x, y))) + .filter(|&(x, y)| image.get_pixel((x as f32 * ppp) as u32, (y as f32 * ppp) as u32).0[..3] == [255, 0, 255]) + .count(); + assert_eq!(magenta, 0, "canvas shows through the floating group's title strip {strip:?}"); +} + #[test] fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { let mut h = harness(app(), vec2(1280.0, 800.0)); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6193717537..2ab7fbf794 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -123,6 +123,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } + // No tool tooltips over an open flyout, or while a button is held to open one (#4450): + // Photoshop shows none there. (The flyout is our own Area, so egui's rule for popups + // doesn't hide them.) + let tool_tips = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none() && !ui.input(|i| i.pointer.primary_down()); let ctx = ui.ctx().clone(); let footer = (if t.pro { 8.0 } else { 14.0 }) + chips_height(t.pro, t.round_chips, t.round_chips && double) @@ -153,9 +157,12 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let key = egui::Id::new(("tool-slot", *slot_index)); let tool = slot_tool(ui, app.ui.tool, slot, key); let sel = slot.contains(&app.ui.tool); - let tip = - if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) }; - let resp = icons::button_with_icon_size(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip); + let tip = match crate::tool_shortcuts::shortcut(app, tool) { + Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)), + None => tl!(tool.label()).to_string(), + }; + let resp = + icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips); if slot.len() > 1 { paint_toolbar_flyout_indicator(ui, &t, &resp); } @@ -191,6 +198,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } if slot.len() > 1 && long_press { + // Holding a tool button selects the tool it shows, as a click would (#4453). + app.ui.tool = tool; ui.ctx().request_repaint(); } if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) @@ -242,11 +251,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { egui::FontId::proportional(12.5), t.text, ); - if item.key() != '\0' { + if let Some(sc) = crate::tool_shortcuts::shortcut(app, item) { ui.painter().text( pos2(r.right() - 8.0, r.center().y), Align2::RIGHT_CENTER, - item.key().to_string(), + crate::shortcuts::pretty(&sc), egui::FontId::proportional(12.0), t.text_dim, ); @@ -259,10 +268,13 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }, ); - let held_release = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) == Some(key); - let clicked_outside = - ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered(); - if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Any press outside the flyout and its button closes it, as in Photoshop: a + // drag on the canvas (cropping, painting) isn't a click (#4460). + let pressed_outside = ui.input(|i| { + i.pointer.any_pressed() + && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !resp.rect.contains(p)) + }); + if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } } @@ -284,25 +296,52 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.with_layout(layout, |ui| { let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button( + if icons::button_with_icon_size_tip( ui, "square-dashed", bx, + (bx * 0.52).round(), quick_mask, if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + tool_tips, ) .clicked() { let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); } - let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)")); + let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); paint_toolbar_flyout_indicator(ui, &t, &sm); - if sm.clicked() { + // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, + // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. + let held_id = sm.id.with("held"); + let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); + let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, true)); + } else if was_held && !sm.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(held_id)); + } + let cycle = sm.clicked() && !was_held; + if cycle { let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); } - // Right-click (or long-press) lists the modes, like Photoshop's flyout. - egui::Popup::context_menu(&sm).show(|ui| { + let open = if sm.secondary_clicked() || long_press { + Some(egui::SetOpenCommand::Bool(true)) + } else if cycle { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + // The release ending the hold counts as a click; it mustn't close the list it opened. + let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { ui.set_min_width(220.0); for (id, label) in [ ("view.screenMode.standard", tl!("Standard Screen Mode")), @@ -513,17 +552,25 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { /// The app's top bar: brand mark, menus, the document title and the workspace controls. With /// [`PhotocraftApp::custom_titlebar`] (Windows and Linux) it is also the window's title bar, as in -/// Photoshop on Windows: the caption buttons take its right end (`titlebar`). With the system +/// Photoshop on Windows: the caption buttons take its ends (`titlebar`). With the system /// title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and title, so the /// in-app bar shows menus and workspace controls only: no brand mark, no centred title. pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let custom = app.custom_titlebar; let system = system_title_bar(app); - let left = if cfg!(target_os = "macos") && app.integrated_titlebar { 78 } else { 10 }; - let right = if custom { 0 } else { 10 }; + // The caption buttons' room at each end (`titlebar`); a side with buttons has no margin. + let (caption_left, caption_right) = crate::titlebar::reserved(app, ui.ctx()); + let left = if cfg!(target_os = "macos") && app.integrated_titlebar { + 78 + } else if caption_left > 0.0 { + 0 + } else { + 10 + }; + let right = if caption_right > 0.0 { 0 } else { 10 }; let bar = egui::Panel::top("title_bar") - .exact_size(if t.pro { 32.0 } else { 38.0 }) + .exact_size(crate::titlebar::bar_height(app, ui.ctx(), if t.pro { 32.0 } else { 38.0 })) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); @@ -549,9 +596,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let rtl = crate::direction::is_rtl(); let mut free = (full.left(), full.right()); crate::direction::horizontal_centered(ui, |ui| { - if rtl && custom { - // The caption buttons keep the right end; the menus start beside them. - ui.add_space(crate::titlebar::WIDTH + 4.0); + // The menus start beside the caption buttons at their end, if it has any. + let lead = if rtl { caption_right } else { caption_left }; + if lead > 0.0 { + ui.add_space(lead + 4.0); } if !system { let side = if t.pro { 18.0 } else { 20.0 }; @@ -569,9 +617,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The menu bar takes the whole row, so the far-end group gets its own rect: from // the menus to the bar's end, or to the caption buttons. let group = if rtl { - egui::Rect::from_min_max(egui::pos2(full.left(), full.top()), egui::pos2((menus_end - TITLE_GAP).max(full.left()), full.bottom())) + let left_edge = if caption_left > 0.0 { full.left() + caption_left + 4.0 } else { full.left() }; + egui::Rect::from_min_max(egui::pos2(left_edge, full.top()), egui::pos2((menus_end - TITLE_GAP).max(left_edge), full.bottom())) } else { - let right_edge = if custom { full.right() - crate::titlebar::WIDTH - 4.0 } else { full.right() }; + let right_edge = if caption_right > 0.0 { full.right() - caption_right - 4.0 } else { full.right() }; egui::Rect::from_min_max(egui::pos2((menus_end + TITLE_GAP).min(right_edge), full.top()), egui::pos2(right_edge, full.bottom())) }; let mut group_ui = ui.new_child(egui::UiBuilder::new().max_rect(group).layout(crate::direction::trailing())); @@ -1013,6 +1062,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); if app.ui.tool == Tool::Eraser { crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); + } else { + opt_label(ui, tl!("Mode")); + let held = app.held_erase; + paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held); } // A Pencil-mode Eraser is always hard and at full flow (Photoshop). let fixed = block || crate::eraser_ui::pencil_mode(app, app.ui.tool); @@ -1187,7 +1240,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; let before = app.ui.tool_options.clone(); for (style, icon, tip) in [ - ("linear", "blend", tl!("Linear Gradient")), + ("linear", "gradient", tl!("Linear Gradient")), ("radial", "circle", tl!("Radial Gradient")), ("angle", "rotate-cw", tl!("Angle Gradient")), ("reflected", "arrow-left-right", tl!("Reflected Gradient")), @@ -1201,7 +1254,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - widgets::dropdown(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + // The wheel over it steps the mode, as over the Layers panel's (#2061). + widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); opt_label(ui, tl!("Opacity")); widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, tl!("Reverse")); @@ -1546,7 +1600,9 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (w, h, mode, bits, layers) = (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); let mut pct = app.ui.views[i].zoom * 100.0; - if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 78.0, control_h).changed() { + if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(78.0, control_h)) + .changed() + { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); app.ui.views[i].fit_pending = false; } @@ -1963,7 +2019,16 @@ fn empty(ui: &mut egui::Ui, s: &str) { ui.add_space(6.0); } +/// The Color tab of the themes other than Photoshop's: the same spectrum field (chips, saturation +/// and brightness field, hue strip and readout) with HSB sliders under it (#4367). fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + color_field(app, ui); + ui.add_space(8.0); + hsb_sliders(app, ui); +} + +/// Hue, Saturation and Brightness sliders for the foreground colour. +fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fg = app.session.tools.foreground; // Keep the last edited HSB while it still gives the foreground: black and greys have no hue or // saturation of their own, so recomputing them from RGB would reset what was just typed to 0. @@ -1990,16 +2055,6 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(key, hsva)); crate::type_tool::foreground_changed(app); } - ui.horizontal(|ui| { - let (sw, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); - ui.painter().rect_filled(sw, 6.0, hsva_color32(hsva)); - let mut c = app.session.tools.foreground; - if color_readout(ui, ui.id().with("color-panel"), &mut c) { - app.session.tools.foreground = c; - ui.data_mut(|d| d.insert_temp(key, srgb_hsva(c))); - crate::type_tool::foreground_changed(app); - } - }); } // ----------------------------------------------------------------------------- layers @@ -2013,9 +2068,8 @@ fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) { if background { return ("layer.new.layerFromBackground".into(), json!({})); } - let k = &l.locks; - let locked = k.transparency || k.pixels || k.position || k.artboard || k.all; - let locks = if locked { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; + let locks = + if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; ("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks})) } @@ -2174,7 +2228,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } if !t.pro { - let locked = l.locks.transparency || l.locks.position || l.locks.all; + let locked = l.locks.any(); if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() { actions.push(simple_lock_toggle(bg, l)); } @@ -2211,8 +2265,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(fx_drag_key())); ctx.data_mut(|d| d.remove::(mask_drag_key())); + ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); } - let held = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag")).is_some() || d.get_temp::(fx_drag_key()).is_some()); + let held = ctx.data(|d| { + d.get_temp::(egui::Id::new("layer-drag")).is_some() + || d.get_temp::(fx_drag_key()).is_some() + || d.get_temp::(crate::smart_ui::drag_key()).is_some() + }); let dragging = held && ctx.input(|i| i.pointer.primary_down()); let pointer = ctx.input(|i| i.pointer.interact_pos()); let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt)); @@ -2296,6 +2355,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // An effect row can be dropped on Delete too (#3102), and a mask's thumbnail (#3113, #3119). let fx_footer_drag = ctx.data(|d| d.get_temp::(fx_drag_key())); fx_drag_feedback(&ctx, &doc); + // And a smart filter's row (#4271). + let filter_footer_drag = ctx.data(|d| d.get_temp::(crate::smart_ui::drag_key())); + crate::smart_ui::drag_feedback(&ctx, &doc); let mask_footer_drag = ctx.data(|d| d.get_temp::(mask_drag_key())); if let Some((_, kind)) = mask_footer_drag && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) @@ -2308,6 +2370,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); ctx.data_mut(|d| d.remove::(fx_drag_key())); ctx.data_mut(|d| d.remove::(mask_drag_key())); + ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); } widgets::panel_footer(ui, |ui| { let active_layer = active.and_then(|id| doc.layer(id)); @@ -2327,6 +2390,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete")); actions.extend(fx_trash_drop(ui, &trash, fx_footer_drag)); + if let Some(drag) = filter_footer_drag + && dropped_on(ui, &trash) + { + actions.push(crate::smart_ui::trash_action(drag)); + } // Photoshop asks "Apply mask to layer before removing?" or "Delete the vector mask?" // (`mask_trash_prompt`). if let Some((layer, kind)) = mask_footer_drag @@ -2384,7 +2452,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if mask_btn.clicked() && let Some(cmd) = mask_cmd { - actions.push((cmd.into(), json!({}))); + // A path selected in the Paths panel becomes the vector mask, as in Photoshop (#4728); + // Alt still inverts it. + match crate::vector_ui::selected_document_path(app).filter(|_| cmd.starts_with("layer.vectorMask.")) { + Some(name) => actions.push(("layer.vectorMask.currentPath".into(), json!({"name": name, "hide": alt}))), + None => actions.push((cmd.into(), json!({}))), + } } let fx = fx_button(ui, 26.0, tl!("Add a layer style")); crate::press_menu::show(&fx, |ui, menu| { @@ -2450,7 +2523,7 @@ fn footer_drop(ui: &egui::Ui, button: &egui::Response, drag: Option<(u64, bool)> } /// While something is dragged over a footer `button`: highlights it, and is true on the release. -fn dropped_on(ui: &egui::Ui, button: &egui::Response) -> bool { +pub(crate) fn dropped_on(ui: &egui::Ui, button: &egui::Response) -> bool { let Some(p) = ui.ctx().input(|i| i.pointer.interact_pos()) else { return false }; if !button.rect.contains(p) { return false; @@ -2606,6 +2679,7 @@ fn layer_row( let mask_drag = mask_drag.is_some(); layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions); fx_drop(ctx, ui, l, rect, actions); + crate::smart_ui::drop_on_row(ctx, ui, l, rect, actions); if resp.drag_started() && !mask_drag { crate::layer_transfer::begin_from_panel(app, ctx, l.id); } @@ -2618,17 +2692,23 @@ fn layer_row( return rect; } let painter = ui.painter_at(rect.expand(1.0)); + let is_hovered = resp.hovered() || app.canvas_hover_layer == Some(l.id); + if resp.hovered() { + app.hover_layer = Some(l.id); + } if t.pro { if selected { painter.rect_filled(rect, 0.0, t.row_selected); - } else if resp.hovered() { + } else if is_hovered { painter.rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); + painter.rect_filled(rect, 0.0, t.accent.gamma_multiply(0.12)); } } else if selected { painter.rect_filled(rect, t.radius, t.hover); painter.rect_stroke(rect, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - } else if resp.hovered() { + } else if is_hovered { painter.rect_filled(rect, t.radius, t.hover.gamma_multiply(0.5)); + painter.rect_filled(rect, t.radius, t.accent.gamma_multiply(0.12)); } let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); if let Some((_, background)) = t.layer_label_colors(l.label) { @@ -2708,6 +2788,13 @@ fn layer_row( { start_fx_drag(ctx, l.id, None); } + // Clicking the lock icon unlocks the layer, and makes the Background a normal one (Photoshop, + // #4723). It takes the click from the row, so the row isn't selected. + if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock) + && ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked() + { + actions.push(simple_lock_toggle(doc.is_background(l.id), l)); + } let name_color = if l.visible { t.text } else { t.text_faint }; let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; // Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright. @@ -2827,10 +2914,20 @@ fn layer_row( if let Some(done) = crate::layer_row_ui::rename_field(ui, l.id.0, edit_rect) { actions.push(done); } - // The visibility icon has its own color menu; elsewhere use the full layer menu. + // The visibility icon has its own color menu, and the layer-mask thumbnail its mask menu + // (#4575); elsewhere use the full layer menu. + let on_mask_id = ui.id().with(("mask-menu", l.id.0)); + if resp.secondary_clicked() { + let on_mask = resp.interact_pointer_pos().and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Pixel); + ctx.data_mut(|d| d.insert_temp(on_mask_id, on_mask)); + } if !eye_resp.secondary_clicked() && !eye_resp.context_menu_opened() { resp.context_menu(|ui| { crate::widgets::style_spectrum_popup_menu(ui); + if ui.ctx().data(|d| d.get_temp::(on_mask_id)).unwrap_or(false) { + crate::layer_menu_ui::show_mask(app, ui, l, actions); + return; + } // Right-clicking inside a multi-selection keeps it and acts on every selected layer. let on_set = row.multi && selected; if crate::layer_menu_ui::show(app, ui, l, on_set, actions) @@ -3073,7 +3170,8 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static // ----------------------------------------------------------------------------- properties -/// Floating Properties card anchored to the canvas' top-right corner. +/// Floating Properties card anchored to the canvas' top-right corner. It shows the same content as +/// the docked panel (`properties_body`): document, layer, adjustment and mask properties. pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.ui.docking.is_some() { return; @@ -3082,14 +3180,7 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.ui.panels.properties || Tokens::get(ctx).pro { return; } - let Some(st) = app.session.active() else { return }; - let Some(id) = st.active_layer else { return }; - let doc = st.doc.clone(); - let Some(layer) = doc.layer(id) else { return }; - // Mask controls (pixel or vector) also belong here when their thumbnail is targeted. - let mask_target = crate::mask_props_ui::targeted(app, layer); - let vector_target = crate::mask_props_ui::vector_targeted(app, layer); - if !mask_target && !vector_target && !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) { + if app.session.active().is_none() { return; } let t = Tokens::get(ctx); @@ -3125,41 +3216,9 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } drag = grip.drag_delta(); ui.add_space(6.0); - let icon = match &layer.content { - LayerContent::Adjustment(_) => "sliders-horizontal", - LayerContent::Group(_) => "folder", - LayerContent::Fill(_) => "paint-bucket", - _ => "image", - }; - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::hover()); - ui.painter().rect_filled(r, t.radius_sm, t.field); - icons::paint(ui, r, icon, 17.0, t.icon); - ui.vertical(|ui| { - ui.label(RichText::new(&layer.name).font(theme::medium(13.0)).color(t.text)); - let kind = match &layer.content { - LayerContent::Adjustment(a) => crate::i18n::fmt(tl!("{name} Properties"), &[("name", tl!(a.label()))]), - // Whole phrases ("Type Layer"), as the Properties header translates them. - other => tl!(&format!("{} Layer", other.kind_name())).to_string(), - }; - ui.label(RichText::new(kind).small().color(t.text_faint)); - }); - }); - ui.add_space(8.0); widgets::hairline(ui); ui.add_space(8.0); - // Per-layer ids, so text still being typed for one layer can't commit to the next. - ui.push_id((doc.id, id, mask_target), |ui| { - if mask_target { - crate::mask_props_ui::properties(app, ui, layer); - } else if vector_target { - crate::mask_props_ui::vector_properties(app, ui, layer); - } else if let LayerContent::Adjustment(adj) = &layer.content { - adjustment_controls(app, ui, id, adj); - } else { - layer_controls(app, ui, layer); - } - }); + properties_body(app, ui); }); }); if drag != egui::Vec2::ZERO && canvas.is_positive() { @@ -3174,14 +3233,14 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &photocraft_doc::Adjustment) { crate::adjust_editors::layer_editor(app, ui, id, adj); ui.add_space(6.0); - let layer = app.session.active().and_then(|s| s.doc.layer(id).cloned()); - if let Some(l) = layer { + let layer = app.session.active().and_then(|s| s.doc.layer(id).map(|l| (l.visible, l.clipped))); + if let Some((visible, clipped)) = layer { ui.horizontal(|ui| { - let mut vis = l.visible; + let mut vis = visible; if widgets::toggle(ui, &mut vis, tl!("Adjustment visible")).changed() { let _ = app.run("layer.setProps", json!({"layer": id.0, "visible": vis})); } - let mut clip = l.clipped; + let mut clip = clipped; if widgets::toggle(ui, &mut clip, tl!("Clip to layer below")).changed() { let _ = app.run("layer.setProps", json!({"layer": id.0, "clipped": clip})); } @@ -3250,8 +3309,12 @@ fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::mask_props_ui::vector_properties(app, ui, layer); return; } - // Photoshop shows the Document properties when nothing or the Background layer is selected. + // Photoshop shows the Document properties when nothing or the Background layer is selected, + // with Align and Distribute above them when the Background is one of several (#4608). if crate::doc_props_ui::shows_document(&st.doc, st.active_layer) { + if t.pro && st.selected_layers().len() > 1 { + crate::layer_props_ui::selection_align(app, ui); + } crate::doc_props_ui::properties(app, ui); return; } @@ -3397,7 +3460,9 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let strip_w = 14.0; // Photoshop's Color panel field fills the panel: take the height the dock offers, less the // hex/RGB readout (which folds onto a second line in a narrow dock). Without a dock, 120 pt. - let h = color_field_height(ui.data(|d| d.get_temp::(color_field_fill_id())), w); + // Only Photoshop's themes stretch it over the group: the other themes put sliders under it. + let fill = if t.pro { ui.data(|d| d.get_temp::(color_field_fill_id())) } else { None }; + let h = color_field_height(fill, w); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover()); @@ -3589,13 +3654,13 @@ fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: C /// The Brush's or Pencil's Mode dropdown: the blend modes, with Photoshop's painting-only Clear /// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while -/// `~` is held (`held`). +/// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061). fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) { let mut opts: Vec<(Option, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); opts.insert(at, (None, "Clear")); let mut mode = if *clear || held { None } else { Some(b.mode) }; - if widgets::dropdown(ui, id, &mut mode, &opts, 96.0) { + if !widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0.is_empty() { *clear = mode.is_none(); if let Some(m) = mode { b.mode = m; @@ -3902,13 +3967,14 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi if t.pro { ui.painter().line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator)); } - let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); + // Nested under the layer's eye column so an effect does not read as its own layer (#4572). + let eye = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - 9.0), vec2(18.0, 18.0)); // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { icons::paint(ui, eye, "eye", 12.0, t.icon); } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. - let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); + let eye_cell = Rect::from_min_max(pos2(rect.left() + 30.0, rect.top()), pos2((rect.left() + 58.0).min(rect.right()), rect.bottom())); if ui.interact(eye_cell, ui.id().with(("fx-eye", l.id.0, i)), Sense::click()).clicked() { let mut params = json!({"layer": l.id.0, "visible": !on}); if let (Some(index), Some(o)) = (i.checked_sub(1), params.as_object_mut()) { @@ -3916,7 +3982,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi } actions.push(("layer.setEffectsVisible".into(), params)); } - let x = rect.left() + indent + if i == 0 { 0.0 } else { 16.0 }; + let x = rect.left() + indent + if i == 0 { 8.0 } else { 24.0 }; if i == 0 { icons::paint(ui, Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 14.0)), "sparkles", 11.0, t.text_dim); } @@ -3932,7 +3998,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); if resp.double_clicked() { - crate::layer_style::open(app, kind); + crate::layer_style::open_for_layer(app, l.id, kind); } // #2846: right-click opens the layer style menu (Create Layer, Copy Layer Style…), as in Photoshop. resp.context_menu(|ui| { @@ -4162,7 +4228,7 @@ mod color_tests { let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - color_picker(app, ui); + hsb_sliders(app, ui); } }, app, @@ -4604,7 +4670,7 @@ mod lock_tests { click_lock(&mut app); let l = &app.session.active().unwrap().doc.layers[0]; assert_eq!(l.name, "Layer 0"); - assert!(!(l.locks.transparency || l.locks.pixels || l.locks.position || l.locks.all)); + assert!(!l.locks.any()); erase_line(&mut app); assert!(!app.ui.status_error, "{}", app.ui.status); assert_eq!(app.session.active().unwrap().doc.layers[0].surface().unwrap().rgba(50, 40)[3], 0.0, "erased to transparency"); @@ -4612,8 +4678,7 @@ mod lock_tests { click_lock(&mut app); assert!(app.session.active().unwrap().doc.layers[0].locks.all); click_lock(&mut app); - let k = app.session.active().unwrap().doc.layers[0].locks; - assert!(!(k.transparency || k.pixels || k.position || k.artboard || k.all)); + assert!(!app.session.active().unwrap().doc.layers[0].locks.any()); // A layer with only transparency locked unlocks in one click. app.run("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); click_lock(&mut app); @@ -4670,9 +4735,9 @@ mod type_flyout_tests { } #[test] - fn long_press_type_button_selects_vertical_without_selecting_on_release() { + fn long_press_type_button_selects_type_then_vertical_from_the_flyout() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let initial = app.ui.tool; + assert_ne!(app.ui.tool, Tool::Type); let ctx = egui::Context::default(); PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); frame(&mut app, &ctx, 0.0, vec![]); @@ -4696,7 +4761,8 @@ mod type_flyout_tests { assert_eq!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).map(|(id, _)| id), Some(egui::Id::new(("tool-slot", index)))); frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]); frame(&mut app, &ctx, 1.45, vec![]); - assert_eq!(app.ui.tool, initial); + // The held button's tool is selected, as Photoshop does (#4453). + assert_eq!(app.ui.tool, Tool::Type); let key = egui::Id::new(("tool-slot", index)); let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); let row = egui::pos2(menu.left() + 65.0, menu.top() + 39.0 + 8.0); @@ -4706,6 +4772,180 @@ mod type_flyout_tests { assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_none()); } + /// #4444: click and hold on Change Screen Mode opens its list like the tool flyouts, without + /// cycling the mode on release; a plain click still cycles, and right-click still opens it. + #[test] + fn long_press_screen_mode_opens_the_modes_without_cycling() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + // The toolbar's last button: Change Screen Mode, after Quick Mask. + let at = ctx + .viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .max_by(|a, b| a.top().total_cmp(&b.top()).then(a.left().total_cmp(&b.left()))) + }) + .unwrap() + .center(); + let standard = |app: &PhotocraftApp| crate::view_cmds::checked(app, "view.screenMode.standard") == Some(true); + let menu_open = |ctx: &egui::Context| egui::Popup::is_any_open(ctx); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(standard(&app)); + // Hold, then release: the list opens and stays open, and the mode is unchanged. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 1.2, vec![]); + assert!(!menu_open(&ctx), "not before the hold time"); + frame(&mut app, &ctx, 1.4, vec![]); + assert!(menu_open(&ctx), "open while still held"); + frame(&mut app, &ctx, 1.5, vec![pointer(at, false)]); + frame(&mut app, &ctx, 1.55, vec![]); + assert!(menu_open(&ctx), "the release keeps it open"); + assert!(standard(&app), "the release doesn't cycle the mode"); + // Escape closes it; a plain click cycles the mode and opens nothing. + frame( + &mut app, + &ctx, + 2.0, + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + frame(&mut app, &ctx, 2.05, vec![]); + assert!(!menu_open(&ctx)); + frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 3.1, vec![pointer(at, false)]); + frame(&mut app, &ctx, 3.15, vec![]); + assert!(!standard(&app), "a click cycles"); + assert!(!menu_open(&ctx)); + // Right-click opens it too. + let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 4.0, vec![secondary(true)]); + frame(&mut app, &ctx, 4.05, vec![secondary(false)]); + frame(&mut app, &ctx, 4.1, vec![]); + assert!(menu_open(&ctx)); + } + + /// Every text drawn in one toolbar frame. + fn frame_texts(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut out = ctx.run_ui( + egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); + let mut texts = Vec::new(); + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts + } + + /// #4450: no tool tooltip while a flyout is open or a tool button is held to open one, for the + /// held tool or any other; a plain hover still shows its tooltip. + #[test] + fn no_tooltips_over_an_open_tool_flyout() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let slot_of = |tool: Tool| TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&tool)).unwrap(); + let (eyedropper, brush) = (buttons[slot_of(Tool::Eyedropper)].center(), buttons[slot_of(Tool::Brush)].center()); + let tip = |tool: Tool| format!("{} ({})", tl!(tool.label()), tool.key()); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // A plain hover shows the tooltip. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(brush)]); + let mut texts = Vec::new(); + for k in 1..=10 { + texts = frame_texts(&mut app, &ctx, 1.0 + 0.1 * f64::from(k), vec![]); + } + assert!(texts.contains(&tip(Tool::Brush)), "hovered long enough: {texts:?}"); + // Held on the Eyedropper: no tooltip before or after its flyout opens. + frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(eyedropper), pointer(eyedropper, true)]); + for k in 1..=10 { + let texts = frame_texts(&mut app, &ctx, 3.0 + 0.1 * f64::from(k), vec![]); + assert!(!texts.contains(&tip(Tool::Eyedropper)), "held {k}: {texts:?}"); + } + assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(), "the flyout is open"); + // Released, with the flyout still open: none for the held tool or another one. + frame(&mut app, &ctx, 4.1, vec![pointer(eyedropper, false)]); + for (start, at, tool) in [(4.5, eyedropper, Tool::Eyedropper), (6.0, brush, Tool::Brush)] { + frame(&mut app, &ctx, start, vec![egui::Event::PointerMoved(at)]); + for k in 1..=10 { + let texts = frame_texts(&mut app, &ctx, start + 0.1 * f64::from(k), vec![]); + assert!(!texts.contains(&tip(tool)), "{tool:?} under the flyout: {texts:?}"); + } + } + } + + /// #4460: a press outside an open flyout closes it, even when it becomes a drag (cropping or + /// painting on the canvas) rather than a click; a press on the flyout keeps it for its pick. + #[test] + fn a_drag_outside_closes_the_tool_flyout() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(); + let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + let primary = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let open = |app: &mut PhotocraftApp, t: f64| { + frame(app, &ctx, t, vec![egui::Event::PointerMoved(at), secondary(true)]); + frame(app, &ctx, t + 0.05, vec![secondary(false)]); + frame(app, &ctx, t + 0.1, vec![]); + assert!(flyout(&ctx), "right-click opens it"); + }; + // A press on the flyout itself keeps it open (the row picks on release). + open(&mut app, 1.0); + let menu = ctx.memory(|m| m.area_rect(egui::Id::new(("tool-slot", index)).with("flyout"))).unwrap(); + frame(&mut app, &ctx, 1.5, vec![egui::Event::PointerMoved(menu.center()), primary(menu.center(), true)]); + assert!(flyout(&ctx), "a press on the flyout keeps it"); + frame(&mut app, &ctx, 1.55, vec![primary(menu.center(), false)]); + // A drag far from the toolbar (the canvas) closes it as soon as it starts. + open(&mut app, 2.0); + let canvas = egui::pos2(900.0, 900.0); + frame(&mut app, &ctx, 2.5, vec![egui::Event::PointerMoved(canvas), primary(canvas, true)]); + frame(&mut app, &ctx, 2.55, vec![egui::Event::PointerMoved(canvas + vec2(80.0, 60.0))]); + assert!(!flyout(&ctx), "a press outside closes it, even one that becomes a drag"); + frame(&mut app, &ctx, 2.6, vec![primary(canvas + vec2(80.0, 60.0), false)]); + assert!(!flyout(&ctx)); + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); @@ -5617,7 +5857,9 @@ mod compact_visual_regressions { egui::RawInput { time: Some(f64::from(frame) * 0.1), events: vec![ - egui::Event::PointerMoved(pos2(width * 0.5, 20.0)), + // Studio: above the controls, clear of the Brush's Mode dropdown, which takes the wheel + // to step through blend modes (#4512). + egui::Event::PointerMoved(if Tokens::get(&ctx).pro { pos2(width * 0.5, 20.0) } else { pos2(width * 0.5, 2.0) }), egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 229d69995a..2b3819cedd 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -139,7 +139,9 @@ fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { assert!(near([k.in_ctrl.x, k.in_ctrl.y], [0.0, 40.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [40.0, 0.0]), "{k:?}"); assert!(k.smooth); - // A plain click on a smooth first anchor closes without touching its handles. + // A plain click on a smooth first anchor closes without touching its handles. This path is + // drawn over the anchors of the one just finished, which Auto Add/Delete would delete (#4144). + h.state_mut().ui.tool_options.pen_auto_add_delete = false; drag(&mut h, &[[20.0, 20.0], [40.0, 20.0]], None); click(&mut h, [120.0, 20.0]); click(&mut h, [120.0, 100.0]); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 202944b58a..cf0ff51969 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -97,6 +97,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> LightTheme::Classic => ThemeKind::Classic, LightTheme::Adwaita => ThemeKind::Adwaita, LightTheme::BreezeLight => ThemeKind::BreezeLight, + LightTheme::SoftLight => ThemeKind::SoftLight, } } else { match interface.dark_theme { @@ -106,6 +107,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> DarkTheme::SolarizedDark => ThemeKind::SolarizedDark, DarkTheme::AdwaitaDark => ThemeKind::AdwaitaDark, DarkTheme::BreezeDark => ThemeKind::BreezeDark, + DarkTheme::SoftDark => ThemeKind::SoftDark, } } } @@ -133,6 +135,7 @@ const LIGHT_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("classic", ThemeKind::Classic), ("adwaita", ThemeKind::Adwaita), ("breezeLight", ThemeKind::BreezeLight), + ("softLight", ThemeKind::SoftLight), ]; const DARK_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("proMedium", ThemeKind::ProMedium), @@ -141,6 +144,7 @@ const DARK_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("solarizedDark", ThemeKind::SolarizedDark), ("adwaitaDark", ThemeKind::AdwaitaDark), ("breezeDark", ThemeKind::BreezeDark), + ("softDark", ThemeKind::SoftDark), ]; fn favorite_theme_groups(favorites: &[SavedTheme]) -> (Vec, Vec) { @@ -765,6 +769,11 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec, let at = out.iter().rposition(|i| i.2.first().map(String::as_str) == Some("Layer")).map_or(out.len(), |i| i + 1); out.splice(at..at, layer); out.extend(tools); + out.extend( + crate::state::Tool::ALL + .into_iter() + .map(|tool| (crate::tool_shortcuts::id(tool), tool.label().to_string(), vec!["Tools".into()], crate::tool_shortcuts::default(tool))), + ); for (id, label, def) in prefs::TEMPORARY_TOOLS { if seen.insert(id.to_string()) { out.push((id.to_string(), label.to_string(), vec!["Tools".into(), "Temporary".into()], Some(def.to_string()))); @@ -1670,6 +1679,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -24,6 +25,23 @@ pub fn is_preset_file(name: &str) -> bool { PRESET_EXTS.contains(&ext(name).as_str()) } +/// Import from a gradient picker through the same preset command as File › Open. +pub(crate) fn import_gradients_dialog(app: &mut PhotocraftApp) { + let result = app.pick_file_bytes_filtered(&["grd"], |app, name, bytes| { + if ext(&name) != "grd" { + return Err(tl!("Choose a gradient file (.grd).").into()); + } + open(app, &name, &bytes).ok_or_else(|| tl!("Choose a gradient file (.grd).").to_string())??; + Ok(Value::Null) + }); + if let Err(error) = result + && error != crate::file_dialog::CANCELLED + { + app.ui.status = error; + app.ui.status_error = true; + } +} + /// Command params for the file: a real path keeps the journal small; browser files arrive as bytes. fn source(name: &str, bytes: &[u8]) -> Value { #[cfg(not(target_arch = "wasm32"))] @@ -39,6 +57,7 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option ("brush.presets.importAbr", "brushes"), "grd" => ("gradient.presets.importGrd", "gradients"), + "pat" => ("pattern.import", "patterns"), "aco" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), "kys" | "psp" => return Some(crate::kys_import::open_bytes(app, name, bytes).map(|_| ())), _ => return None, @@ -60,10 +79,10 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option = r.get("warnings").and_then(|w| serde_json::from_value(w.clone()).ok()).unwrap_or_default(); app.ui.status_error = false; - let (one, other) = if what == "brushes" { - ("Imported {n} brush from {file}", "Imported {n} brushes from {file}") - } else { - ("Imported {n} gradient from {file}", "Imported {n} gradients from {file}") + let (one, other) = match what { + "brushes" => ("Imported {n} brush from {file}", "Imported {n} brushes from {file}"), + "patterns" => ("Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), + _ => ("Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), }; let imported = crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, one, other), &[("file", &stem)]); app.ui.status = match warnings.first() { @@ -91,6 +110,7 @@ mod tests { assert!(is_preset_file("sunsets.grd")); assert!(is_preset_file("Brand.ACO")); assert!(is_preset_file("exchange.ase")); + assert!(is_preset_file("Debaser-ColourLibrary-Part6.PAT")); assert!(!is_preset_file("photo.psd")); assert!(!is_preset_file("abr")); } diff --git a/crates/ui-egui/src/press_menu.rs b/crates/ui-egui/src/press_menu.rs index 9f124afe91..7110e833b4 100644 --- a/crates/ui-egui/src/press_menu.rs +++ b/crates/ui-egui/src/press_menu.rs @@ -57,8 +57,20 @@ impl PressMenu { self.item_response(ui, text).1 } + /// Is the press-drag gesture that opened this menu over `item`, still held? egui doesn't + /// hover other widgets while one is dragged, so the item would show no highlight (#4601). + fn dragged_over(&self, ui: &Ui, item: &Response) -> bool { + item.enabled() && !item.hovered() && item.contains_pointer() && ui.input(|i| i.pointer.primary_down()) && self.owns_gesture(ui.ctx()) + } + fn item_response(&self, ui: &mut Ui, text: impl Into) -> (Response, bool) { + // Under the item: the highlight it would have if hovered, while the gesture drags over it. + let highlight = ui.painter().add(egui::Shape::Noop); let response = ui.button(text); + if self.dragged_over(ui, &response) { + let v = &ui.visuals().widgets.hovered; + ui.painter().set(highlight, egui::Shape::rect_filled(response.rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill)); + } let hit = self.chosen(ui, &response); if hit { ui.close(); @@ -158,6 +170,8 @@ mod tests { struct Frame { open: bool, chosen: Option<&'static str>, + /// The items drawn with a (hover) fill under them. + highlighted: Vec<&'static str>, } const LABELS: [[&str; 2]; 2] = [["Levels", "Curves"], ["Drop Shadow", "Stroke"]]; @@ -174,6 +188,7 @@ mod tests { let mut items = [Vec::new(), Vec::new()]; let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0)); let mut buttons = [Rect::NOTHING; 2]; + let mut labels = Vec::new(); let mut output = self.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events, ..Default::default() }, |ui| { ui.add_space(20.0); ui.horizontal(|ui| { @@ -188,12 +203,21 @@ mod tests { out.chosen = Some(label); } items[m].push(r.rect); + labels.push((label, r.rect)); } }); } }); }); output.textures_delta.clear(); + for (label, rect) in labels { + // An item's own frame is transparent unless hovered; the popup's fade-in can soften + // the fill's alpha a little. + let lit = output.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill.a() > 200 && r.rect.contains_rect(rect))); + if lit { + out.highlighted.push(label); + } + } self.buttons = buttons; for (m, list) in items.into_iter().enumerate() { if !list.is_empty() { @@ -226,7 +250,10 @@ mod tests { assert!(rig.press(at, true).open, "the menu opens on the press"); rig.frame(Vec::new()); let curves = rig.item_center(1); - rig.drag_to(at, curves); + rig.drag_to(at, rig.item_center(0)); + assert_eq!(rig.frame(Vec::new()).highlighted, ["Levels"], "the item under the drag is highlighted (#4601)"); + rig.drag_to(rig.item_center(0), curves); + assert_eq!(rig.frame(Vec::new()).highlighted, ["Curves"], "and only that one"); let f = rig.press(curves, false); assert_eq!(f.chosen, Some("Curves"), "releasing on Curves chose it"); assert!(!rig.frame(Vec::new()).open, "and closed the menu"); diff --git a/crates/ui-egui/src/props_layout_tests.rs b/crates/ui-egui/src/props_layout_tests.rs index 0b99569fe7..74fd29121a 100644 --- a/crates/ui-egui/src/props_layout_tests.rs +++ b/crates/ui-egui/src/props_layout_tests.rs @@ -175,8 +175,12 @@ fn type_controls_fill_the_panel_at_every_width_and_scale() { .map(|r| r.right()) .fold(f32::MIN, f32::max); // (Both rows have a unit suffix: compare with the size/leading row.) - let top = fields.iter().map(|r| r.top()).fold(f32::MAX, f32::min); - let lead = fields.iter().filter(|r| (r.top() - top).abs() < 0.5).map(|r| r.right()).fold(f32::MIN, f32::max); + // Variable-font axes can precede the size/leading row and have no unit suffix. + // Compare the intended point-valued controls, retaining the same edge tolerance. + let lead = ["Font size", "Leading (set to the font size × auto-leading when Auto)"] + .into_iter() + .map(|label| h.get_by_role_and_label(egui::accesskit::Role::SpinButton, label).rect().right()) + .fold(f32::MIN, f32::max); assert!((t_right - lead).abs() < 0.5, "{what}: Transform fields end at {t_right}, Character at {lead}"); // Quick Actions buttons span the panel too. let qa = h.get_by_label("Rasterize Type").rect(); @@ -309,3 +313,36 @@ fn properties_distribute_buttons_move_selected_layers_and_undo_in_both_axes() { assert_eq!(restored, before, "{button}: undo restores its location"); } } + +/// #4608: with the Background one of several selected layers (and the active one), Properties +/// shows the Document properties as before, with Align and Distribute above them, and its buttons +/// align the selection: the Background stays, the other layer lines up with it. +#[test] +fn background_in_a_multiple_selection_keeps_align_and_distribute() { + use photocraft_doc::LayerId; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 300, "height": 200})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id.0; + let block = s.execute("layer.new.layer", json!({"name": "Block"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 30, "width": 40, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + // Background alone: the Document properties only. + s.execute("layer.select", json!({"layer": bg})).unwrap(); + let mut h = harness(s, 1.0, 320.0); + assert!(h.query_by_label("Align Horizontal Centers").is_none(), "one layer: Document properties only"); + // The Background is still the active layer once Block joins the selection. + h.state_mut().run("layer.select", json!({"layer": block, "mode": "add"})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": bg, "mode": "add"})).unwrap(); + h.run_steps(8); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(bg))); + assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2); + let names: Vec = headers(&h).into_iter().map(|(n, _)| n).collect(); + assert!(names.iter().any(|n| n == "Align and Distribute"), "{names:?}"); + h.get_by_label("Align Horizontal Centers").click(); + h.run_steps(4); + let st = h.state().session.active().unwrap(); + let b = st.doc.layer(LayerId(block)).unwrap().surface().unwrap().content_bounds(); + assert_eq!((b.x0 + b.x1) / 2, 150, "centred on the Background, which spans the canvas: {b:?}"); + assert_eq!(b.y0, 30, "only horizontally"); +} diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index 42791715d6..b08546a51d 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -1,6 +1,7 @@ //! Edit › Puppet Warp on the canvas: click on the mesh to add pins, drag pins to move them, //! ⌥-click a pin to remove it; the options bar sets mode, density, expansion, mesh visibility, -//! the selected pin's depth and rotation. ↩ / Commit runs `edit.puppetWarp` with the pins. +//! the selected pin's depth and rotation. ⌘Z / ⌘⇧Z undo and redo pin edits inside the session +//! (#4440). ↩ / Commit runs `edit.puppetWarp` with the pins. //! //! The preview is the layer texture on the deformed triangle mesh (an egui textured mesh, drawn //! by the GPU), re-solved with a few warm-started ARAP rounds per drag event (well under a @@ -37,9 +38,47 @@ pub struct PuppetSession { texture: TextureHandle, opacity: f32, pub preview_doc: Arc, + /// Pins before each edit of this session, newest last (⌘Z), and the edits undone (⌘⇧Z). + undo: Vec>, + redo: Vec>, + /// The pins when the current drag started: one undo step per drag that moved something. + drag_from: Option>, } impl PuppetSession { + /// Records `before` as one undoable pin edit. + fn record(&mut self, before: Vec) { + if before != self.warp.pins { + self.undo.push(before); + self.redo.clear(); + } + } + + /// Puts `pins` back (undo or redo), keeping the selection when that pin still exists. + fn restore(&mut self, pins: Vec) { + self.warp.pins = pins; + self.selected = self.selected.filter(|i| *i < self.warp.pins.len()); + self.drag = None; + self.drag_from = None; + self.rebind(); + } + + /// ⌘Z: the last pin edit undone. False when there is none. + pub fn undo(&mut self) -> bool { + let Some(pins) = self.undo.pop() else { return false }; + self.redo.push(std::mem::replace(&mut self.warp.pins, pins.clone())); + self.restore(pins); + true + } + + /// ⌘⇧Z: the last undone pin edit redone. False when there is none. + pub fn redo(&mut self) -> bool { + let Some(pins) = self.redo.pop() else { return false }; + self.undo.push(std::mem::replace(&mut self.warp.pins, pins.clone())); + self.restore(pins); + true + } + pub fn describe(&self) -> Value { json!({ "layer": self.layer.0, @@ -162,6 +201,9 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Res texture, opacity, preview_doc, + undo: Vec::new(), + redo: Vec::new(), + drag_from: None, }; s.rebuild_mesh(); if s.mesh.tris.is_empty() { @@ -192,6 +234,14 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { return Ok(json!({"cancelled": true})); } let s = app.distort.puppet.as_mut().ok_or(tl!("Puppet Warp is not active"))?; + if ui.get("undo").and_then(Value::as_bool) == Some(true) { + s.undo(); + return Ok(s.describe()); + } + if ui.get("redo").and_then(Value::as_bool) == Some(true) { + s.redo(); + return Ok(s.describe()); + } let mut remesh = false; let (density, expansion) = (s.warp.density, s.warp.expansion); if let Some(m) = ui.get("mode").and_then(Value::as_str) { @@ -222,14 +272,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { match ev { ToolEvent::Down { x, y, .. } => { let p = [x, y]; + let before = s.warp.pins.clone(); if let Some(i) = s.pin_at(p, tol) { if mods.alt { s.warp.pins.remove(i); s.selected = None; s.rebind(); + s.record(before); } else { s.selected = Some(i); s.drag = Some(i); + s.drag_from = Some(before); } } else if !mods.alt && let Some(src) = s.rest_of(p) @@ -238,6 +291,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { s.selected = Some(s.warp.pins.len() - 1); s.drag = s.selected; s.rebind(); + // Placing a pin is one step; dragging it on from there is part of that step. + s.record(before); } } ToolEvent::Move { x, y, .. } => { @@ -255,6 +310,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { pin.dst = [x, y]; } s.solve(true); + if let Some(before) = s.drag_from.take() { + s.record(before); + } } } } @@ -305,6 +363,8 @@ pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(s) = app.distort.puppet.as_mut() else { return }; + // Pin depth, rotation and Remove All Pins are undoable pin edits too. + let pins_before = s.warp.pins.clone(); let mut remesh = false; let mut resolve = false; let mut rebind = false; @@ -369,6 +429,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { s.selected = None; rebind = true; } + s.record(pins_before); if remesh { // A refused value snaps the fields back to the previous settings. let _ = s.remesh_or_restore(density, expansion); @@ -393,6 +454,65 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #4440: ⌘Z / ⌘⇧Z undo and redo pin edits while Puppet Warp is open: placing a pin, + /// dragging one and removing one are a step each; a click that moves nothing isn't one. + #[test] + fn pin_edits_undo_and_redo_inside_the_session() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 30, 80, 50), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + let ev = |app: &mut PhotocraftApp, e| crate::distort_ui::pointer(app, e, egui::Modifiers::NONE); + let pins = |app: &PhotocraftApp| app.distort.puppet.as_ref().unwrap().warp.pins.iter().map(|p| p.dst).collect::>(); + ev(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }); + ev(&mut app, ToolEvent::Down { x: 75.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 40.0 }); + // Drag the second pin up; then a click on it that moves nothing. + ev(&mut app, ToolEvent::Down { x: 75.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Move { x: 75.0, y: 30.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 20.0 }); + ev(&mut app, ToolEvent::Down { x: 75.0, y: 20.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 20.0 }); + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, egui::Modifiers { alt: true, ..Default::default() }); + assert_eq!(pins(&app), [[75.0, 20.0]]); + // ⌘Z, through the keyboard: the removed pin comes back, then the drag is undone. + let press = |app: &mut PhotocraftApp, modifiers: egui::Modifiers| { + let ev = egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| assert!(crate::distort_ui::keys(app, ui.ctx()))).textures_delta.clear(); + }; + press(&mut app, egui::Modifiers::COMMAND); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 20.0]]); + press(&mut app, egui::Modifiers::COMMAND); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 40.0]], "the drag, not the click after it"); + // ⌘⇧Z redoes it; a new edit after an undo drops what was undone. + press(&mut app, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 20.0]]); + control(&mut app, &json!({"undo": true})).unwrap(); + control(&mut app, &json!({"undo": true})).unwrap(); + assert_eq!(pins(&app), [[25.0, 40.0]]); + ev(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 50.0, y: 40.0 }); + control(&mut app, &json!({"redo": true})).unwrap(); + assert_eq!(pins(&app), [[25.0, 40.0], [50.0, 40.0]], "nothing left to redo"); + // Undo past the first pin stops there, and the document's History is untouched. + let past = app.session.active().unwrap().history.past_len(); + for _ in 0..5 { + control(&mut app, &json!({"undo": true})).unwrap(); + } + assert!(pins(&app).is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), past); + } + #[test] fn pins_drag_and_commit_through_the_engine() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index a9e1241e07..48beefbaaa 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -342,13 +342,28 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value } /// Object Selection: the dragged rectangle. A click (or a sub-2 px nudge) cannot be a -/// rectangle: like Photoshop's click-to-select, it falls back to a Quick Selection dab at +/// rectangle: with Object Finder on it selects the object under the pointer, the one hovering +/// highlighted (#4087; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects, otherwise replaces). With it off it +/// falls back to a Quick Selection dab at /// the brush size instead of looking dead (⌥ subtracts, otherwise it adds). Larger drags /// run the rectangle GrabCut (⌥ subtracts, ⇧ adds, otherwise replaces). pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let (x, y) = (start[0].min(end[0]), start[1].min(end[1])); let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); let sample_all = app.ui.tool_options.sample_all_layers; + if (w < 2.0 || h < 2.0) && app.ui.tool_options.object_finder { + // Object Finder (#4087): the object under the click, as hovering highlighted it. + let mode = match (mods.shift, mods.alt) { + (true, true) => "intersect", + (true, false) => "add", + (false, true) => "subtract", + (false, false) => "replace", + }; + let r = app.run("select.object", json!({"point": [end[0], end[1]], "mode": mode, "sampleAllLayers": sample_all})); + crate::object_finder::clear(app); + report_smart_result(app, r, tl!("No object found there — drag a rectangle around it")); + return; + } if w < 2.0 || h < 2.0 { let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; @@ -600,6 +615,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } Tool::ObjectSelection => { + crate::widgets::checkbox(ui, &mut o.object_finder, tl!("Object Finder")); crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); opt(ui, tl!("Drag a rectangle around the object")); crate::widgets::vline(ui, 22.0); @@ -776,6 +792,7 @@ mod tests { // A click cannot be a rectangle: it selects at the cursor instead of looking dead. let mut app = app(); app.ui.tool = Tool::ObjectSelection; + app.ui.tool_options.object_finder = false; let m = egui::Modifiers::NONE; tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 30.0, pressure: 1.0 }, m); tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 30.0 }, m); diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 0204ba5b3b..01841b48f7 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -1,6 +1,7 @@ //! Rotate View tool: turn the canvas camera around its centre without rewriting pixels. //! -//! Drag around the view centre (clockwise increases the angle). Shift snaps to 15°. Reset View, +//! Drag around the view centre: the image follows the pointer (clockwise increases the angle, or +//! decreases it in a flipped view). Shift snaps to 15°. Reset View, //! Escape, a double-click, or the on-canvas compass put the angle back to 0. History is not //! recorded. Trackpad rotate is gated by Preferences › Enhanced Controls › Rotate View with //! Trackpad. @@ -59,6 +60,14 @@ pub fn snap_deg(a: f32, step: f32) -> f32 { wrap_deg((a / step).round() * step) } +/// Turn a pointer or trackpad angle change (screen degrees, clockwise positive) into a change of +/// `View::rotation`. [`ViewXform::to_screen`] mirrors x after rotating, so in a flipped view a +/// larger angle turns the image the other way on screen; negating keeps it under the pointer. +pub fn screen_delta(delta: f32, flip: bool) -> f32 { + let d = wrap_deg(delta); + if flip { wrap_deg(-d) } else { d } +} + /// Pointer angle in degrees around `pivot` (Y-down, so clockwise is positive). pub fn pointer_deg(p: Pos2, pivot: Pos2) -> f32 { let d = p - pivot; @@ -180,7 +189,7 @@ pub fn drag(ctx: &egui::Context, view: &mut View, xf: &ViewXform, b: &Buttons, p return b.started || b.dragged || b.stopped; }; if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { - let delta = wrap_deg(pointer_deg(p, z.pivot) - z.start_pointer); + let delta = screen_delta(pointer_deg(p, z.pivot) - z.start_pointer, xf.flip); let mut angle = z.start_rotation + delta; if !angle.is_finite() { angle = z.start_rotation; @@ -358,6 +367,49 @@ mod tests { assert!((s.x - c.x).abs() < 1e-3 && (s.y - (c.y + 1.0)).abs() < 1e-3, "flip then rotate {s:?}"); } + /// Screen angle, about the canvas centre, of the document point that was under `p0` at the press. + fn followed_angle(h: &Harness<'static, PhotocraftApp>, p0: Pos2, start_rotation: f32) -> f32 { + let st = h.state(); + let (rect, v, flip) = (st.last_canvas_rect, &st.ui.views[0], st.ui.view.flip_horizontal); + let before = ViewXform { rect, zoom: 1.0, center: v.center, flip, rotation: start_rotation, aspect: 1.0 }; + let d = before.to_doc(p0); + let after = ViewXform { rotation: v.rotation, ..before }; + let s = after.to_screen(d[0] as f32, d[1] as f32) - rect.center(); + wrap_deg(s.y.atan2(s.x).to_degrees() - pointer_deg(p0, rect.center())) + } + + #[test] + fn drag_follows_the_pointer_in_a_flipped_view() { + for flip in [false, true] { + let mut h = harness(); + h.state_mut().ui.view.flip_horizontal = flip; + h.run_steps(2); + let c = h.state().last_canvas_rect.center(); + let p0 = c + vec2(120.0, 0.0); + h.event(Event::PointerMoved(p0)); + h.step(); + press(&mut h, p0, true, false); + // 30° clockwise about the pivot: the image turns 30° clockwise on screen. + let p1 = c + vec2(120.0 * 30f32.to_radians().cos(), 120.0 * 30f32.to_radians().sin()); + h.event(Event::PointerMoved(p1)); + h.step(); + let got = followed_angle(&h, p0, 0.0); + assert!((got - 30.0).abs() < 0.75, "flip={flip}: the image turned {got}°, the pointer 30°"); + press(&mut h, p1, false, false); + } + } + + #[test] + fn screen_delta_mirrors_only_in_a_flipped_view() { + assert_eq!(screen_delta(12.5, false), 12.5); + assert_eq!(screen_delta(12.5, true), -12.5); + assert_eq!(screen_delta(-180.0, true), 180.0); + assert_eq!(screen_delta(f32::NAN, true), 0.0); + assert_eq!(screen_delta(f32::INFINITY, false), 0.0); + // A flipped view still lands on the snapped angle with Shift. + assert_eq!(snap_deg(0.0 + screen_delta(-40.0, true), SNAP_DEG), 45.0); + } + fn app_docs(n: usize) -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); for i in 0..n { diff --git a/crates/ui-egui/src/screen_picker.rs b/crates/ui-egui/src/screen_picker.rs index 865f6537d1..944af8bff6 100644 --- a/crates/ui-egui/src/screen_picker.rs +++ b/crates/ui-egui/src/screen_picker.rs @@ -259,7 +259,9 @@ fn viewport_builder(ctx: &Context, image: &ScreenImage) -> egui::ViewportBuilder .with_resizable(false) .with_position(image.position / ctx.zoom_factor()) .with_inner_size(image.size / ctx.zoom_factor()) - .with_window_level(egui::WindowLevel::AlwaysOnTop) + // Not `AlwaysOnTop` here: a monitor-sized window created topmost makes the DX12 backend + // fail its swapchain creation (0x887A0001) and the app panics. The level is raised once + // the first frame has been painted, in `show`. // Do not expose the native window's empty first frame over the desktop. // The viewport is made visible after its snapshot has been painted below. .with_visible(false) @@ -280,7 +282,10 @@ pub fn show(ctx: &Context) { let shared = r.clone(); let vid = egui::ViewportId::from_hash_of(("screen-color", generation, n)); let builder = viewport_builder(ctx, &image); - ctx.show_viewport_deferred(vid, builder, move |ui, _| { + // Immediate, so it paints inside the main window's own frame: as a deferred viewport the + // main window stopped being redrawn on Windows (OpenGL) once the picker was up, so a click + // or Esc was never turned into an answer and the overlay could not be left. + ctx.show_viewport_immediate(vid, builder, move |ui, _| { { let s = lock(&shared); if s.generation != generation || s.owner.is_none() { @@ -288,55 +293,80 @@ pub fn show(ctx: &Context) { return; } } - if ui.ctx().input(|i| i.viewport().close_requested() || i.key_pressed(egui::Key::Escape)) { - lock(&shared).answer = Some(Ok(None)); - ui.ctx().request_repaint_of(egui::ViewportId::ROOT); - return; - } - egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { - let rect = ui.max_rect(); - ui.painter().image(texture.id(), rect, Rect::from_min_max(Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE); - let response = ui.interact(rect, ui.id().with("screen-sample"), Sense::click()); - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - let Some(pos) = ui.ctx().pointer_hover_pos() else { - return; - }; - let Some((x, y)) = image.at(rect, pos) else { - return; - }; - let zoom = { - let delta = ui.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::MouseWheel { delta, .. } => Some(delta.y), - _ => None, - }) - .sum::() - }); - let mut s = lock(&shared); - if delta > 0.0 { - s.zoom = (s.zoom + 2).min(24); - } - if delta < 0.0 { - s.zoom = s.zoom.saturating_sub(2).max(4); - } - s.zoom - }; - let picked = image.sample(x, y, conversion.as_deref()); - loupe(ui, &image, rect, pos, (x, y), zoom, picked.as_ref().ok().copied()); - if response.clicked() { - lock(&shared).answer = Some(picked.map(Some)); - ui.ctx().request_repaint_of(egui::ViewportId::ROOT); - } - }); + frame(ui, &shared, &image, &texture, conversion.as_deref()); // Showing a new native viewport before this frame is painted can briefly cover // the screen with the window's default white clear color. ui.ctx().send_viewport_cmd(egui::ViewportCommand::Visible(true)); + // Once per picker window (`show` runs every frame, so a local flag would resend it): + // raise it, and take the keyboard so Esc reaches the picker: a window shown later + // isn't always given focus by the OS, and the picker covers the screen (#4291). + let raised = Id::new(("screen-picker-raised", ui.ctx().viewport_id())); + if !ui.ctx().data_mut(|d| std::mem::replace(d.get_temp_mut_or_default::(raised), true)) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::WindowLevel(egui::WindowLevel::AlwaysOnTop)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus); + } }); } } + +/// What the picker resolves to: the colour from a pick, or `Ok(None)` to cancel. +type Answer = Result, String>; + +/// One frame of a picker window over its snapshot `image`. The picker covers the whole screen +/// and stays on top, so every way out must work (#4291): Esc, closing the window, a right-click, +/// and a left click (or a press and release that moved) anywhere, which picks the pixel under +/// it, or cancels when there's none to sample. Nothing it gets leaves the user stuck in it. +fn frame(ui: &mut egui::Ui, shared: &Shared, image: &ScreenImage, texture: &egui::TextureHandle, conversion: Option<&photocraft_cms::Transform>) { + let ctx = ui.ctx().clone(); + let answer = |a: Answer| { + lock(shared).answer = Some(a); + ctx.request_repaint_of(egui::ViewportId::ROOT); + }; + if ctx.input(|i| i.viewport().close_requested() || i.key_pressed(egui::Key::Escape) || i.pointer.secondary_released()) { + answer(Ok(None)); + return; + } + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + let rect = ui.max_rect(); + ui.painter().image(texture.id(), rect, Rect::from_min_max(Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE); + let response = ui.interact(rect, ui.id().with("screen-sample"), Sense::click_and_drag()); + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + let released = response.clicked() || response.drag_stopped(); + let pixel = ui.ctx().pointer_latest_pos().and_then(|pos| Some((pos, image.at(rect, pos)?))); + let Some((pos, (x, y))) = pixel else { + // No pixel under the pointer to sample: a click still ends the picker. + if released { + answer(Ok(None)); + } + return; + }; + let zoom = { + let delta = ui.input(|i| { + i.raw + .events + .iter() + .filter_map(|e| match e { + egui::Event::MouseWheel { delta, .. } => Some(delta.y), + _ => None, + }) + .sum::() + }); + let mut s = lock(shared); + if delta > 0.0 { + s.zoom = (s.zoom + 2).min(24); + } + if delta < 0.0 { + s.zoom = s.zoom.saturating_sub(2).max(4); + } + s.zoom + }; + let picked = image.sample(x, y, conversion); + loupe(ui, image, rect, pos, (x, y), zoom, picked.as_ref().ok().copied()); + if released { + answer(picked.map(Some)); + } + }); +} fn loupe(ui: &egui::Ui, image: &ScreenImage, screen: Rect, pos: Pos2, pixel: (usize, usize), zoom: usize, rgb: Option<[f32; 3]>) { let (x, y) = pixel; let t = crate::theme::Tokens::get(ui.ctx()); @@ -393,6 +423,12 @@ mod tests { assert_eq!(builder.visible, Some(false)); } #[test] + fn screen_picker_viewport_is_not_created_always_on_top() { + // Creating the monitor-sized window topmost fails DX12 swapchain creation (Windows). + let builder = viewport_builder(&Context::default(), &image()); + assert_eq!(builder.window_level, None); + } + #[test] fn pixel_mapping_handles_display_edges_negative_origins_and_hidpi() { let i = image(); let rect = Rect::from_min_size(i.position, i.size); @@ -421,6 +457,67 @@ mod tests { i.profile = Some(photocraft_cms::Builtin::LabD50.profile().to_bytes().as_ref().clone()); assert!(i.conversion().is_err()); } + /// The picker window's frame over [`image`] (red | azure) filling a 200 × 100 window, with its + /// own runtime: what the frame answered after `act`. + fn picker_answer(act: impl FnOnce(&mut egui_kittest::Harness<'_>)) -> Option { + let shared: Shared = Arc::default(); + let inner = shared.clone(); + let texture: std::cell::OnceCell = std::cell::OnceCell::new(); + let image = image(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(200.0, 100.0)).build_ui(move |ui| { + let tex = texture.get_or_init(|| { + ui.ctx().load_texture("test-snapshot", egui::ColorImage::new([2, 1], vec![Color32::RED, Color32::BLUE]), TextureOptions::NEAREST) + }); + frame(ui, &inner, &image, tex, None); + }); + h.run(); + act(&mut h); + h.run(); + lock(&shared).answer.take() + } + /// #4291: the picker covers the screen and stays on top, so every input ends it: Esc, a + /// right-click, a click (the pixel under it), a press dragged before its release (the pixel + /// where it's released). + #[test] + fn every_way_out_of_the_picker_ends_it() { + let left = egui::pos2(40.0, 50.0); + let right = egui::pos2(160.0, 50.0); + assert_eq!(picker_answer(|h| h.hover_at(left)), None, "hovering alone keeps it open"); + assert_eq!( + picker_answer(|h| { + h.hover_at(left); + h.run(); + h.drag_at(left); + h.run(); + h.drop_at(left); + }), + Some(Ok(Some([1.0, 0.0, 0.0]))), + "a click picks the pixel under it" + ); + assert_eq!( + picker_answer(|h| { + h.drag_at(left); + h.run(); + h.hover_at(right); + h.run(); + h.drop_at(right); + }), + Some(Ok(Some([0.0, 128.0 / 255.0, 1.0]))), + "a press dragged across picks where it's released, instead of being ignored" + ); + assert_eq!( + picker_answer(|h| { + h.hover_at(left); + h.run(); + h.event(egui::Event::PointerButton { pos: left, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.run(); + h.event(egui::Event::PointerButton { pos: left, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + }), + Some(Ok(None)), + "a right-click cancels" + ); + assert_eq!(picker_answer(|h| h.key_press(egui::Key::Escape)), Some(Ok(None)), "Esc cancels"); + } fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", serde_json::json!({"width":8,"height":8})).unwrap(); diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index 2ab7cdeab6..e0867436c4 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -90,14 +90,40 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 { /// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a /// display with `ppp` physical pixels per point. True when the view moved (then repaint). pub fn clamp_view(view: &mut View, size: egui::Vec2, ppp: f32) -> bool { + clamp_view_with_aspect(view, size, ppp, 1.0) +} + +/// Clamp against the displayed image bounds, so rotated tool auto-scroll is not undone by +/// the next frame's Overscroll-off clamp. `size` is the unmodified screen-space canvas size. +pub fn clamp_view_with_aspect(view: &mut View, size: egui::Vec2, ppp: f32, aspect: f32) -> bool { let [w, h] = view.doc_size; if w == 0 || h == 0 { return false; } let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; - let c = [clamp_axis(view.center[0], w as f32, size.x, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)]; + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let c = if view.rotation.abs() < 1e-8 { + [clamp_axis(view.center[0], w as f32, size.x / aspect, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)] + } else { + if !zoom.is_finite() || zoom <= 0.0 || !size.is_finite() || size.x <= 0.0 || size.y <= 0.0 { + return false; + } + let center = [ + if view.center[0].is_finite() { view.center[0] } else { w as f32 / 2.0 }, + if view.center[1].is_finite() { view.center[1] } else { h as f32 / 2.0 }, + ]; + let xf = crate::canvas::ViewXform { rect: egui::Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect }; + let image = xf.image_bounds(view.doc_size); + if !image.is_finite() { + return false; + } + let offset = xf.rect.center() - image.min; + let delta = vec2(clamp_axis(offset.x, image.width(), size.x, 1.0), clamp_axis(offset.y, image.height(), size.y, 1.0)) - offset; + let d = xf.unmap_vec(delta) / zoom; + [center[0] + d.x, center[1] + d.y] + }; // Ignore sub-pixel float noise so the clamp never keeps requesting frames. - let moved = (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3; + let moved = !view.center[0].is_finite() || !view.center[1].is_finite() || (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3; if moved { view.center = c; } diff --git a/crates/ui-egui/src/scrub_wrap.rs b/crates/ui-egui/src/scrub_wrap.rs new file mode 100644 index 0000000000..a188caa760 --- /dev/null +++ b/crates/ui-egui/src/scrub_wrap.rs @@ -0,0 +1,96 @@ +//! Fullscreen status-bar scrubbing, with absolute cursor-warp movement removed from the value. + +use egui::{Pos2, Rect, Response, Ui, Vec2}; + +#[derive(Clone, Copy)] +struct Scrub { + precise: f64, + pending: Option<(Pos2, Pos2)>, +} + +pub(crate) fn active(ui: &Ui) -> bool { + !cfg!(target_arch = "wasm32") && ui.input(|i| i.focused && i.viewport().fullscreen == Some(true) && !i.any_touches()) +} + +fn wrap_target(pos: Pos2, viewport: Rect) -> Option { + if !viewport.is_finite() || viewport.width() < 32.0 || !pos.is_finite() || pos.y < viewport.top() || pos.y > viewport.bottom() { + return None; + } + if pos.x <= viewport.left() + 1.0 { + Some(Pos2::new(viewport.right() - 3.0, pos.y)) + } else if pos.x >= viewport.right() - 1.0 { + Some(Pos2::new(viewport.left() + 3.0, pos.y)) + } else { + None + } +} + +fn unwarped(delta: Vec2, pos: Pos2, pending: &mut Option<(Pos2, Pos2)>) -> Vec2 { + if let Some((from, to)) = *pending { + let middle = (from.x + to.x) / 2.0; + if (to.x > from.x && pos.x > middle) || (to.x < from.x && pos.x < middle) { + *pending = None; + return delta - (to - from); + } + } + delta +} + +pub(crate) fn update(ui: &Ui, resp: &Response, value: &mut f32, before: f32, range: std::ops::RangeInclusive, speed: f64, wrapping: bool) { + let key = resp.id.with("wrapped-scrub"); + if !wrapping || !resp.dragged_by(egui::PointerButton::Primary) || !ui.input(|i| i.pointer.primary_down()) { + ui.data_mut(|d| d.remove::(key)); + return; + } + let (pos, delta, viewport, slow, aim) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.delta(), i.viewport_rect(), i.modifiers.shift, i.aim_radius())); + let Some(pos) = pos else { return }; + let mut scrub = ui.data(|d| d.get_temp::(key)).unwrap_or(Scrub { precise: f64::from(before), pending: None }); + let delta = unwarped(delta, pos, &mut scrub.pending); + let speed = if slow { speed / 10.0 } else { speed }; + let change = f64::from(delta.x - delta.y) * speed; + let (lo, hi) = (f64::from(*range.start()), f64::from(*range.end())); + if change.is_finite() && change != 0.0 && lo <= hi { + scrub.precise = (scrub.precise + change).clamp(lo, hi); + let aim = f64::from(aim) * speed; + let rounded = egui::emath::smart_aim::best_in_range_f64(scrub.precise - aim, scrub.precise + aim); + *value = egui::emath::round_to_decimals(rounded, 3).clamp(lo, hi) as f32; + } + if scrub.pending.is_none() + && let Some(to) = wrap_target(pos, viewport) + { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(to)); + scrub.pending = Some((pos, to)); + ui.ctx().request_repaint(); + } + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_numeric_value_step(speed)); + ui.data_mut(|d| d.insert_temp(key, scrub)); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{pos2, vec2}; + + #[test] + fn warp_preserves_real_motion_in_the_acknowledgement_frame() { + for (from, to, motion) in [(pos2(0.5, 600.0), pos2(997.0, 600.0), vec2(-8.0, 2.0)), (pos2(999.5, 600.0), pos2(3.0, 600.0), vec2(8.0, -2.0))] { + let mut pending = Some((from, to)); + assert_eq!(unwarped(Vec2::ZERO, from, &mut pending), Vec2::ZERO, "wait for an actual warp acknowledgement"); + assert!(pending.is_some()); + assert_eq!(unwarped(to - from + motion, to + motion, &mut pending), motion); + assert!(pending.is_none()); + assert_eq!(unwarped(motion, to + motion * 2.0, &mut pending), motion, "ordinary movement after the warp"); + } + } + + #[test] + fn warp_uses_logical_viewport_edges_even_when_motion_crosses_the_edge() { + let rect = Rect::from_min_size(pos2(20.0, 30.0), vec2(800.0, 600.0)); + assert_eq!(wrap_target(pos2(20.5, 500.0), rect), Some(pos2(817.0, 500.0))); + assert_eq!(wrap_target(pos2(819.5, 500.0), rect), Some(pos2(23.0, 500.0))); + assert_eq!(wrap_target(pos2(19.0, 500.0), rect), Some(pos2(817.0, 500.0))); + assert_eq!(wrap_target(pos2(20.5, 631.0), rect), None); + assert_eq!(wrap_target(rect.center(), rect), None); + assert_eq!(wrap_target(pos2(0.5, 10.0), Rect::from_min_size(Pos2::ZERO, vec2(8.0, 20.0))), None); + } +} diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index fde2cf78c7..a0b5c61596 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -141,6 +141,20 @@ fn click_slop_scales_with_zoom() { assert!(is_click([10.0, 10.0], &[], 0.0)); } +/// #4205: the click slop is in screen points, so on a 2x (HiDPI) display at 100 % a 2 pt jitter +/// (4 device px = 4 document px) is still a click that opens Create Rectangle, not a tiny shape. +#[test] +fn a_click_with_jitter_opens_the_dialog_on_hidpi() { + let mut app = app(); + app.ppp = 2.0; + let i = app.session.active_index().unwrap(); + app.ui.views[i].zoom = 1.0; + let before = layers(&app); + press(&mut app, Tool::Rectangle, [20.0, 30.0], [24.0, 30.0]); + only_dialog(&app); + assert_eq!(layers(&app), before, "a jittery click draws nothing"); +} + #[test] fn cancel_draws_nothing_and_ok_remembers_the_size() { let mut app = app(); diff --git a/crates/ui-egui/src/shortcut_capture_tests.rs b/crates/ui-egui/src/shortcut_capture_tests.rs index d02b9e6528..d68ae57d92 100644 --- a/crates/ui-egui/src/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/shortcut_capture_tests.rs @@ -32,6 +32,94 @@ fn field(h: &Harness<'_, PhotocraftApp>, id: u64, name: &str) -> Value { h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null) } +#[test] +fn toolbar_shortcut_can_be_captured_and_restored_on_restart() { + let (mut h, id) = dialog(1.0, "tools.select.pencil", "Pencil Tool"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "B").click(); + h.run_steps(2); + h.key_press(Key::F6); + h.run_steps(2); + assert_eq!(field(&h, id, "overrides")["tools.select.pencil"], json!("F6")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + h.key_press(Key::F6); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Pencil); + + let saved = h.state().session.prefs_to_json(); + let mut session = photocraft_engine::Session::new(); + session.load_prefs_json(&saved).unwrap(); + let mut restarted = PhotocraftApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + events: vec![Event::Key { key: Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + ..Default::default() + }); + crate::shortcuts::handle(&mut restarted, &ctx); + ctx.end_pass().textures_delta.clear(); + assert_eq!(restarted.ui.tool, crate::state::Tool::Pencil); +} + +#[test] +fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() { + use crate::state::Tool; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, key, modifiers| { + ctx.begin_pass(egui::RawInput { + // A tap: press and release, so the next press isn't a held key's repeat. + events: vec![ + Event::ModifiersChanged(modifiers), + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + app.run("edit.keyboardShortcuts", json!({"set": {"tools.select.move": "F6", "tools.select.pencil": ""}, "allowUnknown": true})).unwrap(); + app.ui.tool = Tool::Zoom; + press(&mut app, Key::V, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Artboard, "the old key only selects unchanged tools"); + press(&mut app, Key::F6, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Move); + + app.ui.tool = Tool::Pencil; + press(&mut app, Key::V, Modifiers::NONE); + press(&mut app, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Brush, "a removed tool cannot return through group memory"); + press(&mut app, Key::B, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::MixerBrush, "Shift cycling skips a removed tool"); + + app.run("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + app.ui.tool = Tool::Brush; + press(&mut app, Key::B, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Pencil, "reset restores the default group"); + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); + press(&mut app, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MixerBrush, "plain-key cycling still follows the preference"); +} + +#[test] +fn toolbar_shortcuts_participate_in_conflict_resolution() { + let (mut h, id) = dialog(1.0, "edit.search", "search…"); + capture(&mut h, &crate::shortcuts::pretty("Cmd+F"), Key::ControlLeft, Modifiers::COMMAND, Key::B); + // Remove Ctrl from the captured binding: a plain B belongs to the Brush group by default. + h.state_mut().ui.dialog_mut(id).unwrap().fields["overrides"]["edit.search"] = json!("B"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + for tool in ["brush", "pencil", "mixerBrush", "colorReplacement"] { + assert_eq!(h.state().session.prefs().shortcuts.get(&format!("tools.select.{tool}")).map(String::as_str), Some("")); + } + let ctx = h.ctx.clone(); + h.state_mut().ui.tool = crate::state::Tool::Move; + key(&mut h, Key::B, true, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().ui.palette_open, "B now runs Search"); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move); + assert_eq!(crate::shortcut_dispatch::take_log(&ctx).last().map(|i| i.0.as_str()), Some("edit.search")); +} + /// Click the selected command's shortcut button, then press `modifier` and `k` like a keyboard: /// the modifier key goes down first (its own key event), then the key with the modifier held. fn capture(h: &mut Harness<'_, PhotocraftApp>, button: &str, modifier: Key, held: Modifiers, k: Key) { diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 17607c479f..1a6980077d 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -302,6 +302,7 @@ fn label(id: &str) -> String { .map(|c| c.1) .or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1)) .or_else(|| photocraft_engine::commands::find(id).map(|c| c.label)) + .or_else(|| crate::tool_shortcuts::tool(id).map(|tool| tool.label())) .unwrap_or(id) .trim_end_matches('…') .to_string() diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a156a6ce3b..8119695e88 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -108,6 +108,7 @@ pub fn default_shortcut(id: &str) -> Option { .or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).and_then(|c| c.2)) .or_else(|| photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)) .map(str::to_string) + .or_else(|| crate::tool_shortcuts::tool(id).and_then(crate::tool_shortcuts::default)) } /// With ⇧ held the OS reports the shifted character as the logical key (US layout): ⇧; is `:`. @@ -297,6 +298,8 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu } pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { + // A held tool letter released: a temporary tool gives the previous one back (#2672). + crate::spring_tools::tick(app, ctx); // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings // it back later (#2608). app.ui.remember_group_tool(); @@ -311,6 +314,8 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::menu_nav::is_open(ctx) || crate::widgets::dropdown_list_open(ctx) { return; } + // ⌘Z in a filter or adjustment dialog steps the dialog's settings, not the document (#4697). + crate::edit_history::keys(app, ctx); // Stroke Options owns plain navigation/activation keys, including while its child // dropdown is open. This runs before Popup::show registers the current pass, so use // the editor's explicit state; otherwise Escape deselects or commits a pending Pen path. @@ -335,8 +340,14 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim // keyboard input before the distortion-mode handler below runs. Give Liquify's local // shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits. - if app.distort.liquify.is_some() && !ctx.text_edit_focused() { - crate::liquify_ui::keys(app, ctx); + if app.distort.liquify.is_some() { + // Undo and redo still apply while a numeric field (Size, Pressure, …) has focus. + // Other Liquify keys must not steal characters from that field (#4027). + if ctx.text_edit_focused() { + crate::liquify_ui::history_keys(app, ctx); + } else { + crate::liquify_ui::keys(app, ctx); + } } use crate::shortcut_dispatch::{Focus, dispatch_pressed}; let focus = Focus::of(ctx); @@ -567,17 +578,27 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // Tool keys; pressing the key of the current group cycles within it. With Preferences › // Tools › Use Shift Key for Tool Switch, only ⇧+key cycles and the plain key keeps the // group's current tool. From another group, the key picks the group's last-used tool. + // A held tool letter is a spring-loaded tool (#2672), not a burst of presses: its key + // repeats are dropped rather than cycling the group. + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key, pressed: true, repeat: true, .. } if crate::spring_tools::is_tool_key(*key)))); let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { - let Some(k) = Key::from_name(&t.key().to_string()) else { continue }; + if !crate::tool_shortcuts::uses_default(app, t) { + continue; + } + let Some(k) = crate::spring_tools::key_of(t.key()) else { continue }; let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); if cycle_shift || pressed(k) { - let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key()).collect(); + let before = app.ui.tool; + let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => app.ui.group_tool(t.key()).unwrap_or(group[0]), + None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]), }; + // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. + let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time)); + crate::spring_tools::pressed(app, t.key(), before, plain, now); return; } } @@ -598,7 +619,11 @@ mod tests { let ctx = egui::Context::default(); let press = |app: &mut PhotocraftApp, key, modifiers| { let raw = egui::RawInput { - events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], ..Default::default() }; ctx.begin_pass(raw); @@ -638,6 +663,7 @@ mod tests { events: vec![ egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key: Key::T, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: Key::T, physical_key: None, pressed: false, repeat: false, modifiers }, ], ..Default::default() }; diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index f4a9f54f06..6cce7e0a5c 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -166,7 +166,11 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map, key: &str, orig_key: let orig = num(f, orig_key); let px = num(f, key); let mut v = to_unit(px, &unit, orig, res) as f32; - let range = if unit == "px" { -300000.0..=300000.0 } else { -30000.0..=30000.0 }; + // The engine's 300000 px limit in this unit (never below 30000), so a large document's size + // isn't clamped when the field is drawn: egui clamps the shown value to the range, and the + // field reports that as an edit (#4162). + let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res).max(30000.0) as f32 }; + let range = -lim..=lim; // Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434). // A typed unit (`2in` while in pixels) converts at the dialog's resolution. let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi: 72.0 }; @@ -596,6 +600,32 @@ mod tests { assert!((doc.resolution_dpi - 50.0).abs() < 1e-3, "{}", doc.resolution_dpi); } + /// #4162: a document wider than 30000 units in a physical unit (40000 px at 72 ppi is 40000 pt) + /// kept its size in the fields and on OK; the fields clamped it to 30000 on open, so OK with + /// no edit resized (Image Size) or cropped (Canvas Size) it. + #[test] + fn large_documents_in_physical_units_open_unchanged() { + use egui_kittest::Harness; + let mut wrong = Vec::new(); + for command in ["image.imageSize", "image.canvasSize"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 40000, "height": 4, "resolution": 72})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), command).unwrap(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("__unit".into(), json!("pt")); + h.run_steps(3); + let f = fields(&h); + let shown = (num(&f, "width"), num(&f, "height")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let size = h.state().session.active().unwrap().doc.size; + if shown != (40000.0, 4.0) || size != photocraft_doc::Size::new(40000, 4) { + wrong.push(format!("{command}: fields {shown:?}, document {size:?}")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); + } + /// #2329: Image Size and Canvas Size open on Width with its value selected, as in Photoshop: /// typing replaces it and Enter applies it. #[test] diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs new file mode 100644 index 0000000000..519402672c --- /dev/null +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -0,0 +1,164 @@ +//! Layers panel Smart Filters rows (#4271): a click selects a filter (and its smart object), a +//! drag onto another smart object moves it there, ⌥-drag copies it, and a drag onto Delete +//! removes it, as in Photoshop. + +use egui::accesskit::{Role, Toggled}; +use egui::{Modifiers, Pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::{LayerContent, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; + +/// Smart objects "Smart A" (Gaussian Blur and Offset) and "Smart B" (no filters), and a pixel +/// layer "Pixels" on top: (harness, A, B, pixels). +fn panel() -> (egui_kittest::Harness<'static, PhotocraftApp>, u64, u64, u64) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let smart = |s: &mut photocraft_engine::Session, name: &str| { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap(); + id + }; + let a = smart(&mut s, "Smart A"); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap(); + s.execute("filter.other.offset", json!({"horizontal": 2})).unwrap(); + let b = smart(&mut s, "Smart B"); + let c = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": c, "name": "Pixels"})).unwrap(); + // 60 fps steps, so a double-click's two clicks fall inside egui's 0.3 s window. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_max_steps(64).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(4); + (h, a, b, c) +} + +fn filters(h: &egui_kittest::Harness<'static, PhotocraftApp>, id: u64) -> Vec { + match &h.state().session.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.smart_filters.iter().map(|f| f.command.clone()).collect(), + _ => Vec::new(), + } +} + +/// The centre of the Layers panel row (or footer button) labelled `label`: rows are selectable +/// labels, which AccessKit calls buttons, unlike the same name drawn elsewhere. +fn at(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> Pos2 { + h.query_all_by_label(label).find(|n| n.accesskit_node().role() == Role::Button).unwrap_or_else(|| panic!("`{label}` drawn")).rect().center() +} + +fn selected(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_role_and_label(Role::Button, label).accesskit_node().toggled() == Some(Toggled::True) +} + +/// Press at `from`, move to `to` in steps and release there, with `modifiers` held on release. +fn drag(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(4); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn a_click_selects_the_filter_and_its_smart_object() { + let (mut h, a, _, c) = panel(); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(c))); + h.get_by_role_and_label(Role::Button, "Gaussian Blur").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(a)), "the smart object becomes active"); + assert!(selected(&h, "Gaussian Blur")); + assert!(!selected(&h, "Offset")); + // Another layer: the filter is no longer shown selected. + let pixels = at(&h, "Pixels"); + h.hover_at(pixels); + h.run_steps(1); + h.drag_at(pixels); + h.run_steps(1); + h.drop_at(pixels); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(c))); + assert!(!selected(&h, "Gaussian Blur")); +} + +#[test] +fn dragging_a_filter_onto_another_smart_object_moves_it_and_alt_copies_it() { + let (mut h, a, b, _) = panel(); + let (blur, offset) = ("filter.blur.gaussianBlur", "filter.other.offset"); + let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::ALT); + assert_eq!(filters(&h, b), [blur], "⌥-drag copies"); + assert_eq!(filters(&h, a), [blur, offset]); + let (from, to) = (at(&h, "Offset"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, b), [blur, offset], "a drag moves"); + assert_eq!(filters(&h, a), [blur]); +} + +#[test] +fn a_pixel_layer_does_not_take_smart_filters() { + let (mut h, a, _, _) = panel(); + let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Pixels")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, a).len(), 2); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); +} + +#[test] +fn dragging_filters_onto_delete_removes_them() { + let (mut h, a, _, _) = panel(); + let (from, to) = (at(&h, "Offset"), at(&h, "Delete layer")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, a), ["filter.blur.gaussianBlur"]); + let (from, to) = (at(&h, "Smart Filters"), at(&h, "Delete layer")); + drag(&mut h, from, to, Modifiers::NONE); + assert!(filters(&h, a).is_empty()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4, "no layer was deleted"); +} + +/// The report's case: the Camera Raw Filter is ⌥-dragged to another smart object with its +/// settings, and double-clicking the copy opens Camera Raw on that smart object's filter. +#[test] +fn a_camera_raw_filter_copies_and_its_copy_opens_on_the_target() { + let (mut h, a, b, _) = panel(); + h.state_mut().run("layer.select", json!({"layer": a})).unwrap(); + h.state_mut().run("filter.cameraRaw", json!({"exposure": 1.0, "contrast": 20})).unwrap(); + h.run_steps(4); + let (from, to) = (at(&h, "Camera Raw Filter"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::ALT); + let raw = |h: &egui_kittest::Harness<'static, PhotocraftApp>, id: u64| match &h.state().session.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.smart_filters.iter().find(|f| f.command == "filter.cameraRaw").map(|f| f.params.clone()), + _ => None, + }; + assert_eq!(raw(&h, b), raw(&h, a), "copied with its settings"); + assert_eq!(raw(&h, b).unwrap()["exposure"], 1.0); + // B is above A in the panel, so its copy is the upper of the two Camera Raw rows. + let copy = h + .query_all_by_label("Camera Raw Filter") + .filter(|n| n.accesskit_node().role() == Role::Button) + .map(|n| n.rect().center()) + .min_by(|p, q| p.y.total_cmp(&q.y)) + .unwrap(); + for _ in 0..2 { + h.hover_at(copy); + h.drag_at(copy); + h.run_steps(1); + h.drop_at(copy); + h.run_steps(1); + } + h.run_steps(3); + let dialog = h.state().camera_raw.as_ref().expect("Camera Raw opened"); + assert_eq!(dialog.layer, LayerId(b)); +} diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..fd1ed6f01c 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,23 +1,60 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows -//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). +//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters; click +//! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271). -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; -use photocraft_doc::{Layer, LayerContent, SmartObject}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartObject}; use serde_json::{Value, json}; use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; -/// Smart-object badge in the bottom-right corner of a layer thumbnail. -pub fn thumb_badge(ui: &egui::Ui, l: &Layer, thumb: Rect) { - if !matches!(l.content, LayerContent::Smart(_)) { - return; +/// What the badge on a layer thumbnail marks the layer as. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ThumbBadge { + Smart, + Shape, +} + +/// The badge `l`'s thumbnail carries. Smart objects and shape layers (#4584) show their pixels +/// like any other layer, so only the badge tells them apart. +fn thumb_badge_kind(l: &Layer) -> Option { + match l.content { + LayerContent::Smart(_) => Some(ThumbBadge::Smart), + LayerContent::Shape(_) => Some(ThumbBadge::Shape), + _ => None, } +} + +/// Where the badge sits: over the bottom-right corner of the thumbnail. +fn thumb_badge_rect(thumb: Rect) -> Rect { + Rect::from_min_size(pos2(thumb.right() - 9.0, thumb.bottom() - 9.0), vec2(11.0, 11.0)) +} + +/// The shape badge's path, inset from the badge so its corner anchors stay inside the frame. +const SHAPE_PATH_INSET: f32 = 3.0; +/// Side of an anchor point on the shape badge's path. +const SHAPE_ANCHOR: f32 = 3.0; + +/// Smart-object or shape-layer badge in the bottom-right corner of a layer thumbnail. +pub fn thumb_badge(ui: &egui::Ui, l: &Layer, thumb: Rect) { + let Some(kind) = thumb_badge_kind(l) else { return }; let t = Tokens::get(ui.ctx()); - let r = Rect::from_min_size(pos2(thumb.right() - 9.0, thumb.bottom() - 9.0), vec2(11.0, 11.0)); + let r = thumb_badge_rect(thumb); ui.painter().rect_filled(r, 2.0, t.field); ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - icons::paint(ui, r, "app-window", 9.0, t.icon); + match kind { + ThumbBadge::Smart => icons::paint(ui, r, "app-window", 9.0, t.icon), + // Photoshop's shape mark: a path with an anchor point on each corner. Painted rather + // than an icon, so the anchors stay solid squares at this size. + ThumbBadge::Shape => { + let path = r.shrink(SHAPE_PATH_INSET); + ui.painter().rect_stroke(path, 0.0, Stroke::new(1.0, t.icon), egui::StrokeKind::Middle); + for anchor in [path.left_top(), path.right_top(), path.right_bottom(), path.left_bottom()] { + ui.painter().rect_filled(Rect::from_center_size(anchor, vec2(SHAPE_ANCHOR, SHAPE_ANCHOR)), 0.0, t.icon); + } + } + } } /// The display name of a smart filter (its command's label without the ellipsis). A Photoshop @@ -41,10 +78,20 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } let t = Tokens::get(ui.ctx()); let indent = 30.0 + depth as f32 * 14.0 + 34.0; + let Some(st) = app.session.active() else { return }; + let (active, doc) = (st.active_layer == Some(l.id), st.doc.id); + let picked = ui.ctx().data(|d| d.get_temp::(selected_key(doc))); let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if resp.hovered() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp::(drag_key(), (l.id.0, index))); + } + // Selected as in Photoshop: the row stays highlighted while its smart object is active. + let selected = active && picked == Some((l.id.0, index)); + if selected { + ui.painter().rect_filled(rect, 0.0, if t.pro { t.row_selected } else { t.hover }); + } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } if t.pro { @@ -75,7 +122,14 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, &name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)); + if resp.clicked() { + ui.ctx().data_mut(|d| d.insert_temp::(selected_key(doc), (l.id.0, index))); + if !active { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + } + } if let Some(i) = index && resp.double_clicked() { @@ -84,6 +138,67 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } } +/// Smart filter rows being dragged: the smart object and the filter index (`None` = the Smart +/// Filters row, all of them). Also what a click selects. +pub(crate) type FilterDrag = (u64, Option); + +pub(crate) fn drag_key() -> egui::Id { + egui::Id::new("smart-filter-drag") +} + +/// The selected smart filter row of a document. +fn selected_key(doc: photocraft_doc::DocId) -> egui::Id { + egui::Id::new(("smart-filter-selected", doc)) +} + +/// While smart filters are dragged: their name by the pointer, and the copy cursor while ⌥ is held. +pub(crate) fn drag_feedback(ctx: &egui::Context, doc: &Document) { + let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let label = match (filter, doc.layer(LayerId(from)).map(|l| &l.content)) { + (Some(i), Some(LayerContent::Smart(sm))) => sm.smart_filters.get(i).map_or_else(String::new, filter_label), + _ => "Smart Filters".to_string(), + }; + crate::layer_transfer::ghost(ctx, p, &label); + if ctx.input(|i| i.modifiers.alt) { + ctx.set_cursor_icon(egui::CursorIcon::Copy); + } +} + +/// The command for dropping the smart filters of `from` on the smart object `target`: they move +/// there, or with ⌥ held on release are copied (Photoshop). +pub(crate) fn drop_action(from: u64, filter: Option, target: LayerId, copy: bool) -> (String, Value) { + let mut payload = json!({"from": from, "to": target.0, "copy": copy}); + if let (Some(i), Some(o)) = (filter, payload.as_object_mut()) { + o.insert("filter".into(), json!(i)); + } + ("layer.smartFilter.transfer".into(), payload) +} + +/// Another smart object's row accepts the smart filters being dragged: outlined while the pointer +/// is over it, dropped on release. Other layers don't take them, as in Photoshop. +pub(crate) fn drop_on_row(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) { + let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + if from == l.id.0 || !matches!(l.content, LayerContent::Smart(_)) || !rect.contains(p) { + return; + } + let t = Tokens::get(ctx); + ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push(drop_action(from, filter, l.id, ctx.input(|i| i.modifiers.alt))); + } +} + +/// Smart filters dropped on the footer's Delete button: a filter's row deletes that filter, the +/// Smart Filters row clears them all. +pub(crate) fn trash_action((layer, filter): FilterDrag) -> (String, Value) { + match filter { + Some(i) => ("layer.smartFilter.delete".into(), json!({"layer": layer, "index": i})), + None => ("layer.smartFilter.clearSmartFilters".into(), json!({"layer": layer})), + } +} + /// Re-opens a smart filter's dialog with its recorded parameters; OK runs /// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`). pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) { @@ -121,3 +236,70 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A pixel layer, a shape layer and a smart object, in that order. + fn layers() -> [Layer; 3] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + // An empty layer can't become a smart object: fill it first. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let smart = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + let shape = s.execute("shape.create", json!({"kind": "rect", "rect": [8, 8, 40, 24], "fill": "#cc3333"})).unwrap()["layer"].as_u64().unwrap(); + let pixel = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let doc = &s.active().unwrap().doc; + [pixel, shape, smart].map(|id| doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()) + } + + #[test] + fn shape_layers_and_smart_objects_carry_a_thumbnail_badge() { + let [pixel, shape, smart] = layers(); + assert_eq!(thumb_badge_kind(&pixel), None); + assert_eq!(thumb_badge_kind(&shape), Some(ThumbBadge::Shape), "#4584"); + assert_eq!(thumb_badge_kind(&smart), Some(ThumbBadge::Smart)); + } + + /// The rects `thumb_badge` fills for `l` over `thumb`, with their colours. + fn filled(l: &Layer, thumb: Rect) -> Vec<(Rect, Color32)> { + let l = l.clone(); + let mut h = egui_kittest::Harness::new_ui(move |ui| thumb_badge(ui, &l, thumb)); + h.run(); + let badge = thumb_badge_rect(thumb).expand(0.5); + let rects = h.output().shapes.iter().filter_map(|s| match &s.shape { + egui::Shape::Rect(r) if badge.contains_rect(r.rect) && r.fill != Color32::TRANSPARENT => Some((r.rect, r.fill)), + _ => None, + }); + rects.collect() + } + + /// #4584: a shape layer's thumbnail shows a path with four anchor points in its corner. + #[test] + fn shape_badge_paints_a_path_with_an_anchor_on_each_corner() { + let [pixel, shape, smart] = layers(); + let thumb = Rect::from_min_size(pos2(20.0, 20.0), vec2(34.0, 34.0)); + let t = Tokens::get(&egui::Context::default()); + let frame = thumb_badge_rect(thumb); + assert!(frame.right() > thumb.right() && frame.bottom() > thumb.bottom(), "over the bottom-right corner"); + assert!(filled(&pixel, thumb).is_empty(), "a pixel layer has no badge"); + + let drawn = filled(&shape, thumb); + assert!(drawn.contains(&(frame, t.field)), "the badge's frame"); + let anchors: Vec = drawn.iter().filter(|(_, c)| *c == t.icon).map(|(r, _)| *r).collect(); + let path = frame.shrink(SHAPE_PATH_INSET); + let corners = [path.left_top(), path.right_top(), path.right_bottom(), path.left_bottom()]; + assert_eq!(anchors.iter().map(Rect::center).collect::>(), corners); + for a in &anchors { + assert_eq!(a.size(), vec2(SHAPE_ANCHOR, SHAPE_ANCHOR)); + assert!(frame.shrink(1.0).contains_rect(*a), "inside the frame's border"); + } + + // The smart-object badge keeps its frame and draws no anchors. + let drawn = filled(&smart, thumb); + assert!(drawn.contains(&(frame, t.field))); + assert!(drawn.iter().all(|(_, c)| *c != t.icon)); + } +} diff --git a/crates/ui-egui/src/spring_tools.rs b/crates/ui-egui/src/spring_tools.rs new file mode 100644 index 0000000000..5f51136cdb --- /dev/null +++ b/crates/ui-egui/src/spring_tools.rs @@ -0,0 +1,127 @@ +//! Spring-loaded tool keys (#2672): hold a tool's letter for a temporary tool. +//! +//! A tool letter switches tools when it is pressed, as before (`shortcuts::handle`). If the key is +//! then held for [`SPRING_HOLD_SECS`] or longer, or the canvas is used while it is down, the switch +//! was only temporary: releasing the key gives back the tool in effect before the press, with its +//! options. A quick tap with no canvas use stays a permanent switch. +//! +//! - ⇧+letter (cycling the group) and ⌥+letter never spring; nor do letters while typing in a +//! field, editing type or under a modal dialog, which never reach the tool keys. +//! - Pressing the current group's own letter cycles the group, as a tap does, and doesn't spring. +//! - A canvas drag still in progress when the key comes up finishes with the temporary tool; the +//! previous tool returns once the button is up (as `hold_keys` latches Space). +//! - Another letter pressed while one is held wins (newest first); releasing every held letter +//! returns the original tool. Holding two letters at once counts as a hold. +//! - Key repeats are not presses: `shortcuts::handle` drops them for tool letters. +//! +//! Z is one case: hold Z to zoom, add ⌥ to zoom out, as with the Zoom tool itself (and as +//! `hold_keys` does for ⌥ with a held Zoom In). +//! +//! The state is [`crate::state::UiState::spring`], readable over the control channel. + +use egui::Key; +use serde::{Deserialize, Serialize}; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// How long (seconds) a tool letter must stay down for its tool to be temporary. +pub const SPRING_HOLD_SECS: f64 = 0.3; + +/// A tool switch made by a letter that is still down (or whose drag is still finishing). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Spring { + /// The tool to give back. + pub previous: Tool, + /// The tool the held letter picked. If the tool changes any other way while it's held (the + /// toolbar, a menu, automation), that choice stands and nothing springs back. + pub tool: Tool, + /// The tool letters held, oldest first. + pub keys: Vec, + /// `egui` input time of the first press. + pub since: f64, + /// The canvas was pressed (or a second letter pressed) while held: the switch is temporary + /// however short the hold. + pub used: bool, + /// Every letter is up and the switch is temporary, but a drag is still in progress. + pub released: bool, +} + +/// The egui key of a tool letter (`None` for the tools without one). +pub fn key_of(letter: char) -> Option { + if letter == '\0' { + return None; + } + Key::from_name(&letter.to_string()) +} + +/// Is `k` some tool's single-key shortcut? +pub fn is_tool_key(k: Key) -> bool { + Tool::ALL.iter().any(|t| key_of(t.key()) == Some(k)) +} + +/// A tool letter switched the tool from `before` (the switch is already made). `plain` is false +/// when ⇧ or ⌥ was held: those switches are permanent. `now` is the input time. +pub fn pressed(app: &mut PhotocraftApp, letter: char, before: Tool, plain: bool, now: f64) { + let active = app.ui.spring.as_ref().is_some_and(|s| !s.released); + if !plain { + app.ui.spring = None; + return; + } + if active { + if let Some(s) = app.ui.spring.as_mut() { + s.keys.retain(|k| *k != letter); + s.keys.push(letter); + s.used = true; + s.tool = app.ui.tool; + } + return; + } + // The current group's own key cycles it (or keeps it), as a tap always has. + let tool = app.ui.tool; + app.ui.spring = (before.key() != letter).then(|| Spring { previous: before, tool, keys: vec![letter], since: now, used: false, released: false }); +} + +/// The canvas was pressed: a held letter's tool becomes temporary. +pub fn note_canvas_press(app: &mut PhotocraftApp) { + if let Some(s) = app.ui.spring.as_mut().filter(|s| !s.released) { + s.used = true; + } +} + +/// Once a frame, before the key handling: notice released letters and give the previous tool +/// back when the switch was temporary and no drag is in progress. +pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(mut s) = app.ui.spring.take() else { return }; + // Type editing begun with the temporary Type tool keeps it: the edit owns the tool now. So + // does a tool picked another way while the letter is down. + if app.ui.text_edit.is_some() || app.ui.tool != s.tool { + return; + } + let (now, button) = ctx.input(|i| (i.time, i.pointer.any_down())); + let dragging = app.drag.is_some() || button; + if !s.released { + if app.drag.is_some() { + s.used = true; + } + s.keys.retain(|k| key_of(*k).is_some_and(|k| ctx.input(|i| i.key_down(k)))); + if !s.keys.is_empty() { + app.ui.spring = Some(s); + return; + } + if !s.used && now - s.since < SPRING_HOLD_SECS { + // A tap: the switch stands. + return; + } + s.released = true; + } + if dragging { + app.ui.spring = Some(s); + ctx.request_repaint(); + return; + } + app.ui.tool = s.previous; +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/spring_tools/tests.rs b/crates/ui-egui/src/spring_tools/tests.rs new file mode 100644 index 0000000000..eef327c096 --- /dev/null +++ b/crates/ui-egui/src/spring_tools/tests.rs @@ -0,0 +1,269 @@ +//! #2672 through the real key handling and canvas: a tapped tool letter switches for good, a held +//! one (or one used on the canvas) springs back on release. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use super::*; +use crate::canvas::ViewXform; + +/// Frames are 1/60 s apart, so a hold is a count of frames. +const DT: f32 = 1.0 / 60.0; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(DT).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +/// One key event with its modifiers, run as a frame. +fn key_event(h: &mut Harness<'static, PhotocraftApp>, k: Key, pressed: bool, repeat: bool, m: Modifiers) { + h.event(Event::ModifiersChanged(m)); + h.event(Event::Key { key: k, physical_key: None, pressed, repeat, modifiers: m }); + h.run_steps(1); +} + +fn down(h: &mut Harness<'static, PhotocraftApp>, k: Key) { + key_event(h, k, true, false, Modifiers::NONE); +} + +fn up(h: &mut Harness<'static, PhotocraftApp>, k: Key) { + key_event(h, k, false, false, Modifiers::NONE); + h.run_steps(2); +} + +/// Wait `secs` of input time. +fn wait(h: &mut Harness<'static, PhotocraftApp>, secs: f64) { + h.run_steps((secs / f64::from(DT)).ceil() as usize); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool, m: Modifiers) { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: m }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(Event::PointerMoved(p)); + h.run_steps(1); +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + move_to(h, x, y); + let p = screen(h, x, y); + button(h, p, true, m); + button(h, p, false, m); +} + +#[test] +fn a_tap_switches_tools_for_good() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert!(h.state().ui.spring.is_some(), "the press is pending: tap or hold"); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn a_held_letter_gives_the_previous_tool_back() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + assert_eq!(h.state().ui.tool, Tool::Eraser, "the temporary tool is the tool shown meanwhile"); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn hold_z_click_zooms_then_a_quick_release_restores() { + let mut h = harness(Tool::Brush); + let z0 = h.state().ui.views[0].zoom; + down(&mut h, Key::Z); + click(&mut h, 200.0, 150.0, Modifiers::NONE); + up(&mut h, Key::Z); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + assert_eq!(h.state().ui.tool, Tool::Brush, "a canvas click makes even a short hold temporary"); + assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted"); + // ⌥ with a held Z zooms out, as ⌥ does with the Zoom tool. + down(&mut h, Key::Z); + h.event(Event::ModifiersChanged(Modifiers::ALT)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + up(&mut h, Key::Z); + assert!(h.state().ui.views[0].zoom < z1); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn shift_letter_cycles_and_never_springs() { + let mut h = harness(Tool::Brush); + h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); + key_event(&mut h, Key::B, true, false, Modifiers::SHIFT); + assert_eq!(h.state().ui.tool, Tool::Pencil); + assert_eq!(h.state().ui.spring, None); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + key_event(&mut h, Key::B, false, false, Modifiers::SHIFT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Pencil); + + // Without the preference ⇧E is still a permanent switch, however long it is held. + let mut h = harness(Tool::Brush); + key_event(&mut h, Key::E, true, false, Modifiers::SHIFT); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + key_event(&mut h, Key::E, false, false, Modifiers::SHIFT); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn key_repeats_are_not_presses() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Lasso); + for _ in 0..8 { + key_event(&mut h, Key::L, true, true, Modifiers::NONE); + wait(&mut h, 0.05); + } + assert_eq!(h.state().ui.tool, Tool::Lasso, "repeats don't cycle the group"); + up(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn a_drag_keeps_the_temporary_tool_until_the_button_is_up() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::M); + assert_eq!(h.state().ui.tool, Tool::RectMarquee); + move_to(&mut h, 50.0, 50.0); + let p = screen(&h, 50.0, 50.0); + button(&mut h, p, true, Modifiers::NONE); + move_to(&mut h, 100.0, 90.0); + up(&mut h, Key::M); + assert_eq!(h.state().ui.tool, Tool::RectMarquee, "the drag finishes with the temporary tool"); + move_to(&mut h, 150.0, 110.0); + let p = screen(&h, 150.0, 110.0); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.spring, None); + let sel = h.state().session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert!(sel.is_some_and(|r| r.width() > 50 && r.height() > 30), "{sel:?}"); +} + +#[test] +fn the_newest_letter_wins_and_release_restores_the_original() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + down(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Lasso); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Lasso, "L is still held"); + up(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn letters_type_while_editing_text() { + let mut h = harness(Tool::Type); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "a", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 1, + anchor: 1, + session: "spring-test".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + h.run_steps(2); + h.event(Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Text("b".into())); + h.run_steps(1); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + up(&mut h, Key::B); + assert_eq!(h.state().ui.tool, Tool::Type); + assert_eq!(h.state().ui.spring, None); + let st = h.state().session.active().unwrap(); + match &st.doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Text(t) => assert_eq!(t.text, "ab"), + _ => panic!("not text"), + } +} + +/// A tool picked another way while a letter is held (here, as the toolbar would) stands: the release +/// doesn't take it back. +#[test] +fn a_tool_picked_while_a_letter_is_held_stands() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + h.state_mut().ui.tool = Tool::Lasso; + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Lasso); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn plain_functions_follow_the_rules() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Eraser; + // The current group's own key cycles; no spring. + pressed(&mut app, 'E', Tool::Eraser, true, 0.0); + assert_eq!(app.ui.spring, None); + // A ⇧ switch clears any pending spring. + pressed(&mut app, 'B', Tool::Eraser, true, 0.0); + assert!(app.ui.spring.is_some()); + pressed(&mut app, 'L', Tool::Brush, false, 0.1); + assert_eq!(app.ui.spring, None); + // A canvas press marks a pending spring used; nothing to mark without one. + note_canvas_press(&mut app); + pressed(&mut app, 'B', Tool::Eraser, true, 0.0); + note_canvas_press(&mut app); + assert!(app.ui.spring.as_ref().is_some_and(|s| s.used && s.previous == Tool::Eraser)); + assert_eq!(key_of('\0'), None); + assert!(is_tool_key(Key::Z) && !is_tool_key(Key::F1)); +} diff --git a/crates/ui-egui/src/stack_dialog.rs b/crates/ui-egui/src/stack_dialog.rs new file mode 100644 index 0000000000..d1d9d01e75 --- /dev/null +++ b/crates/ui-egui/src/stack_dialog.rs @@ -0,0 +1,205 @@ +//! File › Scripts › Load Files into Stack… (#4671, #4696), laid out as Photoshop's: Source Files +//! with Use (Files or Folder), the list of what will be loaded, Browse…, Remove, Add Open Files and +//! Sort By Name, then Attempt to Automatically Align Source Images and Create Smart Object after +//! Loading Layers. OK runs `file.scripts.loadFilesIntoStack` with the list or the folder. +use egui::{RichText, vec2}; +use serde_json::{Map, Value, json}; + +use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; +use crate::{PhotocraftApp, state::DialogKind, theme::Tokens, widgets}; + +const MARK: &str = "__loadStack"; +pub const COMMAND: &str = "file.scripts.loadFilesIntoStack"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Open the dialog with an empty Files list. Returns its id. +pub fn open(app: &mut PhotocraftApp) -> u64 { + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("Load Files into Stack…")); + f.insert("use".into(), json!("files")); + f.insert("files".into(), json!([])); + f.insert("folder".into(), json!("")); + f.insert("align".into(), json!(false)); + f.insert("smartObject".into(), json!(false)); + app.ui.open_dialog(DialogKind::Command, f) +} + +fn folder_mode(f: &Map) -> bool { + f.get("use").and_then(Value::as_str) == Some("folder") +} + +fn files(f: &Map) -> Vec { + f.get("files").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() +} + +fn folder(f: &Map) -> &str { + f.get("folder").and_then(Value::as_str).unwrap_or("") +} + +fn selected(f: &Map) -> Option { + f.get("selected").and_then(Value::as_u64).map(|i| i as usize) +} + +/// Add `paths` to the Files list, skipping those already in it, in their order. +fn add(f: &mut Map, paths: impl IntoIterator) { + let mut list = files(f); + for p in paths { + if !list.contains(&p) { + list.push(p); + } + } + f.insert("files".into(), json!(list)); +} + +fn file_name(p: &str) -> &str { + std::path::Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p) +} + +/// The open documents that have a file, in tab order (what Add Open Files adds). +fn open_files(app: &PhotocraftApp) -> Vec { + app.session.documents().iter().filter_map(|d| d.path.clone()).collect() +} + +/// OK needs files to load: some in the list, or a chosen folder. +pub fn can_confirm(f: &Map) -> bool { + !owns(f) || if folder_mode(f) { !folder(f).is_empty() } else { !files(f).is_empty() } +} + +/// Browse…: files to add (Files) or the folder to load (Folder), into dialog `id` once the file +/// dialog answers. +fn browse(app: &mut PhotocraftApp, id: u64, folder: bool) { + let r = if folder { + app.ask_folder(move |app, path| { + let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; + d.fields.insert("folder".into(), json!(path)); + Ok(Value::Null) + }) + } else { + let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); + app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, move |app, answer| { + let FileDialogAnswer::Paths(paths) = answer else { return Err("Load Files into Stack reads files from disk".into()) }; + let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; + add(&mut d.fields, paths); + Ok(Value::Null) + }) + }; + if let Err(e) = r.map(|_| ()).or_else(|e| if e == crate::file_dialog::CANCELLED { Ok(()) } else { Err(e) }) { + app.ui.status = e; + } +} + +/// The dialog's body (dialog `id`); OK and Cancel are the standard ones below it. +pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(tl!("Source Files")).font(crate::theme::semibold(13.0))); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(tl!("Use:")); + let mut mode = usize::from(folder_mode(f)); + let options = [(0, tl!("Files")), (1, tl!("Folder"))]; + if widgets::dropdown(ui, "load-stack-use", &mut mode, &options, 120.0) { + f.insert("use".into(), json!(if mode == 1 { "folder" } else { "files" })); + f.remove("selected"); + } + }); + ui.add_space(6.0); + let folder_mode = folder_mode(f); + let mut browse_now = false; + ui.horizontal_top(|ui| { + // The list: the chosen files, or the images in the chosen folder. + let shown: Vec = if folder_mode { + if folder(f).is_empty() { Vec::new() } else { photocraft_engine::file_cmds::list_images(folder(f)).unwrap_or_default() } + } else { + files(f) + }; + let sel = selected(f); + egui::Frame::NONE.fill(t.field).stroke(egui::Stroke::new(1.0, t.field_border)).corner_radius(t.radius_sm).inner_margin(4.0).show(ui, |ui| { + ui.set_min_size(vec2(300.0, 160.0)); + ui.set_max_width(300.0); + egui::ScrollArea::vertical().id_salt("load-stack-files").max_height(160.0).auto_shrink([false, false]).show(ui, |ui| { + if folder_mode && !folder(f).is_empty() { + ui.label(RichText::new(folder(f)).color(t.text_dim)); + } + for (i, p) in shown.iter().enumerate() { + let row = ui.add(egui::Button::selectable(!folder_mode && sel == Some(i), file_name(p))).on_hover_text(p.as_str()); + if row.clicked() && !folder_mode { + f.insert("selected".into(), json!(i)); + } + } + }); + }); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 4.0; + if widgets::secondary_button(ui, tl!("Browse…"), 120.0).clicked() { + browse_now = true; + } + let can_remove = !folder_mode && sel.is_some_and(|i| i < files(f).len()); + if ui.add_enabled_ui(can_remove, |ui| widgets::secondary_button(ui, tl!("Remove"), 120.0)).inner.clicked() + && let Some(i) = sel + { + let mut list = files(f); + list.remove(i); + f.insert("files".into(), json!(list)); + f.remove("selected"); + } + let open = open_files(app); + if ui.add_enabled_ui(!folder_mode && !open.is_empty(), |ui| widgets::secondary_button(ui, tl!("Add Open Files"), 120.0)).inner.clicked() { + add(f, open); + } + if ui.add_enabled_ui(!folder_mode && files(f).len() > 1, |ui| widgets::secondary_button(ui, tl!("Sort By Name"), 120.0)).inner.clicked() { + let mut list = files(f); + list.sort_by_key(|p| file_name(p).to_lowercase()); + f.insert("files".into(), json!(list)); + f.remove("selected"); + } + }); + }); + ui.add_space(8.0); + for (key, label) in [("align", tl!("Attempt to Automatically Align Source Images")), ("smartObject", tl!("Create Smart Object after Loading Layers"))] { + let mut on = f.get(key).and_then(Value::as_bool).unwrap_or(false); + if widgets::checkbox(ui, &mut on, label).changed() { + f.insert(key.into(), json!(on)); + } + } + if browse_now { + // The file dialog's answer lands in the dialog's own fields: save this frame's edits first. + if let Some(d) = app.ui.dialog_mut(id) { + d.fields = f.clone(); + } + browse(app, id, folder_mode); + } +} + +/// OK: load the list (or the folder) into a new document. +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let paths = if folder_mode(f) { + let dir = folder(f); + if dir.is_empty() { + return Err("Choose a folder to load".into()); + } + json!(dir) + } else { + let list = files(f); + if list.is_empty() { + return Err("Add the files to load".into()); + } + json!(list) + }; + let align = f.get("align").and_then(Value::as_bool).unwrap_or(false); + let smart = f.get("smartObject").and_then(Value::as_bool).unwrap_or(false); + let r = app.run(COMMAND, json!({"paths": paths, "align": align, "createSmartObject": smart})); + if r.is_ok() { + app.sync_views(); + } + r +} + +#[cfg(test)] +#[path = "stack_dialog_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/stack_dialog_tests.rs b/crates/ui-egui/src/stack_dialog_tests.rs new file mode 100644 index 0000000000..1f6c5501ed --- /dev/null +++ b/crates/ui-egui/src/stack_dialog_tests.rs @@ -0,0 +1,128 @@ +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; + +use super::*; +use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + +/// `n` small PNGs named `names` in a fresh folder for `tag`: (folder, paths). +fn pngs(tag: &str, names: &[&str]) -> (String, Vec) { + let dir = std::env::temp_dir().join(format!("photocraft-load-stack-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "background": "#406080"})).unwrap(); + let paths = names + .iter() + .map(|n| { + let p = dir.join(n).to_string_lossy().into_owned(); + s.execute("file.saveACopy", json!({"path": p})).unwrap(); + p + }) + .collect(); + (dir.to_string_lossy().into_owned(), paths) +} + +fn harness(answers: Vec>) -> (Harness<'static, PhotocraftApp>, std::rc::Rc>>) { + let (show, asked) = crate::file_dialog::fake(answers); + let h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }) + }); + (h, asked) +} + +fn dialog(h: &Harness<'_, PhotocraftApp>) -> Option<(u64, Map)> { + h.state().ui.dialogs.iter().find(|d| owns(&d.fields)).map(|d| (d.id, d.fields.clone())) +} + +fn listed(h: &Harness<'_, PhotocraftApp>) -> Vec { + dialog(h).map(|(_, f)| files(&f).iter().map(|p| file_name(p).to_string()).collect()).unwrap_or_default() +} + +/// #4671 / #4696: the menu opens Photoshop's dialog, Browse… adds files (once each), Remove, +/// Sort By Name and Add Open Files edit the list, and OK loads it. +#[test] +fn source_files_list_is_built_with_browse_remove_sort_and_open_files() { + let (_, paths) = pngs("files", &["b.png", "c.png", "a.png"]); + let (mut h, asked) = harness(vec![ + Some(FileDialogAnswer::Paths(vec![paths[0].clone(), paths[1].clone()])), + Some(FileDialogAnswer::Paths(vec![paths[2].clone(), paths[0].clone()])), + ]); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, COMMAND, json!({})).unwrap(); + h.run_steps(4); + assert!(dialog(&h).is_some(), "the menu opens the dialog"); + for label in [ + "Source Files", + "Browse…", + "Remove", + "Add Open Files", + "Sort By Name", + "Attempt to Automatically Align Source Images", + "Create Smart Object after Loading Layers", + ] { + assert!(h.query_by_label(label).is_some(), "{label}"); + } + assert!(h.get_by_label("OK").accesskit_node().is_disabled(), "nothing to load yet"); + assert!(h.get_by_label("Remove").accesskit_node().is_disabled()); + + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert!(matches!(asked.borrow()[0], FileDialogRequest::Open { multiple: true, .. }), "{:?}", asked.borrow()); + assert_eq!(listed(&h), ["b.png", "c.png"]); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert_eq!(listed(&h), ["b.png", "c.png", "a.png"], "b.png only once"); + + h.get_by_label("Sort By Name").click(); + h.run_steps(2); + assert_eq!(listed(&h), ["a.png", "b.png", "c.png"]); + h.get_by_label("b.png").click(); + h.run_steps(2); + h.get_by_label("Remove").click(); + h.run_steps(2); + assert_eq!(listed(&h), ["a.png", "c.png"]); + + // Add Open Files: the open documents that have a file (b.png, already saved; not the untitled one). + h.state_mut().run("file.new", json!({"width": 4, "height": 4})).unwrap(); + h.state_mut().session.active_mut().unwrap().path = Some(paths[0].clone()); + h.state_mut().run("file.new", json!({"width": 4, "height": 4})).unwrap(); + h.run_steps(3); + h.get_by_label("Add Open Files").click(); + h.run_steps(2); + assert_eq!(listed(&h), ["a.png", "c.png", "b.png"]); + + let docs = h.state().session.documents().len(); + h.get_by_label("OK").click(); + h.run_steps(4); + assert!(dialog(&h).is_none()); + assert_eq!(h.state().session.documents().len(), docs + 1, "a new document"); + let d = &h.state().session.active().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["a.png", "c.png", "b.png"], "one layer per file, in the list's order"); +} + +/// Use: Folder loads the images of the folder Browse… chose, listed in the dialog; with Create +/// Smart Object they go into one smart object. +#[test] +fn a_folder_loads_its_images() { + let (dir, _) = pngs("folder", &["one.png", "two.png"]); + let (mut h, asked) = harness(vec![Some(FileDialogAnswer::FolderTo(dir.clone()))]); + let id = open(h.state_mut()); + h.run_steps(3); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("use".into(), json!("folder")); + h.run_steps(2); + assert!(h.get_by_label("Remove").accesskit_node().is_disabled() && h.get_by_label("Add Open Files").accesskit_node().is_disabled()); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert!(matches!(asked.borrow()[0], FileDialogRequest::Folder { .. })); + assert!(h.query_by_label("one.png").is_some() && h.query_by_label("two.png").is_some(), "the folder's images are listed"); + // Create Smart Object after Loading Layers: both images inside one smart object. + h.get_by_label("Create Smart Object after Loading Layers").click(); + h.run_steps(2); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let d = &h.state().session.active().unwrap().doc; + assert_eq!(d.layers.len(), 1); + assert!(matches!(d.layers[0].content, photocraft_doc::LayerContent::Smart(_))); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 02b4f005db..b82c7cb023 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -508,6 +508,8 @@ pub struct ToolOptions { pub contiguous: bool, pub anti_alias: bool, pub sample_all_layers: bool, + /// Object Selection › Object Finder: hovering highlights the object a click selects (#4087). + pub object_finder: bool, /// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer). pub remove_new_layer: bool, /// Remove tool: each stroke is filled when released (Photoshop's default). Off, the strokes @@ -586,6 +588,10 @@ pub struct ToolOptions { /// Freeform Pen › Curve Fit: how far (px) the fitted path may stray from the pointer trace. #[serde(default = "default_curve_fit")] pub curve_fit: f32, + /// Pen › Auto Add/Delete: a Pen click on a segment of the active path adds an anchor there, + /// on one of its anchors deletes it (#4144). + #[serde(default = "yes")] + pub pen_auto_add_delete: bool, /// Marquee options bar: "normal" | "fixedRatio" | "fixedSize", with the ratio or size. #[serde(default = "default_marquee_style")] pub marquee_style: String, @@ -750,6 +756,7 @@ impl Default for ToolOptions { contiguous: true, anti_alias: true, sample_all_layers: false, + object_finder: true, remove_new_layer: false, remove_after_each_stroke: true, feather: 0.0, @@ -797,6 +804,7 @@ impl Default for ToolOptions { polygon_sides: 5, line_weight: 3.0, curve_fit: default_curve_fit(), + pen_auto_add_delete: true, marquee_style: default_marquee_style(), marquee_width: 1.0, marquee_height: 1.0, @@ -848,10 +856,12 @@ impl Default for ToolOptions { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TransformMode { - /// Free Transform, Scale and Rotate. + /// Free Transform and Scale: handles scale, drag outside rotates. #[default] Free, - /// Edge handles skew. + /// Edit › Transform › Rotate: handle drags rotate about the reference point (#3941). + Rotate, + /// Edge and corner handles skew: each moves along one axis. Skew, /// Corner handles move freely, one at a time; nothing else moves them (no rotating, no /// edges) and nothing snaps. @@ -864,6 +874,7 @@ impl TransformMode { /// The mode an `edit.transform.*` / `edit.freeTransform` menu id starts. pub fn for_command(id: &str) -> Self { match id { + "edit.transform.rotate" => Self::Rotate, "edit.transform.skew" => Self::Skew, "edit.transform.distort" => Self::Distort, "edit.transform.perspective" => Self::Perspective, @@ -901,13 +912,17 @@ pub struct TransformSession { /// Cancel takes it back and OK folds it into one history step with the transform. #[serde(default)] pub made: Option, - /// Edit › Transform › Skew / Distort / Perspective (`Free` for Free Transform). + /// Edit › Transform › Rotate / Skew / Distort / Perspective (`Free` for Free Transform and Scale). #[serde(default)] pub mode: TransformMode, /// Edit › Content-Aware Scale (#3096): the box only scales and moves, and OK runs /// `edit.contentAwareScale` with these options instead of `edit.transform`. #[serde(default)] pub content_aware: Option, + /// The options bar's V (vertical skew, degrees) last typed. Any box can be read as scale, + /// skew and rotation with any vertical skew, so the bar keeps showing the typed one. + #[serde(default)] + pub skew_v: f64, } /// Content-Aware Scale's options bar: Amount, Protect and Protect Skin Tones. @@ -1033,6 +1048,10 @@ pub struct UiState { /// one per group: the group's key brings it back, as in Photoshop (#2608). #[serde(default)] pub group_tools: Vec, + /// A tool letter still held (spring-loaded tool, #2672): the tool to give back on release. + /// Exposed to automation, never restored from settings. + #[serde(default, skip_deserializing)] + pub spring: Option, /// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated. #[serde(default)] pub recent_files: Vec, @@ -1051,6 +1070,10 @@ pub struct UiState { /// Free Transform session, if any. #[serde(default)] pub transform: Option, + /// The options bar's Maintain Aspect Ratio link is off: W/H change on their own, and handle + /// drags stretch freely (⇧ keeps the proportions), as in Photoshop (#4174). + #[serde(default)] + pub transform_unlinked: bool, /// Temporary symmetry-axis editing, exposed to automation but never restored from settings. #[serde(default, skip_deserializing)] pub symmetry_transform: Option, @@ -1244,12 +1267,14 @@ impl Default for UiState { Self { tool: Tool::Brush, group_tools: Vec::new(), + spring: None, recent_files: Vec::new(), text_edit: None, type_transform: None, type_transform_pivot: None, cam_transform: None, transform: None, + transform_unlinked: false, symmetry_transform: None, layer_picker: None, mask_target: false, diff --git a/crates/ui-egui/src/status_bar_tests.rs b/crates/ui-egui/src/status_bar_tests.rs index 4e4f4a0db5..5f2e576853 100644 --- a/crates/ui-egui/src/status_bar_tests.rs +++ b/crates/ui-egui/src/status_bar_tests.rs @@ -61,3 +61,138 @@ fn status_bar_controls_sit_inside_the_bar_with_even_margins() { assert!((fit.height() - control_h).abs() < 0.01, "{kind:?}: Fit {fit:?}"); } } + +fn cursor_warp(h: &Harness<'_, PhotocraftApp>) -> Option { + h.output().viewport_output.values().flat_map(|v| &v.commands).find_map(|c| match c { + egui::ViewportCommand::CursorPosition(p) => Some(*p), + _ => None, + }) +} + +fn fullscreen(h: &mut Harness<'_, PhotocraftApp>, on: bool) { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().fullscreen = Some(on); + h.state_mut().ui.views[0].zoom = 10.0; + h.run_steps(2); +} + +fn begin_zoom_drag(h: &mut Harness<'_, PhotocraftApp>) -> egui::Pos2 { + let start = h.get_by_role(egui::accesskit::Role::SpinButton).rect().center(); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + start +} + +/// #3344: both status-bar layouts can scrub across either fullscreen edge. Simulate the native +/// CursorPosition acknowledgement as a PointerMoved, then keep dragging in the same direction. +#[test] +fn fullscreen_status_zoom_wraps_without_turning_the_warp_into_a_value_change() { + for kind in ThemeKind::ALL { + for left in [true, false] { + let mut h = harness(kind); + fullscreen(&mut h, true); + let start = begin_zoom_drag(&mut h); + let edge = pos2(if left { 0.5 } else { SIZE.x - 0.5 }, start.y); + h.hover_at(edge); + h.run_steps(1); + let target = cursor_warp(&h).unwrap_or_else(|| panic!("{kind:?}: wrap at {edge:?}")); + assert_eq!(target.y, edge.y, "{kind:?}: preserve Y"); + assert_eq!(target.x, if left { SIZE.x - 3.0 } else { 3.0 }); + let at_edge = h.state().ui.views[0].zoom; + h.hover_at(target); + h.run_steps(1); + assert_eq!(h.state().ui.views[0].zoom, at_edge, "{kind:?}: cursor teleport must not change zoom"); + assert!(cursor_warp(&h).is_none(), "{kind:?}: landing margin must not bounce"); + let next = target + vec2(if left { -40.0 } else { 40.0 }, 0.0); + h.hover_at(next); + h.run_steps(1); + let continued = h.state().ui.views[0].zoom; + assert!((continued - at_edge - if left { -0.2 } else { 0.2 }).abs() < 0.015, "{kind:?}: {at_edge} -> {continued}"); + h.drop_at(next); + h.run_steps(2); + h.hover_at(edge); + h.run_steps(1); + assert_eq!(h.state().ui.views[0].zoom, continued, "{kind:?}: pointer movement after release is not scrubbing"); + assert!(cursor_warp(&h).is_none()); + } + } +} + +#[test] +fn status_zoom_does_not_warp_for_windowed_scrubbing_or_hovering() { + for kind in [ThemeKind::ProMedium, ThemeKind::Studio] { + let mut h = harness(kind); + fullscreen(&mut h, true); + h.hover_at(pos2(0.5, SIZE.y - 10.0)); + h.run_steps(1); + assert!(cursor_warp(&h).is_none(), "hovering the edge"); + fullscreen(&mut h, false); + let start = begin_zoom_drag(&mut h); + let edge = pos2(0.5, start.y); + h.hover_at(edge); + h.run_steps(1); + assert!(cursor_warp(&h).is_none(), "ordinary windowed DragValue"); + assert!(h.state().ui.views[0].zoom < 10.0); + h.drop_at(edge); + h.run_steps(2); + } +} + +#[test] +fn fullscreen_status_zoom_keeps_typing_and_clears_a_pending_warp_on_release() { + let mut h = harness(ThemeKind::ProMedium); + fullscreen(&mut h, true); + let start = begin_zoom_drag(&mut h); + h.hover_at(pos2(0.5, start.y)); + h.run_steps(1); + let target = cursor_warp(&h).expect("wrap"); + let before = h.state().ui.views[0].zoom; + h.drop_at(target); + h.run_steps(2); + assert_eq!(h.state().ui.views[0].zoom, before); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + h.event(egui::Event::Text("250%".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.views[0].zoom, 2.5); + assert!(cursor_warp(&h).is_none()); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().ui.views[0].zoom - 2.51).abs() < 0.0001, "keep keyboard stepping in fullscreen"); +} + +#[test] +fn fullscreen_status_zoom_preserves_shift_precision_and_recovers_from_the_minimum() { + let mut h = harness(ThemeKind::ProMedium); + fullscreen(&mut h, true); + let start = begin_zoom_drag(&mut h); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + let to = start + vec2(40.0, 0.0); + h.hover_at(to); + h.run_steps(1); + assert!((h.state().ui.views[0].zoom - 10.02).abs() < 0.005, "Shift reduces sensitivity by ten"); + h.drop_at(to); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + h.state_mut().ui.views[0].zoom = 0.01; + h.run_steps(2); + let start = begin_zoom_drag(&mut h); + h.hover_at(pos2(0.5, start.y)); + h.run_steps(1); + let target = cursor_warp(&h).expect("wrap"); + let minimum = crate::zoom_levels::min(h.state().ui.views[0].doc_size); + assert_eq!(h.state().ui.views[0].zoom, minimum); + h.hover_at(target); + h.run_steps(1); + let next = target - vec2(100.0, 0.0); + h.hover_at(next); + h.run_steps(1); + assert_eq!(h.state().ui.views[0].zoom, minimum); + h.hover_at(next + vec2(20.0, 0.0)); + h.run_steps(1); + assert!(h.state().ui.views[0].zoom > minimum, "reversing direction responds immediately after reaching a bound"); +} diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index 5c369370ff..fc6d0c7e78 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -337,3 +337,49 @@ fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() { assert_eq!(c.since(0), Some(r(0).union(&r(20)).union(&r(40))), "never showed the stroke: all of it"); assert_eq!(c.since(99), None, "not a key of this stroke"); } + +/// A Clone Stamp stroke through the canvas renders the points its commit gets (position and +/// pressure, no tilt or times), so the commit can take the live pixels; they are what replaying +/// the journaled command paints. +#[test] +fn releasing_a_clone_stamp_stroke_commits_what_a_replay_paints() { + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.4}})); + run("paint.stroke", json!({"points": [[10, 20], [190, 80]], "size": 30, "hardness": 0.5, "color": "#2060c0"})); + run("cloneSource.set", json!({"source": [40, 30]})); + }; + let mut h = harness(); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + h.state_mut().ui.tool = Tool::CloneStamp; + h.run_steps(1); + move_to(&mut h, 60.0, 50.0); + button(&mut h, 60.0, 50.0, true); + for (x, y) in [(80.0, 40.0), (110.0, 60.0), (140.0, 45.0), (170.0, 65.0)] { + move_to(&mut h, x, y); + } + let shown = crate::canvas::live_retouch_points(h.state()).expect("a live Clone Stamp stroke"); + button(&mut h, 170.0, 65.0, false); + h.run_steps(2); + let app = h.state(); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == "paint.cloneStamp").cloned().expect("a committed Clone Stamp stroke"); + let committed: Vec<_> = p["points"] + .as_array() + .unwrap() + .iter() + .map(|q| photocraft_engine::paint::StrokePoint::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap(), q[2].as_f64().unwrap() as f32)) + .collect(); + assert!(shown.len() > 1 && committed.starts_with(&shown), "live {shown:?} vs committed {committed:?}"); + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + fresh.execute("paint.cloneStamp", p).unwrap(); + let st = app.session.active().unwrap(); + let a = photocraft_compose::render(&st.doc, st.doc.bounds()); + let b = photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()); + assert_eq!(a.px.len(), b.px.len()); + assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "live commit and replay differ"); +} diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs index 110f401f63..8460c31ea2 100644 --- a/crates/ui-egui/src/stylus.rs +++ b/crates/ui-egui/src/stylus.rs @@ -137,6 +137,8 @@ pub struct Stylus { current: Option, /// The highest pen pressure (before the curve) since the drag began, for a tap's one dab. peak: f32, + /// The pen's touch contacts in the raw input, so a held pen is no press-and-hold right click. + pub(crate) pen_contacts: PenContacts, /// The egui frame [`Self::update_for_frame`] last ran in. updated_frame: Option, /// Tilt X, tilt Y, rotation of each point of the current drag (parallel to its points). @@ -166,6 +168,7 @@ impl Default for Stylus { prev: None, current: None, peak: 0.0, + pen_contacts: PenContacts::default(), updated_frame: None, stroke: Vec::new(), times: Vec::new(), @@ -416,10 +419,143 @@ impl Stylus { } } +/// The touch contacts a pen makes, read from the raw input. egui takes every touch contact held +/// still past `max_click_duration` (0.8 s) for a touch screen's press-and-hold: a secondary click +/// (a context menu) that also drops the drag. On Windows a pen arrives as touch events, so a pen +/// held still opened a context menu. A pen held still is just a pen, as in Photoshop. +#[derive(Clone, Debug, Default)] +pub(crate) struct PenContacts { + /// Touch ids that moved without touching: only a pen hovers. + hovering: Vec, + /// The contacts down, and whether each is a pen. + down: Vec<(egui::TouchId, bool)>, +} + +/// Most touch ids [`PenContacts`] remembers (the oldest go first). +const CONTACTS: usize = 16; + +impl PenContacts { + /// Follow a frame's touch events. True when a pen contact was down at any point of the frame. + pub(crate) fn observe(&mut self, events: &[egui::Event]) -> bool { + let pressed = |f: &Option| f.is_some_and(|f| f > 0.0); + let mut pen = self.down.iter().any(|(_, p)| *p); + for e in events { + let egui::Event::Touch { id, phase, force, .. } = e else { continue }; + match phase { + egui::TouchPhase::Start => { + let is_pen = self.hovering.contains(id) || pressed(force); + self.down.retain(|(c, _)| c != id); + if self.down.len() >= CONTACTS { + self.down.remove(0); + } + self.down.push((*id, is_pen)); + pen |= is_pen; + } + egui::TouchPhase::Move => match self.down.iter_mut().find(|(c, _)| c == id) { + Some(c) if pressed(force) => { + c.1 = true; + pen = true; + } + Some(_) => {} + None if !self.hovering.contains(id) => { + if self.hovering.len() >= CONTACTS { + self.hovering.remove(0); + } + self.hovering.push(*id); + } + None => {} + }, + egui::TouchPhase::End | egui::TouchPhase::Cancel => self.down.retain(|(c, _)| c != id), + } + } + pen + } +} + +/// Turn egui's touch press-and-hold off while a pen contact is down (and through the frame it +/// lifts in, so the release is the click a quick tap would be), back on otherwise for fingers. +pub(crate) fn pen_hold_is_no_right_click(ctx: &egui::Context, pen_down: bool) { + let want = if pen_down { f64::INFINITY } else { egui::InputOptions::default().max_click_duration }; + if ctx.options(|o| o.input_options.max_click_duration) != want { + ctx.options_mut(|o| o.input_options.max_click_duration = want); + } +} + #[cfg(test)] mod tests { use super::*; + /// Hold a touch contact still for 1.2 s over a click-sensing area, with the press-and-hold + /// handling the app does in `raw_input_hook`. Did the area see a secondary click? + fn held_contact_right_clicks(hover_first: bool, force: Option) -> bool { + let ctx = egui::Context::default(); + let mut contacts = PenContacts::default(); + let mut right_clicked = false; + let ev = |phase, force| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(3), phase, pos: egui::pos2(50.0, 50.0), force }; + // A first frame lays the area out, so the press hits it. + let mut frames = vec![(0.0, vec![])]; + if hover_first { + frames.push((0.05, vec![ev(egui::TouchPhase::Move, None), egui::Event::PointerMoved(egui::pos2(50.0, 50.0))])); + } + frames.push(( + 0.1, + vec![ + ev(egui::TouchPhase::Start, force), + egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), + egui::Event::PointerButton { + pos: egui::pos2(50.0, 50.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + )); + for t in [0.4, 0.7, 1.0, 1.3] { + frames.push((t, vec![])); + } + for (time, mut events) in frames { + // egui-winit emits the touch and its emulated pointer events together. + let mut raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0))), + ..Default::default() + }; + raw.events.append(&mut events); + let pen = contacts.observe(&raw.events); + pen_hold_is_no_right_click(&ctx, pen); + let mut out = ctx.run_ui(raw, |ui| { + let r = ui.allocate_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)), egui::Sense::click_and_drag()); + right_clicked |= r.secondary_clicked(); + }); + out.textures_delta.clear(); + } + right_clicked + } + + #[test] + fn a_held_pen_is_not_a_right_click_but_a_held_finger_still_is() { + assert!(held_contact_right_clicks(false, None), "a finger's press-and-hold is egui's context menu"); + assert!(!held_contact_right_clicks(true, None), "a pen that hovered first"); + assert!(!held_contact_right_clicks(false, Some(0.4)), "a contact that reports pressure"); + } + + #[test] + fn pen_contacts_follow_hover_force_and_lift() { + use egui::TouchPhase::{Cancel, End, Move, Start}; + let t = |id, phase, force| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos: egui::pos2(1.0, 1.0), force }; + let mut c = PenContacts::default(); + assert!(!c.observe(&[t(1, Move, None)]), "hovering is no contact"); + assert!(c.observe(&[t(1, Start, None)])); + assert!(c.observe(&[]), "still down"); + assert!(c.observe(&[t(1, End, None)]), "the lift frame still counts"); + assert!(!c.observe(&[])); + // A finger, until it reports a force. + assert!(!c.observe(&[t(9, Start, None), t(9, Move, None)])); + assert!(c.observe(&[t(9, Move, Some(0.5))])); + assert!(c.observe(&[t(9, Cancel, None)])); + assert!(!c.observe(&[])); + } + fn touch(phase: egui::TouchPhase, force: Option) -> egui::Event { egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(0), phase, pos: egui::pos2(1.0, 1.0), force } } diff --git a/crates/ui-egui/src/targa_options_ui.rs b/crates/ui-egui/src/targa_options_ui.rs index afc7f60ba2..c2161a0c9b 100644 --- a/crates/ui-egui/src/targa_options_ui.rs +++ b/crates/ui-egui/src/targa_options_ui.rs @@ -20,9 +20,10 @@ pub struct Prompt { pub bits: TgaBits, /// Save a Copy: the document keeps its path and unsaved changes. pub copy: bool, - /// File › Save As: the answered save is recorded as a `file.saveAs` step, so an action being - /// recorded keeps it (#2032). The dialog's Save a Copy isn't recorded. - pub record: bool, + /// The step the answered save is recorded as, so an action being recorded keeps it: + /// `file.saveAs` for Save As (#2032, even when a flat Targa is written as a copy) and + /// `file.saveACopy` for Save a Copy (#4373). + pub record: &'static str, } /// `true` when `path` names a Targa (`.tga`, and the rarer `.icb`, `.vda`, `.vst`). @@ -43,7 +44,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result Result<(), String> { +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { if app.save_options_open() { return Err("Answer TIFF or Targa Options before starting another save".into()); } @@ -80,9 +81,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() }; let step = serde_json::json!({"path": p.path, "tgaBits": p.bits.bits()}); let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); - if written.is_ok() && p.record { - // An action being recorded keeps the Save As with this answer (#2032). - app.session.journal.push(("file.saveAs".into(), step)); + if written.is_ok() { + // An action being recorded keeps the Save As or Save a Copy with this answer (#2032). + app.session.journal.push((p.record.into(), step)); } match written { Err(e) => { diff --git a/crates/ui-egui/src/text_field_menu.rs b/crates/ui-egui/src/text_field_menu.rs index 420263e7ef..6556c68cfc 100644 --- a/crates/ui-egui/src/text_field_menu.rs +++ b/crates/ui-egui/src/text_field_menu.rs @@ -17,9 +17,9 @@ //! caret to the pointer on any press), so Copy and Cut act on it. //! //! The right-click belongs to the field: the canvas and its tool menus only see pointer presses -//! on the canvas, which a field drawn above it takes. Spelling suggestions are not in this menu -//! yet: the spell checker (`edit.checkSpelling`, whose `suggest` action lists them) is not wired -//! into text fields. +//! on the canvas, which a field drawn above it takes. Spelling suggestions are not in this menu: +//! the spell checker (`edit.checkSpelling`) checks type layers, so they head the Type tool's edit +//! menu instead (`canvas_tool_menu`, over a misspelled word of the text being edited, #4370). use egui::text::CCursorRange; use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, SurrenderFocusOn, Ui}; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bee0104b89..eb61cfbeaf 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -48,10 +48,14 @@ pub enum ThemeKind { BreezeLight, /// An adaptation of KDE Plasma's Breeze Dark look. BreezeDark, + /// A low-contrast dark theme: muted violet-grey surfaces, softened text and a periwinkle accent. + SoftDark, + /// A low-contrast light theme: pale violet-grey surfaces, softened dark text and a periwinkle accent. + SoftLight, } impl ThemeKind { - pub const ALL: [ThemeKind; 12] = [ + pub const ALL: [ThemeKind; 14] = [ ThemeKind::Pro, ThemeKind::ProMedium, ThemeKind::ProLightGray, @@ -64,6 +68,8 @@ impl ThemeKind { ThemeKind::AdwaitaDark, ThemeKind::BreezeLight, ThemeKind::BreezeDark, + ThemeKind::SoftDark, + ThemeKind::SoftLight, ]; pub fn label(self) -> &'static str { match self { @@ -79,6 +85,8 @@ impl ThemeKind { ThemeKind::SolarizedDark => "Solarized Dark", ThemeKind::BreezeLight => "Breeze Light", ThemeKind::BreezeDark => "Breeze Dark", + ThemeKind::SoftDark => "Soft Dark", + ThemeKind::SoftLight => "Soft Light", } } /// The canonical name: `ui.set {theme}` accepts it and the Window › Theme commands are @@ -97,6 +105,8 @@ impl ThemeKind { ThemeKind::SolarizedDark => "solarizedDark", ThemeKind::BreezeLight => "breezeLight", ThemeKind::BreezeDark => "breezeDark", + ThemeKind::SoftDark => "softDark", + ThemeKind::SoftLight => "softLight", } } /// Photoshop-grammar layout (tab strips, flat panels): the four Pro brightness levels. @@ -121,6 +131,8 @@ impl ThemeKind { "solarizeddark" | "solarized" => Some(ThemeKind::SolarizedDark), "breezelight" | "breeze" | "kde" | "kdelight" | "plasma" | "plasmalight" => Some(ThemeKind::BreezeLight), "breezedark" | "kdedark" | "plasmadark" => Some(ThemeKind::BreezeDark), + "softdark" => Some(ThemeKind::SoftDark), + "softlight" => Some(ThemeKind::SoftLight), _ => None, } } @@ -602,6 +614,91 @@ impl Tokens { caption_close_text: Color32::WHITE, scrim: Color32::from_black_alpha(110), }, + // Soft Dark: our own low-contrast palette on the Studio layout. Muted violet-grey + // surfaces (never near-black), softened off-white text and a periwinkle accent; the + // text colours still hold 4.5:1 on every surface (checked by the tests). + ThemeKind::SoftDark => Tokens { + kind, + chrome: Color32::from_rgb(58, 58, 68), + canvas: Color32::from_rgb(40, 40, 48), + canvas_dot: Color32::from_rgb(40, 40, 48), + dock: Color32::from_rgb(47, 47, 56), + card: Color32::from_rgb(54, 54, 64), + card_border: Color32::from_rgb(70, 70, 82), + field: Color32::from_rgb(64, 64, 75), + field_border: Color32::from_rgb(84, 84, 98), + hover: Color32::from_rgb(68, 68, 80), + pressed: Color32::from_rgb(74, 74, 88), + text: Color32::from_rgb(238, 238, 246), + text_dim: Color32::from_rgb(212, 212, 224), + text_faint: Color32::from_rgb(190, 190, 206), + icon: Color32::from_rgb(214, 214, 226), + accent: Color32::from_rgb(138, 154, 232), + accent_soft: Color32::from_rgba_unmultiplied(138, 154, 232, 48), + accent_border: Color32::from_rgba_unmultiplied(138, 154, 232, 150), + accent_text: Color32::from_rgb(190, 200, 252), + separator: Color32::from_rgb(36, 36, 44), + shadow: Color32::from_black_alpha(110), + primary_bg: Color32::from_rgb(86, 104, 196), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(250, 160, 172), + warning: Color32::from_rgb(240, 200, 130), + radius_sm: 7.0, + radius: 10.0, + radius_lg: 14.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(40, 40, 48), + histogram_level: 220, + caption_close: Color32::from_rgb(200, 72, 86), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(100), + }, + // Soft Light: pale violet-grey surfaces, softened dark-violet text (not black) and a + // periwinkle accent, with the same generous radii as Soft Dark. + ThemeKind::SoftLight => Tokens { + kind, + chrome: Color32::from_rgb(232, 230, 236), + canvas: Color32::from_rgb(218, 216, 224), + canvas_dot: Color32::from_rgb(218, 216, 224), + dock: Color32::from_rgb(226, 224, 231), + card: Color32::from_rgb(240, 238, 243), + card_border: Color32::from_rgb(208, 205, 215), + field: Color32::from_rgb(250, 249, 252), + field_border: Color32::from_rgb(190, 187, 201), + hover: Color32::from_rgb(228, 226, 234), + pressed: Color32::from_rgb(212, 209, 221), + text: Color32::from_rgb(44, 42, 56), + text_dim: Color32::from_rgb(62, 60, 76), + text_faint: Color32::from_rgb(80, 78, 96), + icon: Color32::from_rgb(64, 62, 80), + accent: Color32::from_rgb(110, 128, 216), + accent_soft: Color32::from_rgba_unmultiplied(110, 128, 216, 44), + accent_border: Color32::from_rgba_unmultiplied(110, 128, 216, 170), + accent_text: Color32::from_rgb(46, 58, 146), + separator: Color32::from_rgb(210, 207, 218), + shadow: Color32::from_black_alpha(40), + primary_bg: Color32::from_rgb(80, 98, 190), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(166, 44, 62), + warning: Color32::from_rgb(140, 72, 0), + radius_sm: 7.0, + radius: 10.0, + radius_lg: 14.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(44, 42, 56), + histogram_level: 240, + caption_close: Color32::from_rgb(200, 60, 72), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(90), + }, // Ethan Schoonover's Solarized Dark palette: base03 canvas, base02 surfaces, base0 // body text and the Solarized blue accent. https://ethanschoonover.com/solarized/ // (MIT; see LICENSE-solarized.txt) @@ -714,7 +811,13 @@ impl Tokens { pub fn dark(&self) -> bool { matches!( self.kind, - ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::SolarizedDark | ThemeKind::AdwaitaDark | ThemeKind::BreezeDark + ThemeKind::Studio + | ThemeKind::Pro + | ThemeKind::ProMedium + | ThemeKind::SolarizedDark + | ThemeKind::AdwaitaDark + | ThemeKind::BreezeDark + | ThemeKind::SoftDark ) } } @@ -935,7 +1038,15 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { s.visuals.indent_has_left_vline = false; s.interaction.tooltip_delay = TOOLTIP_DELAY; // Thin overlay scrollbars that appear on hover (Photoshop/macOS style). - s.spacing.scroll = if t.bevel { egui::style::ScrollStyle::solid() } else { egui::style::ScrollStyle::thin() }; + // Thin bars stay visible, including while the pointer is on them. The stock thin + // style fades the handle to the same opacity as the track, so the thumb disappears (#4611). + let mut scroll = if t.bevel { egui::style::ScrollStyle::solid() } else { egui::style::ScrollStyle::thin() }; + if scroll.floating { + scroll.interact_handle_opacity = 1.0; + scroll.interact_background_opacity = 0.4; + scroll.floating_width = 4.0; + } + s.spacing.scroll = scroll; s.spacing.tooltip_width = 280.0; }); // egui selects its style branch using OS appearance, independently of PhotoCraft's fixed @@ -1168,6 +1279,42 @@ mod tests { assert_eq!(ThemeKind::from_name("gnome dark"), Some(ThemeKind::AdwaitaDark)); } + #[test] + fn soft_themes_parse_and_hold_text_contrast() { + for (kind, alias) in [(ThemeKind::SoftDark, "soft dark"), (ThemeKind::SoftLight, "Soft_Light")] { + assert_eq!(ThemeKind::from_name(kind.id()), Some(kind)); + assert_eq!(ThemeKind::from_name(kind.label()), Some(kind)); + assert_eq!(ThemeKind::from_name(alias), Some(kind)); + assert!(ThemeKind::ALL.contains(&kind)); + let t = Tokens::for_kind(kind); + assert_eq!(t.kind, kind); + assert_eq!(t.dark(), kind == ThemeKind::SoftDark); + assert!(!t.pro && !t.bevel); + // Soft means no extremes: surfaces stay off black and white. + for c in [t.chrome, t.canvas, t.dock, t.card, t.field] { + let (hi, lo) = (c.r().max(c.g()).max(c.b()), c.r().min(c.g()).min(c.b())); + assert!(if t.dark() { (32..96).contains(&hi) } else { hi < 255 && lo > 200 }, "{kind:?}: {c:?}"); + } + let tint = |under: Color32| { + let k = f32::from(t.accent_soft.a()) / 255.0; + let ch = |over: u8, under: u8| (f32::from(over) * k + f32::from(under) * (1.0 - k)).round() as u8; + Color32::from_rgb(ch(t.accent.r(), under.r()), ch(t.accent.g(), under.g()), ch(t.accent.b(), under.b())) + }; + let surfaces = [t.chrome, t.canvas, t.dock, t.card, t.field, t.hover, t.pressed, tint(t.dock), tint(t.card)]; + for (i, surface) in surfaces.into_iter().enumerate() { + for (name, colour) in [("text", t.text), ("dim", t.text_dim), ("faint", t.text_faint), ("accent text", t.accent_text)] { + assert!(contrast(colour, surface) >= 4.5, "{kind:?}: {name} on {surface:?}"); + } + // Status colours are marks on hovered or selected surfaces (3:1), text on resting ones. + let floor = if i < 5 { 4.5 } else { 3.0 }; + assert!(contrast(t.danger, surface) >= floor && contrast(t.warning, surface) >= floor, "{kind:?}: status on {surface:?}"); + } + for k in [1.0, 0.93, 0.85] { + assert!(contrast(t.primary_text, t.primary_bg.gamma_multiply(k)) >= 4.5, "{kind:?}: primary at {k}"); + } + } + } + #[test] fn breeze_themes_parse_and_meet_text_contrast() { for (kind, aliases) in [ diff --git a/crates/ui-egui/src/tile_overlay.rs b/crates/ui-egui/src/tile_overlay.rs new file mode 100644 index 0000000000..731ddc06b8 --- /dev/null +++ b/crates/ui-egui/src/tile_overlay.rs @@ -0,0 +1,301 @@ +//! The developer tile grid: the active layer's storage tiles over the canvas, turned on with the +//! control channel's `ui.set {"tileGrid": true}` ([`crate::view_cmds::ViewOptions::tile_grid`]). +//! It shows how sparse a layer is, which tiles copy-on-write shares, and which ones `.pcraft` +//! saves once. +//! +//! A layer's pixels live in a sparse [`Surface`] of [`TILE_SIZE`] tiles; cells with no stored +//! tile read as the surface's default pixel and cost nothing. The overlay draws the tile grid +//! faintly over the document, fills every stored tile, and labels it `tx,ty` once it is large +//! enough on screen. Tiles with identical pixels share a hue; a tile whose pixels appear nowhere +//! else is plain blue. +//! +//! Grouping reads every stored tile, so it is cached per document revision and active layer, +//! never redone per frame (AGENTS.md rule 8). Tile identity can't stand in for the revision: a +//! tile nothing else shares is written in place. Each allocation is hashed once, and tiles are +//! grouped by that 64-bit hash of their bytes. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Painter, Shape, Stroke}; +use photocraft_geom::{Rect as DRect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; +#[cfg(not(target_arch = "wasm32"))] +use rayon::prelude::*; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; + +/// Outline and fill of a tile whose pixels appear nowhere else in the layer. +const UNIQUE: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); +/// Alpha of a stored tile's fill; low so the image stays readable underneath. +const FILL_ALPHA: u8 = 40; +/// The grid over cells with no stored tile: a translucent mid grey, seen on light and dark pixels. +const EMPTY_LINE: Color32 = Color32::from_rgba_premultiplied(32, 32, 32, 64); +/// Tile labels show once a tile is at least this many points across. +const LABEL_MIN: f32 = 56.0; + +/// One stored tile and the group of tiles it shares its pixels with (`None` = unique). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TileInfo { + pub coord: TileCoord, + pub group: Option, +} + +/// The tiles of one layer, valid for one document revision. +#[derive(Clone)] +struct Cached { + revision: u64, + tiles: Arc>, +} + +/// A tile's allocation as a plain address, so it can cross to the Rayon pool. +fn ptr(t: &Arc) -> usize { + Arc::as_ptr(t) as usize +} + +fn content_hash(t: &Tile) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + t.bytes().hash(&mut h); + h.finish() +} + +/// Every stored tile of `s` with its shared-pixels group. Groups are numbered in tile order, so +/// the same surface always gets the same colours. `parallel` hashes on the Rayon pool; pass +/// false while jobs hold it, so the UI thread never waits on them (#1554). +pub fn tile_infos(s: &Surface, parallel: bool) -> Vec { + // Hash each distinct allocation once; copy-on-write clones share one. + let mut by_ptr: HashMap> = HashMap::new(); + for (_, t) in s.tiles() { + by_ptr.entry(ptr(t)).or_insert(t); + } + let distinct: Vec<(usize, &Arc)> = by_ptr.into_iter().collect(); + // rayon is a native-only dependency; the web build hashes serially. + #[cfg(not(target_arch = "wasm32"))] + let hashed: Vec<(usize, u64)> = if parallel { + distinct.par_iter().map(|(p, t)| (*p, content_hash(t))).collect() + } else { + distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() + }; + #[cfg(target_arch = "wasm32")] + let hashed: Vec<(usize, u64)> = { + let _ = parallel; + distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() + }; + let hash_of: HashMap = hashed.into_iter().collect(); + let mut count: HashMap = HashMap::new(); + for (_, t) in s.tiles() { + if let Some(h) = hash_of.get(&ptr(t)) { + *count.entry(*h).or_default() += 1; + } + } + let mut group_of: HashMap = HashMap::new(); + s.tiles() + .map(|(c, t)| { + let group = hash_of.get(&ptr(t)).filter(|h| count.get(h).is_some_and(|&n| n > 1)).map(|h| { + let next = group_of.len(); + *group_of.entry(*h).or_insert(next) + }); + TileInfo { coord: *c, group } + }) + .collect() +} + +/// Hues (0..1) left to unique tiles: shared groups never land in the blues, nor in the +/// blue-violets that read as blue next to them ([`UNIQUE`] is about 0.58). +const BLUE_HUES: (f32, f32) = (0.45, 0.75); +/// Gap kept from either end of the allowed range, as a share of it. +const HUE_MARGIN: f32 = 0.05; + +/// The hue of shared group `g`: golden-ratio steps keep neighbouring group numbers far apart, +/// spread over the wheel outside [`BLUE_HUES`] and clear of its edges. +fn group_hue(g: usize) -> f32 { + let step = (g as f32 * 0.618_034).fract(); + let span = 1.0 - (BLUE_HUES.1 - BLUE_HUES.0); + (BLUE_HUES.1 + (HUE_MARGIN + step * (1.0 - 2.0 * HUE_MARGIN)) * span).fract() +} + +/// The colour of a tile group: blue for unique tiles, an evenly spread hue per shared group. +pub fn group_color(group: Option) -> Color32 { + let Some(g) = group else { return UNIQUE }; + egui::ecolor::Hsva::new(group_hue(g), 0.85, 0.95, 1.0).into() +} + +/// The tile cells of the grid that touch `r`, as an inclusive (tx0, ty0, tx1, ty1) range. +pub fn cell_range(r: DRect) -> Option<(i32, i32, i32, i32)> { + if r.is_empty() { + return None; + } + let (a, b) = (TileCoord::containing(r.x0, r.y0), TileCoord::containing(r.x1 - 1, r.y1 - 1)); + Some((a.tx, a.ty, b.tx, b.ty)) +} + +fn quad(xf: &ViewXform, r: DRect) -> [egui::Pos2; 4] { + let (x0, y0, x1, y1) = (r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32); + [xf.to_screen(x0, y0), xf.to_screen(x1, y0), xf.to_screen(x1, y1), xf.to_screen(x0, y1)] +} + +/// Draw the active layer's tiles of document `idx`. `visible` is the document area in view. +pub fn paint(app: &PhotocraftApp, ctx: &egui::Context, painter: &Painter, xf: &ViewXform, idx: usize, visible: DRect) { + let Some(st) = app.session.documents().get(idx) else { return }; + let Some(surface) = st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()) else { return }; + let revision = st.revision; + let mem = egui::Id::new(("pc-tile-overlay", st.doc.id.0, st.active_layer.map(|l| l.0))); + let cached = ctx.data(|d| d.get_temp::(mem)).filter(|c| c.revision == revision); + let tiles = match cached { + Some(c) => c.tiles, + None => { + let tiles = Arc::new(tile_infos(surface, !app.session.has_jobs())); + ctx.data_mut(|d| d.insert_temp(mem, Cached { revision, tiles: tiles.clone() })); + tiles + } + }; + + // The grid over the document, so the cells without a stored tile show too. + let bounds = st.doc.bounds(); + if let Some((tx0, ty0, tx1, ty1)) = cell_range(visible.intersect(&bounds)) { + let line = Stroke::new(1.0, EMPTY_LINE); + let edge = |t: i32| t.saturating_mul(TILE_SIZE); + let (top, bottom) = (bounds.y0.max(edge(ty0)) as f32, bounds.y1.min(edge(ty1.saturating_add(1))) as f32); + let (left, right) = (bounds.x0.max(edge(tx0)) as f32, bounds.x1.min(edge(tx1.saturating_add(1))) as f32); + for tx in tx0..=tx1.saturating_add(1) { + let x = edge(tx).clamp(bounds.x0, bounds.x1) as f32; + painter.line_segment([xf.to_screen(x, top), xf.to_screen(x, bottom)], line); + } + for ty in ty0..=ty1.saturating_add(1) { + let y = edge(ty).clamp(bounds.y0, bounds.y1) as f32; + painter.line_segment([xf.to_screen(left, y), xf.to_screen(right, y)], line); + } + } + + // Stored tiles, which may lie past the canvas edge. + let size = TILE_SIZE as f32 * xf.zoom; + for t in tiles.iter() { + let r = t.coord.rect(); + if r.intersect(&visible).is_empty() { + continue; + } + let c = group_color(t.group); + let pts = quad(xf, r).to_vec(); + let fill = Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), FILL_ALPHA); + painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE)); + painter.add(Shape::closed_line(pts, Stroke::new(1.0, c))); + if size >= LABEL_MIN { + let mid = xf.to_screen(r.x0 as f32 + TILE_SIZE as f32 / 2.0, r.y0 as f32 + TILE_SIZE as f32 / 2.0); + let label = format!("{},{}", t.coord.tx, t.coord.ty); + let font = FontId::monospace((size / 8.0).clamp(10.0, 16.0)); + // A dark drop shadow keeps the label readable over light pixels. + painter.text(mid + egui::vec2(1.0, 1.0), Align2::CENTER_CENTER, &label, font.clone(), Color32::from_black_alpha(180)); + painter.text(mid, Align2::CENTER_CENTER, label, font, Color32::WHITE); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::PixelFormat; + + /// The document rect of tile cell (tx, ty). + fn cell(tx: i32, ty: i32) -> DRect { + TileCoord::new(tx, ty).rect() + } + + #[test] + fn empty_surface_has_no_tiles() { + assert!(tile_infos(&Surface::new(PixelFormat::RGBA8), true).is_empty()); + } + + #[test] + fn identical_pixels_share_a_group_and_unique_tiles_have_none() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + identical_pixels_share_a_group_in(fmt); + } + } + + fn identical_pixels_share_a_group_in(fmt: PixelFormat) { + let mut s = Surface::new(fmt); + // Two separately allocated tiles with the same red pixels, as after loading a .pcraft. + s.fill_rect(cell(0, 0), &[1.0, 0.0, 0.0, 1.0]); + s.fill_rect(cell(2, 0), &[1.0, 0.0, 0.0, 1.0]); + // One shared allocation in two cells (copy-on-write), and a lone green tile. + let blue = s.solid_tile(&[0.0, 0.0, 1.0, 1.0]); + s.put_tiles([(TileCoord::new(0, 1), blue.clone()), (TileCoord::new(1, 1), blue)]); + s.fill_rect(cell(1, 0), &[0.0, 1.0, 0.0, 1.0]); + for parallel in [true, false] { + let infos = tile_infos(&s, parallel); + let group = |tx, ty| infos.iter().find(|i| i.coord == TileCoord::new(tx, ty)).map(|i| i.group); + assert_eq!(infos.len(), 5); + assert_eq!(group(1, 0), Some(None), "green is unique"); + let (red, blue) = (group(0, 0).flatten(), group(0, 1).flatten()); + assert!(red.is_some() && blue.is_some() && red != blue); + assert_eq!(group(2, 0).flatten(), red); + assert_eq!(group(1, 1).flatten(), blue); + } + } + + #[test] + fn groups_get_distinct_colours_and_unique_tiles_stay_blue() { + assert_eq!(group_color(None), UNIQUE); + let colours: Vec = (0..6).map(|g| group_color(Some(g))).collect(); + for (i, a) in colours.iter().enumerate() { + assert_ne!(*a, UNIQUE); + assert!(colours.iter().skip(i + 1).all(|b| b != a)); + } + // Huge group numbers still map to a colour. + let _ = group_color(Some(usize::MAX)); + } + + #[test] + fn shared_groups_never_take_the_blue_of_unique_tiles() { + for g in (0..10_000).chain([usize::MAX]) { + let h = group_hue(g); + assert!((0.0..1.0).contains(&h), "group {g}: hue {h}"); + assert!(!(BLUE_HUES.0..=BLUE_HUES.1).contains(&h), "group {g}: hue {h} is blue"); + // Clear of the range's edges too, where hues still read as blue. + assert!((h - BLUE_HUES.1).abs() > 0.01 && (h - BLUE_HUES.0).abs() > 0.01, "group {g}: hue {h} is at the edge"); + } + } + + /// `cargo test --release -p photocraft-ui-egui --lib tile_grouping_36mp -- --ignored --nocapture` + #[test] + #[ignore = "benchmark"] + fn tile_grouping_36mp() { + let (w, h) = (7360, 4912); + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + // Every tile different, the worst case: each one is hashed and none share. + let mut s = Surface::new(fmt); + let Some((tx1, ty1)) = cell_range(DRect::from_xywh(0, 0, w, h)).map(|(_, _, tx1, ty1)| (tx1, ty1)) else { return }; + for ty in 0..=ty1 { + for tx in 0..=tx1 { + let seed = (tx * 31 + ty * 17) as usize; + for (i, b) in s.tile_mut(TileCoord::new(tx, ty)).bytes_mut().iter_mut().enumerate() { + *b = (i ^ seed) as u8; + } + } + } + for parallel in [true, false] { + let mut best = f64::MAX; + for _ in 0..8 { + let t = std::time::Instant::now(); + std::hint::black_box(tile_infos(&s, parallel)); + best = best.min(t.elapsed().as_secs_f64() * 1000.0); + } + let mode = if parallel { "parallel" } else { "serial" }; + println!("36 MP {fmt:?}, {} tiles, {mode}: {best:.1} ms", s.tile_count()); + } + } + } + + #[test] + fn cell_range_covers_the_cells_a_rect_touches() { + let t = TILE_SIZE; + // Exactly one cell, then a partial cell past whole ones (a canvas not a multiple of it). + assert_eq!(cell_range(cell(0, 0)), Some((0, 0, 0, 0))); + assert_eq!(cell_range(DRect::new(0, 0, 7 * t + t / 2, 4 * t + 1)), Some((0, 0, 7, 4))); + // One pixel either side of a cell edge, including left of the origin. + assert_eq!(cell_range(DRect::new(-1, t - 1, t + 1, t + 4)), Some((-1, 0, 1, 1))); + assert_eq!(cell_range(DRect::EMPTY), None); + } +} diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index d02536aeef..db1963f137 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -1,35 +1,217 @@ //! The window's own title bar on Windows and Linux, where the window has no OS decorations //! ([`PhotocraftApp::custom_titlebar`]): the app's top bar (`panels::title_bar`) is the title bar, //! as in Photoshop on Windows. The free gap between its menus and controls drags the window and a -//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons at -//! the far right minimize, maximize/restore and close, and thin invisible zones along the window's -//! edges resize it. +//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons +//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize +//! it. +//! +//! The caption buttons follow the desktop ([`CaptionStyle`], from `Services::caption_style`): +//! Windows' 46 pt buttons at the right end by default; on Linux libadwaita's round buttons (GNOME), +//! Breeze's (KDE) or a plain round set elsewhere, with the set of buttons and their side taken +//! from the desktop's button layout. The shapes are drawn here, not read from the icon theme. -use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use serde_json::json; use crate::PhotocraftApp; use crate::theme::Tokens; -/// One caption button: as wide as the native Windows ones. +/// One Windows caption button: as wide as the native ones. pub const BUTTON_WIDTH: f32 = 46.0; -/// The three caption buttons together. -pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH; /// Resize zone thickness along an edge, and the side of a corner zone. 5 pt was hard to find /// (#1581); 8 pt is about a native resize border, and only trims the toolbar buttons' outer 3 pt. const EDGE: f32 = 8.0; const CORNER: f32 = 12.0; +/// Breeze's Close button on hover and press (KDE's Breeze decoration colours). +const BREEZE_CLOSE_HOVER: Color32 = Color32::from_rgb(0xff, 0x98, 0xa2); +const BREEZE_CLOSE_PRESSED: Color32 = Color32::from_rgb(0x6d, 0x22, 0x29); -#[derive(Clone, Copy, PartialEq, Debug)] -enum Caption { +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Caption { Minimize, Maximize, Close, } -/// Left to right. +/// Windows' order, left to right. const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close]; +/// Which desktop's caption buttons to look like. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum CaptionFamily { + /// Windows 10/11: full-height buttons 46 pt wide; Close turns red on hover. + #[default] + Windows, + /// GNOME (libadwaita): 24 pt round buttons on a faint disc that darkens on hover and press. + Adwaita, + /// KDE (Breeze decoration): smaller round buttons, bare at rest, inverted on hover. + Breeze, + /// Other Linux desktops: libadwaita's buttons without the disc at rest. + Generic, +} + +impl CaptionFamily { + /// `windows`, `adwaita`, `breeze` or `generic`, in any case. + pub fn parse(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "windows" => Some(Self::Windows), + "adwaita" => Some(Self::Adwaita), + "breeze" => Some(Self::Breeze), + "generic" => Some(Self::Generic), + _ => None, + } + } + + /// The button side, gap on each side of it, and gap between the outermost button and the + /// window's edge. libadwaita: 24 pt buttons 13 pt apart, 10 pt from the edge (the gap is split + /// between neighbours, so the hit areas touch). Breeze sizes buttons by the font (about 1.3 em, + /// 18 pt at KDE's default 10 pt font), half a button apart, 8 pt from the edge. + fn metrics(self) -> (f32, f32, f32) { + match self { + Self::Windows => (BUTTON_WIDTH, 0.0, 0.0), + Self::Adwaita | Self::Generic => (24.0, 6.5, 10.0), + Self::Breeze => (18.0, 4.5, 8.0), + } + } +} + +/// The caption buttons' look and placement. +#[derive(Clone, PartialEq, Debug)] +pub struct CaptionStyle { + pub family: CaptionFamily, + /// The buttons at the bar's left end, left to right. + pub left: Vec, + /// The buttons at the bar's right end, left to right. + pub right: Vec, + /// The round buttons' glyphs from the desktop's icon theme; `None`, or an icon it lacks, draws + /// the family's own. + pub icons: Option, +} + +/// A caption button glyph, as the icon themes name them (`window-restore-symbolic`, …). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum CaptionIcon { + Minimize, + Maximize, + Restore, + Close, +} + +/// Renders an icon as a square coverage mask (`side` × `side` bytes, row by row), or `None`. +pub type RenderIconFn = dyn Fn(CaptionIcon, u32) -> Option> + Send + Sync; + +/// Glyphs from an icon theme (Linux: `window-*-symbolic`), drawn in the button's ink colour as +/// symbolic icons are. +#[derive(Clone)] +pub struct CaptionIcons { + /// The theme's name; the cached textures follow it. + pub theme: String, + pub render: std::sync::Arc, +} + +impl PartialEq for CaptionIcons { + fn eq(&self, other: &Self) -> bool { + self.theme == other.theme && std::sync::Arc::ptr_eq(&self.render, &other.render) + } +} + +impl std::fmt::Debug for CaptionIcons { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CaptionIcons").field("theme", &self.theme).finish_non_exhaustive() + } +} + +impl Default for CaptionStyle { + fn default() -> Self { + Self { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None } + } +} + +impl CaptionStyle { + /// `family` with the buttons a GTK decoration layout names (`gtk-decoration-layout`, GNOME's + /// `button-layout`, e.g. `appmenu:minimize,maximize,close`): those before the colon go left, + /// those after it right; without a colon all go left, as in GTK. Names other than `minimize`, + /// `maximize` and `close` (`appmenu`, `icon`, `spacer`, …) are skipped, and a button named + /// twice is placed once. + pub fn from_gtk_layout(family: CaptionFamily, layout: &str) -> Self { + let (left, right) = layout.split_once(':').unwrap_or((layout, "")); + let mut seen = vec![]; + let mut side = |names: &str| { + let mut v = vec![]; + for name in names.split(',') { + let c = match name.trim() { + "minimize" => Caption::Minimize, + "maximize" => Caption::Maximize, + "close" => Caption::Close, + _ => continue, + }; + if !seen.contains(&c) { + seen.push(c); + v.push(c); + } + } + v + }; + let left = side(left); + let right = side(right); + Self { family, left, right, icons: None } + } +} + +/// The caption style the platform reports, or Windows' when it reports none. +fn style(app: &PhotocraftApp, ctx: &egui::Context) -> CaptionStyle { + app.services.caption_style.as_ref().map(|read| read(ctx)).unwrap_or_default() +} + +/// The title bar's height: `base`, or with GNOME's buttons at least libadwaita's header bar +/// (46 pt, plus the bar's 1 pt separator), so the bar is as tall as its neighbours' title bars. +pub fn bar_height(app: &PhotocraftApp, ctx: &egui::Context, base: f32) -> f32 { + if app.custom_titlebar && style(app, ctx).family == CaptionFamily::Adwaita { base.max(47.0) } else { base } +} + +/// The width the caption buttons take at the bar's (left, right) end: zero for a side without +/// buttons, and for both without the custom title bar. +pub fn reserved(app: &PhotocraftApp, ctx: &egui::Context) -> (f32, f32) { + if !app.custom_titlebar { + return (0.0, 0.0); + } + let s = style(app, ctx); + let width = |side: &[Caption]| slots(s.family, side, 0.0, true).last().map_or(0.0, |(_, r, _)| r.1); + (width(&s.left), width(&s.right)) +} + +/// Each button's horizontal extent (left, right) and face centre on a side starting at `from`, in +/// order. `outer_left` puts the edge gap on the first button's left (the left end) instead of the +/// last button's right. +fn slots(family: CaptionFamily, side: &[Caption], from: f32, outer_left: bool) -> Vec<(Caption, (f32, f32), f32)> { + let (size, gap, end) = family.metrics(); + let mut x = from; + let n = side.len(); + side.iter() + .enumerate() + .map(|(i, &c)| { + let before = if outer_left && i == 0 { end } else { gap }; + let after = if !outer_left && i + 1 == n { end } else { gap }; + let slot = (x, x + before + size + after); + let centre = x + before + size / 2.0; + x = slot.1; + (c, slot, centre) + }) + .collect() +} + +/// Every caption button's hit rect (full bar height, flush with the window's edge) and face +/// centre, for a bar `bar` between the window's `left` and `right` edges. +fn layout(s: &CaptionStyle, bar: Rect, left: f32, right: f32) -> Vec<(Caption, Rect, Pos2)> { + let rect = |(c, (l, r), centre): (Caption, (f32, f32), f32)| { + (c, Rect::from_min_max(pos2(l, bar.top()), pos2(r, bar.bottom() - 1.0)), pos2(centre, bar.center().y)) + }; + let lefts = slots(s.family, &s.left, left, true); + let width = slots(s.family, &s.right, 0.0, false).last().map_or(0.0, |(_, r, _)| r.1); + let rights = slots(s.family, &s.right, right - width, false); + lefts.into_iter().chain(rights).map(rect).collect() +} + /// Caption button ids (stable, so tests and agents can find them). fn caption_id(c: Caption) -> Id { Id::new(("titlebar-caption", c as u8)) @@ -43,29 +225,38 @@ fn toggle_maximize(ctx: &egui::Context) { ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx))); } -/// Minimize, Maximize/Restore and Close at the right end of `bar` (the title bar's outer rect), -/// full bar height, flush with the window's edge (even if the bar's contents run wider) so a -/// maximized window's corner hits Close. Close runs File › Exit, which asks about unsaved -/// documents first. +/// The caption buttons at the ends of `bar` (the title bar's outer rect), full bar height, flush +/// with the window's edges (even if the bar's contents run wider) so a maximized window's corner +/// hits the outermost one. Close runs File › Exit, which asks about unsaved documents first. On +/// Linux the buttons drag the window too, as GTK's and KDE's do. pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { let t = Tokens::get(ui.ctx()); + let s = style(app, ui.ctx()); let max = maximized(ui.ctx()); - let right = bar.right().min(ui.ctx().content_rect().right()); + let content = ui.ctx().content_rect(); + let windows = s.family == CaptionFamily::Windows; let mut clicked = None; - for (i, c) in CAPTIONS.into_iter().enumerate() { - let r = Rect::from_min_size(pos2(right - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0)); - let resp = ui.interact(r, caption_id(c), Sense::click()); - let close = c == Caption::Close; - let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) { - (true, _) if close => t.caption_close.gamma_multiply(0.8), - (_, true) if close => t.caption_close, - (true, _) => t.pressed, - (_, true) => t.hover, - _ => egui::Color32::TRANSPARENT, - }; - ui.painter().rect_filled(r, 0.0, fill); - let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon }; - paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink)); + for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) { + let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() }); + if resp.drag_started_by(PointerButton::Primary) { + ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); + } + let pressed = resp.is_pointer_button_down_on() && resp.hovered(); + if windows { + let close = c == Caption::Close; + let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) { + (true, _) if close => t.caption_close.gamma_multiply(0.8), + (_, true) if close => t.caption_close, + (true, _) => t.pressed, + (_, true) => t.hover, + _ => Color32::TRANSPARENT, + }; + ui.painter().rect_filled(r, 0.0, fill); + let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon }; + paint_windows_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink)); + } else { + paint_round(ui, &t, &s, c, max, centre, resp.hovered(), pressed); + } let tip = match c { Caption::Minimize => tl!("Minimize"), Caption::Maximize if max => tl!("Restore"), @@ -88,8 +279,8 @@ pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { } } -/// The 10 pt line glyphs of the caption buttons. -fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { +/// The 10 pt line glyphs of the Windows caption buttons. +fn paint_windows_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { let p = ui.painter(); let g = Rect::from_center_size(at, vec2(10.0, 10.0)); match c { @@ -112,6 +303,119 @@ fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) } } +/// A round Linux caption button centred at `centre`: its disc, then its glyph on a 16 pt grid. +/// The disc is the icon colour at a fraction of its opacity (libadwaita's `alpha(currentColor)`), +/// so it suits light and dark themes alike; Breeze instead fills it with the icon colour on hover +/// and draws the glyph in the bar's colour. +#[allow(clippy::too_many_arguments)] +fn paint_round(ui: &Ui, t: &Tokens, s: &CaptionStyle, c: Caption, maximized: bool, centre: Pos2, hovered: bool, pressed: bool) { + let family = s.family; + let (size, _, _) = family.metrics(); + // Whole pixels, so the glyphs' edges stay sharp. + let ppp = ui.ctx().pixels_per_point(); + let centre = pos2((centre.x * ppp).round() / ppp, (centre.y * ppp).round() / ppp); + let close = c == Caption::Close; + let (fill, ink) = if family == CaptionFamily::Breeze { + match (hovered, pressed) { + (_, true) if close => (BREEZE_CLOSE_PRESSED, t.chrome), + (true, _) if close => (BREEZE_CLOSE_HOVER, t.chrome), + (_, true) => (t.icon.gamma_multiply(0.5), t.chrome), + (true, _) => (t.icon, t.chrome), + _ => (Color32::TRANSPARENT, t.icon), + } + } else { + let rest = if family == CaptionFamily::Adwaita { 0.10 } else { 0.0 }; + let target = if pressed { + 0.30 + } else if hovered { + 0.15 + } else { + rest + }; + // libadwaita eases the disc over 0.2 s. + let a = ui.ctx().animate_value_with_time(caption_id(c).with("disc"), target, 0.2); + (t.icon.gamma_multiply(a), t.icon) + }; + let p = ui.painter(); + if fill.a() > 0 { + p.circle_filled(centre, size / 2.0, fill); + } + // The glyph grid: 16 pt, or 2 pt less than a smaller button. + let k = (size - 2.0).min(16.0) / 16.0; + let icon = match c { + Caption::Minimize => CaptionIcon::Minimize, + Caption::Maximize if maximized => CaptionIcon::Restore, + Caption::Maximize => CaptionIcon::Maximize, + Caption::Close => CaptionIcon::Close, + }; + if let Some(icons) = &s.icons + && let Some(tex) = theme_icon(ui.ctx(), icons, icon, (16.0 * k * ppp).round() as u32) + { + let side = tex.size_vec2() / ppp; + let min = centre - side / 2.0; + let rect = Rect::from_min_size(pos2((min.x * ppp).round() / ppp, (min.y * ppp).round() / ppp), side); + p.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), ink); + return; + } + let at = |x: f32, y: f32| centre + vec2(x - 8.0, y - 8.0) * k; + if family == CaptionFamily::Breeze { + // Breeze: 1 pt chevrons (down to minimize, up to maximize), a diamond to restore, an X. + let s = Stroke::new(1.0, ink); + match c { + Caption::Minimize => { + p.line(vec![at(2.5, 5.5), at(8.0, 11.0), at(13.5, 5.5)], s); + } + Caption::Maximize if maximized => { + p.add(egui::Shape::closed_line(vec![at(8.0, 3.0), at(13.0, 8.0), at(8.0, 13.0), at(3.0, 8.0)], s)); + } + Caption::Maximize => { + p.line(vec![at(2.5, 10.5), at(8.0, 5.0), at(13.5, 10.5)], s); + } + Caption::Close => { + let s = Stroke::new(1.2, ink); + p.line_segment([at(3.6, 3.6), at(12.4, 12.4)], s); + p.line_segment([at(12.4, 3.6), at(3.6, 12.4)], s); + } + } + } else { + // libadwaita: a 2 pt bar low down, a 2 pt square outline (smaller to restore), an X. + let frame = |a: f32, b: f32| Rect::from_min_max(at(a, a), at(b, b)); + match c { + Caption::Minimize => { + p.rect_filled(Rect::from_min_max(at(4.0, 10.0), at(12.0, 12.0)), 0.0, ink); + } + Caption::Maximize => { + let square = if maximized { frame(5.0, 11.0) } else { frame(4.0, 12.0) }; + p.rect_stroke(square, 0.0, Stroke::new(2.0 * k, ink), egui::StrokeKind::Inside); + } + Caption::Close => { + let s = Stroke::new(1.5 * k, ink); + p.line_segment([at(4.5, 4.5), at(11.5, 11.5)], s); + p.line_segment([at(11.5, 4.5), at(4.5, 11.5)], s); + } + } + } +} + +/// `icon` from the theme at `px` device pixels, uploaded once per theme and size; `None` (also +/// remembered) when the theme lacks it or it doesn't render. +fn theme_icon(ctx: &egui::Context, icons: &CaptionIcons, icon: CaptionIcon, px: u32) -> Option { + let id = Id::new(("caption-icon", &icons.theme, icon, px)); + if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { + return cached; + } + let side = usize::try_from(px).ok().filter(|&s| (1..=256).contains(&s)); + let tex = side.and_then(|side| { + let mask = (icons.render)(icon, px).filter(|m| m.len() == side * side)?; + // White at the mask's coverage, tinted with the ink when drawn. + let pixels = mask.into_iter().map(Color32::from_white_alpha).collect(); + let image = egui::ColorImage { size: [side, side], source_size: vec2(px as f32, px as f32), pixels }; + Some(ctx.load_texture(format!("caption-{icon:?}-{px}"), image, egui::TextureOptions::LINEAR)) + }); + ctx.data_mut(|d| d.insert_temp(id, tex.clone())); + tex +} + /// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners /// come last so they win where they overlap the edges. Edges use the one-sided cursors /// (`w-resize`, …), as GTK and KDE window borders do: some cursor themes ship no `ew-resize` / @@ -465,4 +769,100 @@ mod tests { assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); assert!(w.click(pos2(999.5, 0.5)).iter().any(|c| matches!(c, ViewportCommand::Close))); } + + #[test] + fn gtk_layouts_pick_the_buttons_and_their_sides() { + use Caption::*; + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:minimize,maximize,close"); + assert_eq!((s.left, s.right), (vec![], vec![Minimize, Maximize, Close])); + // Unknown names are skipped, spaces trimmed, a repeat placed once, a second colon is a name. + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "close,close , minimize:icon,maximize:close"); + assert_eq!((s.left, s.right), (vec![Close, Minimize], vec![])); + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, "minimize,close"); + assert_eq!((s.left, s.right), (vec![Minimize, Close], vec![]), "no colon: GTK puts them all at the start"); + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, ""); + assert!(s.left.is_empty() && s.right.is_empty()); + assert_eq!(CaptionFamily::parse(" Breeze "), Some(CaptionFamily::Breeze)); + assert_eq!(CaptionFamily::parse("aqua"), None); + assert_eq!(CaptionStyle::default(), CaptionStyle { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None }); + } + + #[test] + fn linux_buttons_sit_where_the_layout_says_and_drag_the_window() { + use Caption::*; + for (family, size, gap, end) in [(CaptionFamily::Adwaita, 24.0, 6.5, 10.0), (CaptionFamily::Breeze, 18.0, 4.5, 8.0)] { + let width = 1100.0; + let mut w = Win::new(width); + w.app.services.caption_style = Some(Box::new(move |_| CaptionStyle { family, left: vec![Close], right: vec![Minimize, Maximize], icons: None })); + w.frame(vec![]); + w.frame(vec![]); + let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); + // Flush with the window's edges, the edge gap outermost, the hit areas touching. + assert_eq!((close.left(), close.width()), (0.0, end + size + gap), "{family:?}"); + assert_eq!((max.right(), max.width()), (width, gap + size + end), "{family:?}"); + assert_eq!((min.right(), min.width()), (max.left(), 2.0 * gap + size), "{family:?}"); + // GNOME's header bars are taller than Photoshop's bar; KDE's are not. + if family == CaptionFamily::Adwaita { + assert_eq!(close.height(), 46.0); + } else { + assert!((31.0..46.0).contains(&close.height()), "{close:?}"); + } + let mark = crate::brand::mark_rect(&w.ctx).expect("the brand mark is painted"); + assert!(mark.left() > close.right(), "the bar starts after the left buttons: {mark:?} {close:?}"); + for a in w.bar_widgets() { + assert!(a.left() >= close.right() && a.right() <= min.left(), "{a:?} runs into the caption buttons ({family:?})"); + } + assert!(w.click(max.center()).contains(&ViewportCommand::Maximized(true))); + assert!(w.click(close.center()).iter().any(|c| matches!(c, ViewportCommand::Close))); + // A drag that starts on a button moves the window instead of pressing it. + let at = min.center(); + let cmds = w.gesture(&[at, at + vec2(12.0, 4.0), at + vec2(30.0, 8.0)]); + assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); + assert!(!cmds.contains(&ViewportCommand::Minimized(true)), "{cmds:?}"); + } + } + + /// A side without buttons reserves nothing; without the custom title bar, neither does any. + #[test] + fn only_sides_with_buttons_take_room() { + let mut w = Win::new(1000.0); + w.app.services.caption_style = Some(Box::new(|_| CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:close"))); + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 6.5 + 24.0 + 10.0)); + w.app.custom_titlebar = false; + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 0.0)); + w.app.custom_titlebar = true; + w.app.services.caption_style = None; + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 3.0 * BUTTON_WIDTH)); + } + + /// Theme glyphs upload once per theme and size; an icon the theme lacks, a wrong-sized mask or + /// an absurd size gives `None`, so the button draws its own glyph. + #[test] + fn theme_icons_are_cached_and_fall_back() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&calls); + let render = move |icon: CaptionIcon, px: u32| { + counter.fetch_add(1, Ordering::Relaxed); + match icon { + CaptionIcon::Close => Some(vec![255; (px * px) as usize]), + CaptionIcon::Minimize => Some(vec![255; 3]), + _ => None, + } + }; + let icons = CaptionIcons { theme: "Test".into(), render: std::sync::Arc::new(render) }; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(RawInput::default(), |ui| { + let tex = theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).expect("rendered"); + assert_eq!(tex.size(), [16, 16]); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).is_some()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Minimize, 16).is_none(), "a mask of the wrong size"); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 0).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 100_000).is_none()); + }); + out.textures_delta.clear(); + assert_eq!(calls.load(Ordering::Relaxed), 3, "each icon and size rendered once; absurd sizes never"); + } } diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index da78f1160b..20ce1b8396 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -11,12 +11,14 @@ use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; mod hand; -/// The largest cursor bitmap, in physical pixels; a larger tip paints its outline instead (with a -/// small OS crosshair at the hotspot). Windows takes winit's limit. Elsewhere, X servers and -/// hardware cursor planes commonly cap cursors at 256 px and fall back to slow software cursors -/// (or reject them) past that, so stay within it there (#968 used 128). Either way it keeps -/// allocations bounded for hostile brush sizes or zooms. -const MAX_SIDE: u16 = if cfg!(target_os = "windows") { 2048 } else { 255 }; +/// The largest cursor bitmap (physical pixels; points on macOS, see [`native_scale`]); a larger tip +/// paints its outline instead, with a small OS crosshair at the hotspot, and that outline trails +/// the pointer by a frame or two (26 ms on a 120 Hz Mac, #1343). Windows takes winit's limit, and +/// so does macOS: AppKit sets no size limit on an `NSCursor` image. On Linux, X servers and +/// hardware cursor planes commonly cap cursors at 256 px and fall back to slow software cursors (or +/// reject them) past that, so stay within it there (#968 used 128). Either way it keeps allocations +/// bounded for hostile brush sizes or zooms. +const MAX_SIDE: u16 = if cfg!(any(target_os = "windows", target_os = "macos")) { 2048 } else { 255 }; /// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags. /// `Outline` is a brush tip outline: closed rings of logical-point offsets from the hotspot. @@ -75,6 +77,16 @@ pub(crate) fn outline(painter: &Painter, at: Pos2, rings: Vec>, centre show(painter, at, Shape::Outline { rings: rings.into(), centre }) } +/// The Pen's pointer over the anchor a click would close the path on (#4596): the centre cross +/// with a small ring below right, as Photoshop's pen shows a small circle there. +pub(crate) fn close_path(painter: &Painter, at: Pos2) -> CursorIcon { + outline(painter, at, vec![close_ring()], true) +} + +fn close_ring() -> Vec { + (0..16).map(|i| (i as f32 / 16.0 * std::f32::consts::TAU, vec2(9.0, 9.0))).map(|(a, c)| c + vec2(a.cos(), a.sin()) * 3.5).collect() +} + /// 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 { @@ -308,13 +320,27 @@ fn splat_cross(dist: &mut [f32], side: usize, hot: f32, arm: f32) { } /// One bitmap pixel from its distance to the stroke centre, in straight RGBA (winit form). -fn push_stroke_pixel(rgba: &mut Vec, distance: f32, scale: f32, dark: egui::Color32, light: egui::Color32) { - let (fill, outline) = stroke_coverage(distance, scale); +/// A pixel with light `fill` over a dark `outline` (coverages 0 to 1), in straight RGBA as winit +/// requires (not egui's premultiplied). No coverage is `[0; 4]`. +fn stroke_pixel((fill, outline): (f32, f32), dark: egui::Color32, light: egui::Color32) -> [u8; 4] { let a_dark = outline * f32::from(dark.a()) / 255.0; let a_light = fill * f32::from(light.a()) / 255.0; let alpha = a_light + a_dark * (1.0 - a_light); let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 }; - rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]); + [white, white, white, (255.0 * alpha).round() as u8] +} + +/// The columns of row offset `dy` (from the hotspot, in pixels) that can be painted by a ring of +/// radius `r` drawn `reach` wide either side: the left and right arcs of the annulus, padded by +/// a pixel. Every other pixel of the row is transparent. +fn ring_spans(dy: f32, r: f32, reach: f32) -> Option<[(f32, f32); 2]> { + let (ro, ri) = (r + reach + 1.0, (r - reach - 1.0).max(0.0)); + if dy.abs() > ro { + return None; + } + let xo = (ro * ro - dy * dy).max(0.0).sqrt(); + let xi = if dy.abs() < ri { (ri * ri - dy * dy).sqrt() } else { 0.0 }; + Some([(-xo, -xi), (xi, xo)]) } fn rasterize(shape: &Shape, scale: f32) -> Option { @@ -331,13 +357,20 @@ fn rasterize(shape: &Shape, scale: f32) -> Option { let hot = half as u16; let side = hot * 2 + 1; let bytes = usize::from(side).checked_mul(usize::from(side))?.checked_mul(4)?; - let mut rgba = Vec::with_capacity(bytes); + // Transparent to start; only pixels a shape can reach are evaluated below (a big tip's + // outline is a thin ring in a large square, #1343). + let mut rgba = vec![0u8; bytes]; let [dark, light] = crate::theme::Tokens::cursor_outline(); + let n = usize::from(side); + let hotf = f32::from(hot); + let mut put = |col: usize, row: usize, cov: (f32, f32)| { + if let Some(px) = row.checked_mul(n).and_then(|i| i.checked_add(col)).and_then(|i| rgba.get_mut(i * 4..i * 4 + 4)) { + px.copy_from_slice(&stroke_pixel(cov, dark, light)); + } + }; if let Shape::Outline { rings, centre } = shape { // Splat each segment into its bounding box instead of measuring every ring per pixel: // tip outlines may carry hundreds of points. - let hotf = f32::from(hot); - let n = usize::from(side); let reach = 1.5 * scale + 2.0; let mut dist = vec![f32::INFINITY; n * n]; for ring in rings.iter() { @@ -352,22 +385,36 @@ fn rasterize(shape: &Shape, scale: f32) -> Option { if *centre { splat_cross(&mut dist, n, hotf, 3.0 * scale); } - for &d in &dist { - push_stroke_pixel(&mut rgba, d, scale, dark, light); + for (i, &d) in dist.iter().enumerate() { + if d.is_finite() { + put(i % n, i / n, stroke_coverage(d, scale)); + } } return Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] }); } - for row in 0..side { - let dy = f32::from(row) - f32::from(hot); - for col in 0..side { - let dx = f32::from(col) - f32::from(hot); - let (fill, outline) = coverage(shape, dx, dy, scale); - let a_dark = outline * f32::from(dark.a()) / 255.0; - let a_light = fill * f32::from(light.a()) / 255.0; - let alpha = a_light + a_dark * (1.0 - a_light); - // Straight RGBA, as required by winit, not egui's premultiplied representation. - let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 }; - rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]); + // Columns to evaluate in each row: the ring's two arcs (and the centre mark) for a circle, + // the whole row otherwise. + let last = f32::from(side - 1); + for row in 0..n { + let dy = row as f32 - hotf; + let mut spans: [Option<(f32, f32)>; 3] = [None; 3]; + if let Shape::Circle { radius, centre } = shape { + let reach = 1.5 * scale + 0.5; + if let Some([l, r]) = ring_spans(dy, radius * scale, reach) { + spans = [Some(l), Some(r), None]; + } + let cross = 3.0 * scale + reach + 1.0; + if *centre && dy.abs() <= cross { + spans[2] = Some((-cross, cross)); + } + } else { + spans[0] = Some((-hotf, hotf)); + } + for (a, b) in spans.into_iter().flatten() { + let (c0, c1) = ((hotf + a).floor().clamp(0.0, last) as usize, (hotf + b).ceil().clamp(0.0, last) as usize); + for col in c0..=c1 { + put(col, row, coverage(shape, col as f32 - hotf, dy, scale)); + } } } Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] }) diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 3a8bd1f010..5d287901c0 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -48,14 +48,17 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() { assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230); } -/// Off Windows a tip stays within what X servers and hardware cursor planes take (256 px); a -/// larger one falls back to the painted outline. Windows takes far larger cursors. +/// On Linux a tip stays within what X servers and hardware cursor planes take (256 px); a larger +/// one falls back to the painted outline. Windows and macOS take far larger cursors. #[test] fn cursor_bitmaps_stay_within_the_platform_limit() { let fits = |radius: f32| rasterize(&Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]); + let large = cfg!(any(target_os = "windows", target_os = "macos")); assert!(fits(100.0).is_some_and(|side| side <= 255)); - assert_eq!(fits(200.0).is_some(), cfg!(target_os = "windows")); + assert_eq!(fits(200.0).is_some(), large); + assert_eq!(fits(1000.0).is_some(), large, "a 2000 px brush at 100 % still has a native outline"); assert!(fits(200.0).is_none_or(|side| side <= 2048)); + assert!(fits(1100.0).is_none(), "nothing past 2048 px"); } #[test] @@ -340,3 +343,38 @@ fn painting_preference_and_zoom_change_the_native_cursor_without_changing_pointe h.run_steps(2); assert!(h.output().platform_output.cursor_image.as_ref().unwrap().size[0] > cross_size); } + +/// #4596: the close-path pointer is the centre cross with a ring below right. +#[test] +fn close_path_bitmap_has_the_centre_cross_and_a_ring_beside_it() { + for scale in [1.0, 2.0] { + let image = rasterize(&Shape::Outline { rings: vec![close_ring()].into(), centre: true }, scale).unwrap(); + let [x, y] = image.hotspot; + let at = |dx: f32, dy: f32| alpha(&image, x + (dx * scale) as u16, y + (dy * scale) as u16); + assert!(at(0.0, 0.0) > 230 && at(2.0, 0.0) > 0, "the centre cross"); + assert!(at(12.5, 9.0) > 0 && at(9.0, 12.5) > 0, "the ring around (9, 9)"); + assert_eq!(at(9.0, 9.0), 0, "open in the middle"); + assert_eq!(at(0.0, 9.0), 0, "and apart from the cross"); + } +} + +/// A big tip's outline is a thin ring in a large square: only the ring (and the centre mark) is +/// evaluated, and the bitmap is exactly what evaluating every pixel gives. +#[test] +fn circle_bitmaps_evaluate_only_the_ring_and_match_every_pixel() { + let [dark, light] = crate::theme::Tokens::cursor_outline(); + for (radius, scale, centre) in [(0.4, 1.0, true), (3.0, 1.0, false), (20.0, 1.25, true), (57.3, 2.0, false), (90.0, 1.0, true)] { + let shape = Shape::Circle { radius, centre }; + let image = rasterize(&shape, scale).unwrap(); + let [hot, _] = image.hotspot; + let side = usize::from(image.size[0]); + for row in 0..side { + for col in 0..side { + let (dx, dy) = (col as f32 - f32::from(hot), row as f32 - f32::from(hot)); + let want = stroke_pixel(coverage(&shape, dx, dy, scale), dark, light); + let i = (row * side + col) * 4; + assert_eq!(image.rgba[i..i + 4], want, "radius {radius} at {scale}×, pixel ({col}, {row})"); + } + } + } +} diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index bc993a7ae6..eda03b305e 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -105,6 +105,10 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option reset!(feather, anti_alias), Tool::MagneticLasso => reset!(feather, anti_alias, magnetic_width, magnetic_contrast, magnetic_frequency, magnetic_pressure), Tool::MagicWand => reset!(tolerance, anti_alias, contiguous, sample_all_layers), - Tool::QuickSelection | Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge), + Tool::QuickSelection => reset!(sample_all_layers, enhance_edge), + Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge, object_finder), Tool::Crop => reset!( crop_ratio, crop_delete, @@ -160,7 +161,8 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::Smudge => reset!(strength, sample_all_layers, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), - Tool::Pen | Tool::CurvaturePen => reset!(vector_mode), + Tool::Pen => reset!(vector_mode, pen_auto_add_delete), + Tool::CurvaturePen => reset!(vector_mode), Tool::FreeformPen => reset!(vector_mode, curve_fit), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { reset!(vector_mode, shape_fill, stroke_width, shape_stroke, corner_radius, polygon_sides, line_weight) diff --git a/crates/ui-egui/src/tool_shortcuts.rs b/crates/ui-egui/src/tool_shortcuts.rs new file mode 100644 index 0000000000..345b7f57b3 --- /dev/null +++ b/crates/ui-egui/src/tool_shortcuts.rs @@ -0,0 +1,96 @@ +//! Toolbar tools in Edit › Keyboard Shortcuts (#4086). Explicit assignments select an +//! individual tool; unchanged tools keep Photoshop's shared keys and Shift cycling. + +use crate::{PhotocraftApp, state::Tool}; + +pub(crate) fn id(tool: Tool) -> String { + let mut name = format!("{tool:?}"); + if let Some(first) = name.get_mut(..1) { + first.make_ascii_lowercase(); + } + format!("tools.select.{name}") +} + +pub(crate) fn tool(id: &str) -> Option { + let name = id.strip_prefix("tools.select.")?; + let tool = Tool::from_name(name)?; + (id == self::id(tool)).then_some(tool) +} + +pub(crate) fn default(tool: Tool) -> Option { + (tool.key() != '\0').then(|| tool.key().to_string()) +} + +pub(crate) fn shortcut(app: &PhotocraftApp, tool: Tool) -> Option { + crate::shortcuts::effective_shortcut(app, &id(tool), default(tool).as_deref()) +} + +pub(crate) fn uses_default(app: &PhotocraftApp, tool: Tool) -> bool { + !app.session.prefs().shortcuts.contains_key(&id(tool)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn every_toolbar_tool_has_a_unique_live_shortcut_row() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let items = crate::prefs_ui::shortcut_items(&app); + let mut ids = std::collections::HashSet::new(); + for t in Tool::ALL { + let id = id(t); + assert!(ids.insert(id.clone())); + assert_eq!(tool(&id), Some(t)); + assert!(crate::menus::is_live(&id)); + assert!(crate::menus::is_enabled(&app, &id)); + let rows: Vec<_> = items.iter().filter(|i| i.0 == id).collect(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].1, t.label()); + assert_eq!(rows[0].2, ["Tools"]); + assert_eq!(rows[0].3, default(t)); + assert_eq!(crate::shortcuts::default_shortcut(&id), default(t)); + crate::menus::invoke(&mut app, &ctx, &id, json!({})).unwrap(); + assert_eq!(app.ui.tool, t); + } + for id in ["tools.select.", "tools.select.missing", "tools.select.Brush"] { + assert!(!crate::menus::is_live(id)); + assert!(crate::menus::invoke(&mut app, &ctx, id, json!({})).is_err()); + } + } + + #[test] + fn toolbar_hints_follow_assignments_removal_and_reset() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B")); + assert_eq!(shortcut(&app, Tool::SingleRowMarquee), None); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "F6"})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("F6")); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": ""})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil), None); + app.run("prefs.reset", json!({"path": "shortcuts.tools.select.pencil"})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B")); + } + + #[test] + fn a_remapped_tool_letter_does_not_steal_text_input() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "Q"})).unwrap(); + let mut h = egui_kittest::Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.text_edit_singleline(&mut app.ui.status).request_focus(); + crate::shortcuts::handle(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + assert!(h.ctx.text_edit_focused()); + h.key_press(egui::Key::Q); + h.event(egui::Event::Text("q".into())); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.status, "q"); + } +} diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 84d1031bb6..8ee9c08e67 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2}; use photocraft_algo::transform::Homography; use photocraft_doc::{DocId, Document, LayerContent, LayerId}; +use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use serde_json::json; @@ -85,6 +86,9 @@ pub struct TransformPreview { /// A layer's session keeps `doc` and `opacity` up to date with edits made while the box is /// open ([`follow_edits`]); `None` for a lone mask or channel and Transform Selection. follow: Option, + /// The commit running as a background job (#1015): the moving pixels stay on screen, without + /// the box, until it lands; Esc cancels it. The session ends with the job. + committing: Option, /// Content-Aware Scale's live preview (cas_transform.rs). pub(crate) content_aware: Option, } @@ -229,6 +233,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> above: Some(above), styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), follow: Some(Follow { revision, hidden: lifted.is_none() }), + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -254,6 +259,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> made: None, mode: Default::default(), content_aware: None, + skew_v: 0.0, }); start_steps(app); Ok(()) @@ -327,6 +333,7 @@ pub(crate) fn begin_content_aware( opacity: layer.opacity * layer.fill_opacity, above: Some(above), follow: Some(Follow { revision, hidden: false }), + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -353,6 +360,7 @@ pub(crate) fn begin_content_aware( made: None, mode: Default::default(), content_aware: Some(Default::default()), + skew_v: 0.0, }); start_steps(app); Ok(()) @@ -385,13 +393,20 @@ fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) { /// After the transform: the step that made the layer and the transform become one history step, /// the transform's for a copy and Place Embedded for a placed file. -fn fold_made(app: &mut PhotocraftApp, made: MadeLayer) { +fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) { + // A background commit lands in its own document, which need not be the active one by then. + let Some(index) = app.session.documents().iter().position(|st| st.doc.id == document) else { return }; + let active = app.session.active_index(); + app.session.set_active(index); if let Some(st) = app.session.active_mut() { st.history.purge_last(); if made == MadeLayer::Place { st.history.set_current_label(photocraft_engine::file_cmds::PLACE_EMBEDDED); } } + if let Some(active) = active { + app.session.set_active(active); + } } /// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the @@ -440,6 +455,7 @@ fn begin_lone( above: None, styled: None, follow: None, + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -465,6 +481,7 @@ fn begin_lone( made: None, mode: Default::default(), content_aware: None, + skew_v: 0.0, }); start_steps(app); Ok(()) @@ -501,6 +518,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: content_aware: None, above: None, follow: None, + committing: None, }); app.ui.transform = Some(TransformSession { session, @@ -516,6 +534,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: made: None, mode: Default::default(), content_aware: None, + skew_v: 0.0, }); start_steps(app); Ok(()) @@ -574,6 +593,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( above: None, styled: None, follow: None, + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -599,6 +619,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( made: None, mode: Default::default(), content_aware: None, + skew_v: 0.0, }); start_steps(app); Ok(()) @@ -669,6 +690,9 @@ pub fn warps_type(app: &PhotocraftApp) -> bool { fn enter_type_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.ui.transform.as_ref().is_some_and(|t| t.quad != corners(t.rect)) { commit(app); + if app.ui.transform.is_some() { + return; // refused: the box stays as it was + } if let Err(e) = begin(app, ctx) { app.ui.status = e; return; @@ -782,7 +806,7 @@ fn preview_mask(l: &photocraft_doc::Layer, b: photocraft_geom::Rect) -> Option bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } +/// Undo and Redo while the commit runs in the background. +const STILL_APPLYING: &str = "Free Transform is still being applied"; + pub fn commit(app: &mut PhotocraftApp) { + if committing(app).is_some() { + return; + } let document = app.transform_preview.as_ref().map(|pv| pv.doc.id); if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) { cancel(app); @@ -954,7 +984,7 @@ pub fn commit(app: &mut PhotocraftApp) { } let type_layer = warps_type(app); let Some(t) = app.ui.transform.take() else { return }; - app.transform_preview = None; + let mut preview = app.transform_preview.take(); if let Some(options) = &t.content_aware { crate::cas_transform::commit(app, &t, options); return; @@ -968,12 +998,12 @@ pub fn commit(app: &mut PhotocraftApp) { None => p["quad"] = json!(t.quad), } if let Err(e) = app.run("select.transformSelection", p) { - app.ui.status = e; + keep_open(app, t, preview, e); } return; } - if let Some(target) = t.path { - let mut p = target; + if let Some(target) = &t.path { + let mut p = target.clone(); match &t.warp { Some(w) if w.is_identity() => return, Some(w) => p["warp"] = json!(w), @@ -988,7 +1018,7 @@ pub fn commit(app: &mut PhotocraftApp) { }, } if let Err(e) = app.run("path.transform", p) { - app.ui.status = e; + keep_open(app, t, preview, e); } return; } @@ -1004,26 +1034,66 @@ pub fn commit(app: &mut PhotocraftApp) { return; // untouched: nothing to do and no history step; a ⌥⌘T copy or a place stays } let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); - if let Some(target) = t.target { - p["target"] = target; + if let Some(target) = &t.target { + p["target"] = target.clone(); } app.run("edit.transform", p) }; - match r { - Ok(_) => { - if let Some(made) = made { - fold_made(app, made); - } - } - Err(e) => { - if made.is_some() - && let Some(document) = document - { - take_back_made(app, document, LayerId(t.layer)); - } - app.ui.status = e; - } + // A large layer's warp runs in the background: the session stays until the job ends. + if let Ok(v) = &r + && let Some(job) = v.get("pending").and_then(serde_json::Value::as_bool).filter(|p| *p).and(v.get("job")).and_then(serde_json::Value::as_u64) + && let Some(pv) = preview.as_mut() + { + pv.committing = Some(JobId(job)); + app.ui.transform = Some(t); + app.transform_preview = preview; + return; } + match r { + Ok(_) => finish(app, document, LayerId(t.layer), made, true), + Err(e) => keep_open(app, t, preview, e), + } +} + +/// After the transform landed or not: fold the layer the session made into its step, or take it +/// back. +fn finish(app: &mut PhotocraftApp, document: Option, layer: LayerId, made: Option, landed: bool) { + let (Some(made), Some(document)) = (made, document) else { return }; + if landed { + fold_made(app, document, made); + } else { + take_back_made(app, document, layer); + } +} + +/// The job applying the transform, while it runs. +pub(crate) fn committing(app: &PhotocraftApp) -> Option { + app.transform_preview.as_ref().and_then(|pv| pv.committing) +} + +/// A job ended (see [`crate::jobs_ui`]): when it was the commit, the session ends with it. Its +/// status and errors are reported like any job's. +pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) { + if committing(app) != Some(e.id) { + return; + } + let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return }; + // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline. + if let JobOutcome::Failed(msg) = &e.outcome { + pv.committing = None; + keep_open(app, t, Some(pv), msg.clone()); + return; + } + finish(app, Some(pv.doc.id), LayerId(t.layer), t.made, matches!(e.outcome, JobOutcome::Done(_))); +} + +/// A commit the engine refused (a result over the pixel budget, #3773): the box stays open as it +/// was, with the reason in the status bar, so the transform can be changed or cancelled. A copy or +/// a placed layer it made goes only when it is cancelled. +fn keep_open(app: &mut PhotocraftApp, t: TransformSession, preview: Option, e: String) { + app.ui.transform = Some(t); + app.transform_preview = preview; + app.ui.status = e; } /// A transform whose layer or document went away (undo, close) ends silently; picking another tool @@ -1033,6 +1103,9 @@ pub fn commit(app: &mut PhotocraftApp) { /// that tool. pub fn end_if_left(app: &mut PhotocraftApp) { let Some(t) = &app.ui.transform else { return }; + if committing(app).is_some() { + return; + } let Some(st) = app.session.active() else { return cancel(app) }; let layer = LayerId(t.layer); @@ -1055,6 +1128,10 @@ pub fn end_if_left(app: &mut PhotocraftApp) { || (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0)) { commit(app); + // Picking another tool or layer can't leave a refused transform open: it ends, as Esc would. + if app.ui.transform.is_some() { + cancel(app); + } } } @@ -1084,6 +1161,11 @@ pub fn follow_edits(app: &mut PhotocraftApp) { } pub fn cancel(app: &mut PhotocraftApp) { + // Esc while the commit runs cancels the job; the session ends when it does. + if let Some(job) = committing(app) { + crate::jobs_ui::cancel(app, job); + return; + } let transform = app.ui.transform.take(); let preview = app.transform_preview.take(); if let (Some(t), Some(pv)) = (transform, preview) @@ -1114,6 +1196,9 @@ pub fn track_steps(app: &mut PhotocraftApp, ctx: &egui::Context) { /// Toggle Last State, which would change the document under the box), `None` for the rest. pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { let t = app.ui.transform.as_ref()?; + if committing(app).is_some() { + return box_command(id).then_some(false); + } let steps = app.transform_preview.as_ref().map(|pv| &pv.steps); match id { // Content-Aware Scale's box only scales: no switching it to another transform or Warp. @@ -1159,8 +1244,16 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { /// document under the box, is refused. Edit › Transform's Rotate and Flip turn the box itself, as /// in Photoshop, instead of the layer under it; committing stays with ↩ and the options bar. /// `None` for other commands or when not transforming. +/// The commands the open box answers itself ([`is_enabled`], [`intercept`]). +fn box_command(id: &str) -> bool { + matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some() +} + pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { let warping = app.ui.transform.as_ref()?.warp.is_some(); + if committing(app).is_some() { + return box_command(id).then(|| Err(STILL_APPLYING.into())); + } match id { "edit.undo" => Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), @@ -1250,6 +1343,9 @@ fn accepts_outside_click(g: Gesture, end: [f64; 2], tol: f64) -> bool { /// Pointer input while transforming. Returns false when no transform is active. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { let Some(t) = app.ui.transform.clone() else { return false }; + if committing(app).is_some() { + return true; + } let tol = handle_tolerance(app); if t.warp.is_some() { warp_pointer(app, ev, tol, mods); @@ -1287,6 +1383,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> return true; } let legacy = app.session.prefs().general.use_legacy_free_transform; + let unlinked = app.ui.transform_unlinked; let type_layer = moves_type(app, &t); if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut()) && mode_allows(t.mode, g.hit) @@ -1294,12 +1391,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> { let (mode, mods) = if no_distort { scale_not_distort(t.mode, g.hit, mods) } else { (t.mode, mods) }; if t.content_aware.is_some() { - let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods)); + let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); // Content-Aware Scale: ⌘ neither skews nor distorts. mods.command = false; apply_drag(s, g, [x, y], mods); } else { - apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, mods)); + apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); } } } @@ -1342,6 +1439,16 @@ pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> mods } +/// The options bar's Maintain Aspect Ratio link turned off: corner and edge drags stretch freely +/// and ⇧ keeps the proportions, as in Photoshop CC 2019 and later (#4174). The legacy preference +/// already works that way, so it is left alone there. +pub(crate) fn unlinked_mods(unlinked: bool, legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { + if unlinked && !legacy && !mods.command && matches!(hit, Hit::Corner(_) | Hit::Edge(_)) { + mods.shift = !mods.shift; + } + mods +} + /// Distort / Perspective modes: a corner drag acts as if that gesture's keys were held (⌘-drag a /// corner distorts, ⌘⌥⇧-drag a corner adds perspective). Skew has its own ([`apply_mode_drag`]). fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { @@ -1399,6 +1506,13 @@ pub(crate) fn apply_mode_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], s.quad = keep_convex(q0, q); s.pivot = carry_pivot(q0, s.quad, g.pivot0); } + // Rotate (#3941): a corner or edge turns about the reference point, the same drag as the + // zone just outside the box. Free Transform still scales from those handles. + (TransformMode::Rotate, Hit::Corner(_) | Hit::Edge(_)) => { + let mut g = g; + g.hit = Hit::Outside; + apply_drag(s, g, p, mods); + } // Skew: an edge slides only along itself (⌘⇧ in Free Transform). (TransformMode::Skew, Hit::Edge(_)) => { mods.command = true; @@ -1833,7 +1947,10 @@ pub fn split(app: &mut PhotocraftApp, id: &str, at: Option<[f64; 2]>) -> Result< fn near_rotate_corner(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { t.warp.is_none() && t.mode != TransformMode::Distort - && hit(t, p, tol) == Hit::Outside + && { + let h = hit(t, p, tol); + h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_))) + } && t.quad.iter().any(|q| (p[0] - q[0]).hypot(p[1] - q[1]) <= tol * 3.0) } @@ -1971,6 +2088,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(a) = &pv.above { a.draw(painter, xf); } + if pv.committing.is_some() { + return; + } let accent = crate::theme::Tokens::get(painter.ctx()).accent; if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) { crate::vector_ui::draw_outline( @@ -2299,14 +2419,102 @@ fn quad_scale(t: &TransformSession) -> f32 { if s.is_finite() { s as f32 } else { 1.0 } } -/// Scale (%), angle (°) and translation implied by the current quad (affine readout). +/// W and H (%), the angle (°) and the horizontal skew (°) the options bar shows for the box. fn readout(t: &TransformSession) -> (f64, f64, f64, f64) { - let (w0, h0) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]); - let e = [t.quad[1][0] - t.quad[0][0], t.quad[1][1] - t.quad[0][1]]; - let f = [t.quad[3][0] - t.quad[0][0], t.quad[3][1] - t.quad[0][1]]; - let sx = (e[0].hypot(e[1]) / w0.max(1e-9)) * 100.0; - let sy = (f[0].hypot(f[1]) / h0.max(1e-9)) * 100.0; - (sx, sy, e[1].atan2(e[0]).to_degrees(), 0.0) + let p = Parts::of(t); + (p.sx * 100.0, p.sy.abs() * 100.0, p.angle.to_degrees(), p.skew_h.to_degrees()) +} + +/// The box's affine part read as Photoshop's options bar does: the frame scaled (W, H), skewed +/// (H, V) and then rotated about the reference point. Angles are radians; positive turns +/// clockwise on screen, so a positive H skew leans the box's sides like the angle field would +/// turn them, and a positive V skew its top and bottom. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Parts { + sx: f64, + /// Negative when the box is flipped vertically relative to its rotation. + sy: f64, + angle: f64, + skew_h: f64, + skew_v: f64, +} + +/// The skew fields' reach: a skew of ±90° would flatten the box. +const SKEW_LIMIT_DEG: f32 = 89.0; + +impl Parts { + /// The box's columns: where the frame's unit X and Y edges went (from the top-left corner). + fn columns(t: &TransformSession) -> Option<[[f64; 2]; 2]> { + let (w0, h0) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]); + if w0.abs() < 1e-9 || h0.abs() < 1e-9 { + return None; + } + let q = &t.quad; + let m = [[(q[1][0] - q[0][0]) / w0, (q[1][1] - q[0][1]) / w0], [(q[3][0] - q[0][0]) / h0, (q[3][1] - q[0][1]) / h0]]; + m.iter().flatten().all(|v| v.is_finite()).then_some(m) + } + + /// The box as scale, skew and rotation, keeping the session's typed V skew (any box can be + /// written with any V skew short of ±90°). + fn of(t: &TransformSession) -> Parts { + let skew_v = t.skew_v.to_radians(); + let none = Parts { sx: 1.0, sy: 1.0, angle: 0.0, skew_h: 0.0, skew_v }; + Parts::columns(t).map_or(none, |m| Parts::from_columns(m, skew_v)) + } + + fn from_columns([c0, c1]: [[f64; 2]; 2], skew_v: f64) -> Parts { + // X edge = sx · R(angle) · (1, tan v): its direction is the angle plus v. + let angle = c0[1].atan2(c0[0]) - skew_v; + let sx = c0[0].hypot(c0[1]) * skew_v.cos(); + // Y edge = sy · R(angle) · (−tan h, 1). + let (gx, gy) = unturn(c1, angle); + let skew_h = if gy.abs() < 1e-12 { 0.0 } else { (-gx / gy).atan() }; + Parts { sx, sy: gy, angle, skew_h, skew_v } + } + + fn columns_of(self) -> [[f64; 2]; 2] { + let (sn, cs) = self.angle.sin_cos(); + let rot = |x: f64, y: f64| [x * cs - y * sn, x * sn + y * cs]; + let c0 = rot(self.sx, self.sx * self.skew_v.tan()); + let c1 = rot(-self.sy * self.skew_h.tan(), self.sy); + [c0, c1] + } +} + +/// `c` turned back by `angle` (radians). +fn unturn(c: [f64; 2], angle: f64) -> (f64, f64) { + let (sn, cs) = angle.sin_cos(); + (c[0] * cs + c[1] * sn, -c[0] * sn + c[1] * cs) +} + +/// Sets the box's H and V skew (degrees), keeping its scale, angle and reference point: the +/// options bar's skew fields. +fn skew_about_pivot(app: &mut PhotocraftApp, skew_h: f64, skew_v: f64) { + let Some(s) = app.ui.transform.as_mut() else { return }; + let Some(m) = Parts::columns(s) else { return }; + let limit = f64::from(SKEW_LIMIT_DEG); + let (skew_h, skew_v) = (skew_h.clamp(-limit, limit), skew_v.clamp(-limit, limit)); + let from = Parts::from_columns(m, s.skew_v.to_radians()); + let to = Parts { skew_h: skew_h.to_radians(), skew_v: skew_v.to_radians(), ..from }.columns_of(); + // The map from the old box to the new one: to · m⁻¹. + let det = m[0][0] * m[1][1] - m[1][0] * m[0][1]; + if det.abs() < 1e-12 { + return; + } + let inv = [[m[1][1] / det, -m[0][1] / det], [-m[1][0] / det, m[0][0] / det]]; + let l = |x: f64, y: f64| { + let (u, v) = (inv[0][0] * x + inv[1][0] * y, inv[0][1] * x + inv[1][1] * y); + [to[0][0] * u + to[1][0] * v, to[0][1] * u + to[1][1] * v] + }; + let c = s.pivot; + let quad = s.quad.map(|q| { + let d = l(q[0] - c[0], q[1] - c[1]); + [c[0] + d[0], c[1] + d[1]] + }); + if quad.iter().flatten().all(|v| v.is_finite()) { + s.quad = quad; + s.skew_v = skew_v; + } } /// Width of the mode, cancel and commit cluster kept on the right of the options bar. @@ -2392,7 +2600,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession) { let tk = crate::theme::Tokens::get(ui.ctx()); - let (_, _, angle, _) = readout(t); + let (_, _, angle, skew_h) = readout(t); let lbl = |ui: &mut egui::Ui, s: &str| { ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0)); }; @@ -2405,6 +2613,16 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes if crate::widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 60.0).changed() { rotate_about_pivot(app, (a as f64 - angle).to_radians()); } + // Photoshop's H and V skew fields: the box's sides lean by H, its top and bottom by V. + let (mut h, mut v) = (skew_h as f32, t.skew_v as f32); + let lim = -SKEW_LIMIT_DEG..=SKEW_LIMIT_DEG; + lbl(ui, "H:"); + let rh = crate::widgets::value_field(ui, &mut h, lim.clone(), "°", 56.0).on_hover_text(tl!("Set horizontal skew")); + lbl(ui, "V:"); + let rv = crate::widgets::value_field(ui, &mut v, lim, "°", 56.0).on_hover_text(tl!("Set vertical skew")); + if rh.changed() || rv.changed() { + skew_about_pivot(app, f64::from(h), f64::from(v)); + } crate::widgets::vline(ui, 22.0); lbl(ui, tl!("Interpolation:")); let mut interp = t.interpolation.clone(); @@ -2449,11 +2667,9 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 }; lbl(ui, "W:"); let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places); - let link_id = egui::Id::new("transform-link"); - let mut link: bool = ui.data(|d| d.get_temp(link_id)).unwrap_or(true); + let link = !app.ui.transform_unlinked; if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() { - link = !link; - ui.data_mut(|d| d.insert_temp(link_id, link)); + app.ui.transform_unlinked = link; } lbl(ui, "H:"); let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places); @@ -2737,6 +2953,7 @@ mod tests { made: None, mode: Default::default(), content_aware: None, + skew_v: 0.0, } } @@ -2902,6 +3119,58 @@ mod tests { assert!(h.state().ui.transform.as_ref().unwrap().warp.as_ref().unwrap().is_identity()); } + fn eq6(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 + } + + /// Scale, skew and rotation read back exactly from the box they make, flips included. + #[test] + fn parts_round_trip() { + for (sx, sy, angle, h, v) in [(1.0, 1.0, 0.0, 0.0, 0.0), (1.5, 0.5, 30.0, 20.0, 0.0), (0.8, -1.2, -120.0, -35.0, 15.0), (2.0, 1.0, 179.0, 0.0, -60.0)] { + let p = Parts { sx, sy, angle: f64::to_radians(angle), skew_h: f64::to_radians(h), skew_v: f64::to_radians(v) }; + let back = Parts::from_columns(p.columns_of(), p.skew_v); + for (a, b) in [(back.sx, p.sx), (back.sy, p.sy), (back.angle, p.angle), (back.skew_h, p.skew_h)] { + assert!(eq6(a, b), "{p:?} read back as {back:?}"); + } + } + } + + /// A box sheared sideways keeps W, H and the angle; only H skew moves (it used to read as a + /// taller box). + #[test] + fn a_sheared_box_reads_as_horizontal_skew() { + let mut t = session(); + // The 100×50 frame with its bottom slid 50·tan 20° to the left: a positive H skew. + let d = 50.0 * 20f64.to_radians().tan(); + t.quad = [[0.0, 0.0], [100.0, 0.0], [100.0 - d, 50.0], [-d, 50.0]]; + let (w, h, angle, skew) = readout(&t); + assert!(eq6(w, 100.0) && eq6(h, 100.0) && eq6(angle, 0.0), "{w} {h} {angle}"); + assert!(eq6(skew, 20.0), "{skew}"); + } + + /// Typing H and V skews leans the box about the reference point; the angle, W and H fields keep + /// their values, and V stays as typed. + #[test] + fn skew_fields_lean_the_box_about_the_reference_point() { + let t = type_into_bar_field(5, "20"); + let (w, h, angle, skew) = readout(&t); + assert!(eq6(skew, 20.0) && eq6(angle, 0.0) && eq6(w, 100.0) && eq6(h, 100.0), "{w} {h} {angle} {skew}"); + // The pivot (50, 25) stays put: the top edge slid right, the bottom left, by the same amount. + let d = 25.0 * 20f64.to_radians().tan(); + assert!(eq6(t.quad[0][0], d) && eq6(t.quad[0][1], 0.0) && eq6(t.quad[3][0], -d) && eq6(t.quad[3][1], 50.0), "{:?}", t.quad); + + let t = type_into_bar_field(6, "-10"); + assert!(eq6(t.skew_v, -10.0)); + let (w, h, angle, skew) = readout(&t); + assert!(eq6(skew, 0.0) && eq6(angle, 0.0) && eq6(w, 100.0) && eq6(h, 100.0), "{w} {h} {angle} {skew}"); + // The right side rose: tan(−10°) per pixel across, about the pivot. + let d = 50.0 * (-10f64).to_radians().tan(); + assert!(eq6(t.quad[1][1], d) && eq6(t.quad[0][1], -d), "{:?}", t.quad); + // A skew past ±90° would flatten the box: the field stops at the limit. + let t = type_into_bar_field(5, "120"); + assert!(eq6(readout(&t).3, f64::from(SKEW_LIMIT_DEG)), "{}", readout(&t).3); + } + /// Types `text` + Enter into the `n`th numeric field of the transform bar (X, Y, W, H, angle…). fn type_into_bar_field(n: usize, text: &str) -> TransformSession { type_into_bar_field_with(|_| {}, n, text) @@ -3404,6 +3673,30 @@ mod tests { assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); } + /// A commit the engine refuses (here a collapsed box; a result over the pixel budget, #3773, is + /// refused the same way) shows the reason and keeps the box open, unchanged; picking another + /// tool then ends it. + #[test] + fn a_refused_commit_keeps_the_box_open() { + let ctx = egui::Context::default(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let (revision, steps) = (app.session.active().unwrap().revision, app.session.active().unwrap().history.past_len()); + begin(&mut app, &ctx).unwrap(); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = [[16.0, 16.0]; 4]; + } + commit(&mut app); + let t = app.ui.transform.as_ref().expect("the box stays open"); + assert_eq!(t.quad, [[16.0, 16.0]; 4], "as the user left it"); + assert!(app.transform_preview.is_some(), "with its preview"); + assert!(app.ui.status.contains("collapses"), "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert_eq!((st.revision, st.history.past_len()), (revision, steps), "the document was not changed"); + app.ui.tool = if app.ui.tool == Tool::Brush { Tool::Move } else { Tool::Brush }; + end_if_left(&mut app); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "another tool ends it"); + } + /// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step; /// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected /// pixels only. @@ -3727,6 +4020,25 @@ mod tests { assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Free); } + /// #3941: Edit › Transform › Rotate must rotate from a corner handle, not scale. + #[test] + fn rotate_mode_turns_the_box_from_a_corner_handle() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.ui.extras.snap = false; + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "edit.transform.rotate", json!({})).unwrap(); + assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Rotate); + let q0 = app.ui.transform.as_ref().unwrap().quad; + press_drag(&mut app, q0[1], [q0[1][0], q0[1][1] + 16.0], egui::Modifiers::NONE); + let q = app.ui.transform.as_ref().unwrap().quad; + let w0 = (q0[1][0] - q0[0][0]).hypot(q0[1][1] - q0[0][1]); + let w = (q[1][0] - q[0][0]).hypot(q[1][1] - q[0][1]); + assert!((w - w0).abs() < 1e-6, "side length stays {w0}, got {w}: {q:?}"); + assert!((q[0][1] - q[1][1]).abs() > 1.0, "the box rotated, not scaled: {q:?}"); + crate::menus::invoke(&mut app, &ctx, "edit.transform.scale", json!({})).unwrap(); + assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Free); + } + fn near2(a: Option<[f64; 2]>, b: [f64; 2]) -> bool { a.is_some_and(|a| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9) } @@ -3843,6 +4155,41 @@ mod tests { } } + /// #4174: turning off the options bar's Maintain Aspect Ratio link frees the handles too, as in + /// Photoshop: a plain corner or edge drag stretches one way, ⇧ keeps the proportions. Linked + /// (the default) is unchanged, and so is the legacy preference. + #[test] + fn the_aspect_link_decides_whether_handle_drags_keep_proportions() { + let (none, shift) = (egui::Modifiers::NONE, egui::Modifiers::SHIFT); + for (unlinked, legacy, mods, proportional) in + [(false, false, none, true), (true, false, none, false), (true, false, shift, true), (true, true, none, false)] + { + for at_edge in [false, true] { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.ui.extras.snap = false; + app.ui.transform_unlinked = unlinked; + app.session.prefs.edit(|p| p.general.use_legacy_free_transform = legacy); + begin(&mut app, &egui::Context::default()).unwrap(); + let q0 = app.ui.transform.as_ref().unwrap().quad; + let from = if at_edge { [q0[1][0], (q0[1][1] + q0[2][1]) / 2.0] } else { q0[2] }; + press_drag(&mut app, from, [from[0] + 16.0, from[1]], mods); + let q = app.ui.transform.as_ref().unwrap().quad; + let (w, h) = (q[2][0] - q[0][0], q[2][1] - q[0][1]); + let case = format!("unlinked {unlinked}, legacy {legacy}, shift {}, edge {at_edge}: {w} × {h}", mods.shift); + if legacy && at_edge { + // Legacy edges always stretch their one axis. + assert!((w - 32.0).abs() < 1e-6 && (h - 16.0).abs() < 1e-6, "{case}"); + } else { + assert_eq!((w - h).abs() < 1e-6, proportional, "{case}"); + assert!((w - 32.0).abs() < 1e-6, "{case}"); + } + } + } + // ⌘ gestures (skew, distort) are left alone. + assert!(!unlinked_mods(true, false, Hit::Corner(0), egui::Modifiers::COMMAND).shift); + assert!(!unlinked_mods(true, false, Hit::Inside, none).shift); + } + #[test] fn right_click_while_transforming_switches_the_mode() { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); @@ -4390,6 +4737,32 @@ mod tests { assert_eq!(texel_alpha(&img, 10, 4), 255, "revealed"); } + #[test] + fn preview_feathers_the_linked_mask() { + // A feathered pixel mask without a vector mask: the compositor softens the edge at x 16, + // so the moving texels must be soft there too, not hard until the commit (#4560). + let mut app = masked_app(true); + app.session + .edit("feather", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().mask.as_mut().unwrap().feather = 4.0; + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + let b = photocraft_geom::Rect::new(8, 8, 24, 24); + let (img, _) = preview_image(&st.doc, id, None, b, 4096, false); + // Texel x 7 = doc x 15, just inside the hidden half; texel x 8 = doc x 16, just revealed. + assert!(texel_alpha(&img, 7, 4) > 0, "the feather reaches into the hidden side"); + assert!(texel_alpha(&img, 8, 4) < 255, "the feather reaches into the revealed side"); + // The same values the compositor masks the committed layer with. + let m = photocraft_compose::masks::combined_mask(st.doc.layer(id).unwrap(), b).unwrap(); + for x in [6usize, 7, 8, 9, 15] { + let want = (m.sample_channel(8 + x as i32, 12, 0) * 255.0).round() as i32; + assert!((i32::from(texel_alpha(&img, x, 4)) - want).abs() <= 1, "texel {x}"); + } + } + #[test] fn targeted_unlinked_mask_transforms_alone_through_the_box() { let mut app = masked_app(false); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index ea3c128a18..dcbbb70b3c 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -52,6 +52,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { pasteboard: false, anchor: None, text: false, + spelling: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 28d4d7c56b..53a1e6c021 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -52,7 +52,8 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { (p.x as f32, p.y as f32) } -/// Topmost visible type layer whose laid-out text contains the document point. +/// Topmost shown type layer (it and every group around it visible, #4320) whose laid-out text +/// contains the document point. fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.point_zoom().max(0.01); @@ -61,7 +62,7 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let mut ids: Vec = doc .walk() .into_iter() - .filter(|(_, _, l)| (l.visible || editing == Some(l.id)) && matches!(l.content, LayerContent::Text(_))) + .filter(|(path, _, l)| matches!(l.content, LayerContent::Text(_)) && (editing == Some(l.id) || doc.shown_at(path))) .map(|(_, _, l)| l.id) .collect(); ids.reverse(); // walk() is bottom-up; hit the topmost first @@ -150,6 +151,11 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { } fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { + offset_at(app, id, x, y) +} + +/// The character offset of the caret position nearest document point (`x`, `y`) in layer `id`. +pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { let Some((l, aff, text)) = layout(app, id) else { return 0 }; let (tx, ty) = to_text(&aff, x, y); photocraft_text::hit_char(&l, &text, tx, ty).0 @@ -178,7 +184,7 @@ fn handle_sides(i: u8) -> (bool, bool, bool, bool) { } pub(crate) fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> { - match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape))? { + match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()))? { photocraft_doc::text::TextShape::Box { x, y, width, height } => Some((x, y, width, height)), _ => None, } @@ -459,6 +465,135 @@ fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) { let _ = app.run("type.edit", json!({"layer": id.0, "kernPair": {"at": caret, "by": by}})); } +/// A character style the type editor's ⇧⌘ keys toggle (Photoshop: ⇧⌘K All Caps, ⇧⌘U +/// Underline, ⇧⌘B Faux Bold, ⇧⌘I Faux Italic, ⇧⌘/ Strikethrough). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CharToggle { + AllCaps, + Underline, + FauxBold, + FauxItalic, + Strikethrough, +} + +impl CharToggle { + fn is_on(self, st: &photocraft_doc::text::CharStyle) -> bool { + match self { + Self::AllCaps => st.caps == photocraft_doc::text::Caps::AllCaps, + Self::Underline => st.underline, + Self::FauxBold => st.faux_bold, + Self::FauxItalic => st.faux_italic, + Self::Strikethrough => st.strikethrough, + } + } + + fn param(self, on: bool) -> (&'static str, serde_json::Value) { + match self { + Self::AllCaps => ("caps", json!(if on { "all" } else { "normal" })), + Self::Underline => ("underline", json!(on)), + Self::FauxBold => ("fauxBold", json!(on)), + Self::FauxItalic => ("fauxItalic", json!(on)), + Self::Strikethrough => ("strikethrough", json!(on)), + } + } +} + +/// The byte range of characters `a..b` of `text`. +fn byte_range(text: &str, a: usize, b: usize) -> (usize, usize) { + let at = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + (at(a.min(b)), at(a.max(b))) +} + +/// The character styles of the selection, or of the typing style when the text is empty. Empty +/// when nothing is selected in non-empty text. +fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { + let runs = t.char_runs(); + if t.text.is_empty() { + return runs.into_iter().take(1).map(|r| r.style).collect(); + } + let (ba, bb) = byte_range(&t.text, a, b); + let mut at = 0usize; + let mut out = Vec::new(); + for r in runs { + let (rs, re) = (at, at.saturating_add(r.len)); + at = re; + if rs < bb && re > ba { + out.push(r.style); + } + } + out +} + +/// The character range the type editor's formatting keys act on: the selection, or the empty +/// text's typing style. `None` for a collapsed caret in non-empty text: Photoshop would style the +/// next characters typed, which the document model can't hold apart from the text, so the keys +/// do nothing there (rather than restyle a word the user didn't select). +fn format_range(app: &PhotocraftApp, ed: &TextEdit) -> Option<(usize, usize)> { + let t = text_layer(&app.session.active()?.doc, LayerId(ed.layer))?; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + (a < b || n == 0).then_some((a, b)) +} + +/// One `type.setStyle` step on the edited layer's `range`, kept apart from the typing session's +/// coalesced edit so Undo takes back one key press at a time (as with kerning). +fn set_style(app: &mut PhotocraftApp, ed: &TextEdit, range: (usize, usize), mut p: serde_json::Value) { + crate::type_transform::finish(app); + p["layer"] = json!(ed.layer); + p["range"] = json!([range.0, range.1]); + if let Err(e) = app.run("type.setStyle", p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// ⇧⌘K/U/B/I/: flips a character style over the selection: off when every selected character +/// already has it, else on. +pub(crate) fn toggle_char_style(app: &mut PhotocraftApp, which: CharToggle) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + let Some(styles) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))).map(|t| selected_styles(t, range.0, range.1)) else { + return; + }; + let on = !styles.iter().all(|st| which.is_on(st)); + let (key, value) = which.param(on); + set_style(app, &ed, range, json!({ key: value })); +} + +/// ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking: steps every selected character from its +/// own value (`key` is `leadingBy`, `baselineShiftBy` or `trackingBy`). +pub(crate) fn step_char_metric(app: &mut PhotocraftApp, key: &str, by: f32) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + // Leading steps in the points the Character panel shows for a scaled layer. + set_style(app, &ed, range, json!({ key: by, "metricsAsShown": key == "leadingBy" })); +} + +/// ⌥⇧⌘H: flips Hyphenate for the paragraphs the selection (or the caret) is in: off when all of +/// them hyphenate, else on. +pub(crate) fn toggle_hyphenate(app: &mut PhotocraftApp) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(t) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))) else { return }; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + let (ba, bb) = byte_range(&t.text, a, b); + let len = t.text.len(); + let mut at = 0usize; + let mut all = true; + let mut any = false; + for r in t.paragraph_runs() { + let (ps, pe) = (at, at.saturating_add(r.len)); + at = pe; + let touches = if ba == bb { ps <= ba && (ba < pe || pe >= len) } else { ps < bb && pe > ba }; + if touches { + any = true; + all &= r.style.hyphenate; + } + } + let on = !(any && all); + set_style(app, &ed, (a, b), json!({ "hyphenate": on })); +} + /// IME composition. The preedit text is written into the layer (so it lays out and reflows like /// typed text) and replaced by every update; `commit` makes the result final. fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { @@ -631,6 +766,10 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { crate::type_transform::cancel_drag(app); } let Some(ed) = app.ui.text_edit.clone() else { return false }; + // The font picker (and other focused fields) owns keyboard input while it is open. + if ctx.text_edit_focused() || ctx.egui_wants_keyboard_input() { + return true; + } let id = LayerId(ed.layer); let Some(text) = current_text(app, id) else { app.ui.text_edit = None; @@ -676,6 +815,35 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use egui::Key; let key = &flow_key(*key, vertical_flow); match key { + // Photoshop's type formatting keys act on the selected characters and win + // over the global ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse) + // while typing. Each press is one history step. + Key::K | Key::U | Key::B | Key::I | Key::Slash | Key::Questionmark if m.command && m.shift && !m.alt => { + let which = match key { + Key::K => CharToggle::AllCaps, + Key::U => CharToggle::Underline, + Key::B => CharToggle::FauxBold, + Key::I => CharToggle::FauxItalic, + _ => CharToggle::Strikethrough, + }; + toggle_char_style(app, which); + } + Key::H if m.command && m.shift && m.alt => toggle_hyphenate(app), + // ⌥↑/↓ leading -/+2 pt, ⇧⌥↑/↓ baseline shift +/-2 pt (10 with ⌘/Ctrl). + Key::ArrowUp | Key::ArrowDown if m.alt => { + let step = if m.command { 10.0 } else { 2.0 }; + let up = *key == Key::ArrowUp; + if m.shift { + step_char_metric(app, "baselineShiftBy", if up { step } else { -step }); + } else { + step_char_metric(app, "leadingBy", if up { -step } else { step }); + } + } + // ⌥←/→ over a selection tracks it by -/+20 (100 with ⌘/Ctrl). + Key::ArrowLeft | Key::ArrowRight if m.alt && !m.shift && a != b => { + let step = if m.command { 100.0 } else { 20.0 }; + step_char_metric(app, "trackingBy", if *key == Key::ArrowRight { step } else { -step }); + } Key::Backspace | Key::Delete => { if a == b { let fwd = *key == Key::Delete; @@ -849,7 +1017,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if crate::type_transform::draw(app, painter, xf, mods) { return; } - let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape)); + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { @@ -985,11 +1153,11 @@ fn draw_mask_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor } /// Font families: bundled, system, and those the host serves (`photocraft_text::served`, fetched -/// when picked). Cached until fonts are registered or the served catalog changes. +/// when picked). Cached until the shared engine registers fonts or the served catalog changes. pub fn families() -> Arc> { type Cached = Option<((u64, u64), Arc>)>; static FAMILIES: std::sync::Mutex = std::sync::Mutex::new(None); - let key = (photocraft_text::fonts::generation(), photocraft_text::served::generation()); + let key = (photocraft_text::fonts::shared_generation(), photocraft_text::served::generation()); let mut cached = FAMILIES.lock().unwrap_or_else(PoisonError::into_inner); if let Some((k, v)) = cached.as_ref() && *k == key @@ -1027,11 +1195,11 @@ pub fn sync_font_names(ctx: &egui::Context, show_in_english: bool) { }); } -/// The fonts' localized family names, keyed by family. Cached until fonts are registered. +/// The fonts' localized family names, keyed by family. Cached until the shared engine registers fonts. fn localized_families() -> Arc> { type Cached = Option<(u64, Arc>)>; static LOCALIZED: std::sync::Mutex = std::sync::Mutex::new(None); - let key = photocraft_text::fonts::generation(); + let key = photocraft_text::fonts::shared_generation(); let mut cached = LOCALIZED.lock().unwrap_or_else(PoisonError::into_inner); if let Some((k, v)) = cached.as_ref() && *k == key @@ -1733,8 +1901,14 @@ fn num_field(ui: &mut egui::Ui, label: &str, tip: &str, v: f32, range: std::ops: ui.spacing_mut().item_spacing.x = 4.0; let (r, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover()); ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, crate::theme::semibold(10.0), t.text_dim); - crate::widgets::scrub_label(ui, &resp.on_hover_text(tip)); - if crate::widgets::value_field(ui, &mut v, range, unit, width).changed() { + crate::widgets::scrub_label(ui, &resp.on_hover_text(tl!(tip))); + let response = crate::widgets::value_field(ui, &mut v, range, unit, width); + response.widget_info(|| { + let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), f64::from(v)); + info.label = Some(tl!(tip).to_owned()); + info + }); + if response.changed() { out = Some(v); } }); @@ -2221,6 +2395,86 @@ mod tests { current_text(app, LayerId(ed.layer)).unwrap() } + /// A committed type layer reading `s` at the same spot as the others (stacked variants, #4320). + fn stacked_variant(app: &mut PhotocraftApp, s: &str) -> LayerId { + app.ui.text_edit = None; + pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + insert(app, s); + commit(app); + app.session.active().unwrap().active_layer.unwrap() + } + + fn hide(app: &mut PhotocraftApp, id: LayerId) { + app.run("layer.setProps", json!({"layer": id.0, "visible": false})).unwrap(); + } + + /// Wraps `id` in a new group and returns the group. + fn group_of(app: &mut PhotocraftApp, id: LayerId) -> LayerId { + app.run("layer.select", json!({"layer": id.0, "mode": "replace"})).unwrap(); + app.run("layer.groupLayers", json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + let path = doc.path_of(id).unwrap(); + doc.layer_at(&path[..path.len() - 1]).unwrap().id + } + + /// Three type variants at one spot, bottom to top: A, B, C. + fn variants() -> (PhotocraftApp, [LayerId; 3]) { + let mut app = app(); + let ids = ["Variant A", "Variant B", "Variant C"].map(|s| stacked_variant(&mut app, s)); + (app, ids) + } + + const ON_TEXT: (f64, f64) = (60.0, 95.0); + + /// #4320: variants hidden through their groups above the shown one are not picked. + #[test] + fn type_click_skips_text_in_hidden_groups_above_the_shown_variant() { + let (mut app, [a, b, c]) = variants(); + for id in [b, c] { + let g = group_of(&mut app, id); + hide(&mut app, g); + } + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a)); + assert!(pointer_down(&mut app, ON_TEXT.0, ON_TEXT.1, egui::Modifiers::NONE)); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(a.0), "the click edits the shown variant"); + } + + #[test] + fn type_click_skips_text_in_a_nested_hidden_group() { + let (mut app, [a, b, c]) = variants(); + hide(&mut app, b); + let inner = group_of(&mut app, c); + let outer = group_of(&mut app, inner); + hide(&mut app, outer); + assert!(app.session.active().unwrap().doc.layer(c).unwrap().visible, "only the outer group is hidden"); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a)); + } + + #[test] + fn type_click_skips_own_hidden_variants_above_and_below() { + let (mut app, [a, b, c]) = variants(); + hide(&mut app, b); + hide(&mut app, c); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a), "hidden variants above"); + let (mut app, [a, b, c]) = variants(); + hide(&mut app, a); + hide(&mut app, b); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(c), "hidden variants below"); + } + + /// The Move tool's double-click on type (#1132) shares the pick. + #[test] + fn move_double_click_edits_the_shown_variant_not_one_in_a_hidden_group() { + let (mut app, [a, b, c]) = variants(); + for id in [b, c] { + let g = group_of(&mut app, id); + hide(&mut app, g); + } + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(a.0)); + } + /// Photoshop: a click on existing type edits it; a ⇧-click there starts a new type layer. #[test] fn shift_click_on_existing_type_starts_new_text() { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index a6b045bc1b..0f5be9e2fa 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -14,6 +14,65 @@ use super::{layout, text_layer}; use crate::PhotocraftApp; use crate::canvas::ViewXform; +#[test] +fn focused_font_search_does_not_type_into_the_canvas_layer() { + let mut app = new_app(); + let id = app.run("type.create", json!({"text": "Sample", "size": 24})).unwrap()["layer"].as_u64().unwrap(); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 6, + anchor: 6, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + let search_id = egui::Id::new("font-search-regression"); + let mut query = ui.data(|d| d.get_temp::(search_id)).unwrap_or_default(); + ui.add(egui::TextEdit::singleline(&mut query).id(search_id)).request_focus(); + ui.data_mut(|d| d.insert_temp(search_id, query)); + super::handle_keys(app, ui.ctx()); + }, + app, + ); + h.run_steps(2); + h.event(egui::Event::Text("Ins".into())); + h.run(); + assert_eq!(h.ctx.data(|d| d.get_temp::(egui::Id::new("font-search-regression"))).as_deref(), Some("Ins")); + assert_eq!(super::current_text(h.state_mut(), LayerId(id)).as_deref(), Some("Sample")); +} + +#[test] +fn numeric_type_fields_have_localized_accessible_names() { + let pt = crate::i18n::Lang::from_code("pt-br").unwrap(); + let mut h = Harness::builder().build_ui_state( + move |ui, value: &mut f32| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::i18n::with_language(pt, || { + if let Some(next) = super::num_field(ui, "tT", "Font size", *value, 1.0..=999.0, "pt", 80.0) { + *value = next; + } + }); + }, + 24.0, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tamanho da fonte").click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("36".into())); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), 36.0); +} + fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { make_harness(ppp, app, false) } @@ -1333,8 +1392,10 @@ mod multi_layer_formatting { h } - fn number(h: &mut Harness<'static, PhotocraftApp>, index: usize, value: &str) { - h.get_all_by_role(Role::SpinButton).nth(index).expect("numeric field").click(); + fn number(h: &mut Harness<'static, PhotocraftApp>, label: &str, value: &str) { + // Variable-font axes add numeric fields before size/leading. Select the setting by + // its accessible name, so a font scan cannot shift this action onto another control. + h.get_by_role_and_label(Role::SpinButton, label).click(); h.run(); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text(value.into())); @@ -1399,6 +1460,33 @@ mod multi_layer_formatting { assert_ne!(text(h.state(), ids[2]).antialias, AntiAlias::Sharp); } + /// Other engines registering fonts and the served catalog growing (tests running in parallel) + /// used to move the key of the font menu's preview cache, so its samples re-rendered and asked + /// for a repaint every pass and `Harness::run` never settled (a flaky `exceeded max_steps`). + /// Here both happen on every pass. + #[test] + fn the_font_menu_settles_while_other_engines_register_fonts() { + let (app, _) = fixture(); + let mut pass = 0u32; + let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).build_ui_state( + move |ui, app| { + photocraft_text::TextEngine::new(); + // The served catalog is process-wide: names that sort last. + photocraft_text::served::add_families([format!("zz Churn Test {pass:03}")]); + pass = pass.saturating_add(1); + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::super::options_bar(app, ui)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.run(); + h.run(); + } + #[test] fn properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary() { let (mut app, ids) = fixture(); @@ -1409,16 +1497,16 @@ mod multi_layer_formatting { let raster = LayerId(app.run("layer.new.layer", json!({"name": "Selected raster"})).unwrap()["layer"].as_u64().unwrap()); selected(&mut app, &[ids[0], ids[1], raster]); let mut h = controls(app, true); - number(&mut h, 0, "60"); + number(&mut h, "Font size", "60"); // Differ from the automatic 72 pt leading so this is an actual property change. - number(&mut h, 1, "84"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "84"); h.get_by_label("Faux Bold").click(); h.run(); h.get_by_label("Center text").click(); h.run(); h.get_by_label("Hyphenate").click(); h.run(); - number(&mut h, 6, "9"); + number(&mut h, "Indent left margin", "9"); for (id, scale) in [(ids[0], 2.0), (ids[1], 4.0)] { let c = style(h.state(), id, 0); assert_eq!(c.size_pt * scale, 60.0); @@ -1435,7 +1523,7 @@ mod multi_layer_formatting { let peer = text(h.state(), ids[1]).char_runs(); editing(h.state_mut(), ids[0]); h.run(); - number(&mut h, 0, "40"); + number(&mut h, "Font size", "40"); assert_eq!((size_at(h.state(), ids[0], 0), size_at(h.state(), ids[0], 1), size_at(h.state(), ids[0], 3)), (30.0, 20.0, 30.0)); assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index d99334cba9..2a68366d6e 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -85,7 +85,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { return None; } let LayerContent::Text(text) = &layer.content else { return None }; - let shape = text.shape; + let shape = text.shape.clone(); let size = text.size_pt; let (layout, aff, _) = crate::type_tool::layout(app, id)?; if !aff.m.iter().all(|v| v.is_finite()) || aff.inverse().is_none() { @@ -126,6 +126,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { made: None, mode: Default::default(), content_aware: None, + skew_v: 0.0, }) } diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 3a5dade0e2..b4e6cca168 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -187,14 +187,31 @@ fn pen_to_json(pen: &PenPath, closed: bool) -> Value { json!({"subpaths": [{"closed": closed, "knots": pen.knots.iter().enumerate().map(|(i, k)| json!({"anchor": k[0], "in": k[1], "out": k[2], "smooth": !pen.unlinked.contains(&i) && (k[1] != k[0] || k[2] != k[0])})).collect::>()}]}) } +/// Is `(x, y)` on the first anchor of a Pen path that a click there would close (two anchors or +/// more)? `tol` is the anchors' hit radius in document pixels. +fn on_closing_anchor(pen: &PenPath, x: f64, y: f64, tol: f64) -> bool { + pen.knots.len() >= 2 && pen.knots.first().is_some_and(|k| (k[0][0] - x).hypot(k[0][1] - y) < tol) +} + +/// Would a click at `(x, y)` with `tool` close the path being drawn: the Pen over the first +/// anchor, or the Curvature Pen over the first of three anchors or more? The canvas shows the +/// close-path pointer there, as Photoshop does (#4596). +pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { + let Some(pen) = app.ui.pen.as_ref().filter(|p| !p.dragging && p.moving.is_none()) else { return false }; + match tool { + Tool::Pen => !pen.curvature && on_closing_anchor(pen, x, y, 6.0 / app.point_zoom().max(0.01) as f64), + Tool::CurvaturePen => pen.curvature && pen.knots.len() >= 3 && curvature_hit(app, x, y) == Some(0), + _ => false, + } +} + /// Pen press: close on the first anchor, reshape the last anchor's outgoing handle, /// or add a new anchor (dragging a new anchor pulls symmetrical handles). pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) { let tol = 6.0 / app.point_zoom().max(0.01) as f64; let pen = app.ui.pen.get_or_insert_with(PenPath::default); if let Some(first) = pen.knots.first().map(|k| k[0]) - && pen.knots.len() >= 2 - && (first[0] - x).hypot(first[1] - y) < tol + && on_closing_anchor(pen, x, y, tol) { // Pressing the first anchor closes the path on release. Until then a stand-in for it // takes the drag, which pulls out that anchor's handles (close-drag, #3306). @@ -304,6 +321,12 @@ pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { true } +/// Would a press at `at` close the Pen path: is the pointer on the first anchor of a path that +/// already has a segment, and no drag is under way? The canvas then shows the close cue (#4193). +pub(crate) fn over_first_anchor(pen: &PenPath, at: [f64; 2], tol: f64) -> bool { + !pen.dragging && pen.moving.is_none() && pen.knots.len() >= 2 && pen.knots.first().is_some_and(|k| (k[0][0] - at[0]).hypot(k[0][1] - at[1]) < tol) +} + /// Is a close-drag in progress: does the path end in the stand-in for its first anchor? fn closing(pen: &PenPath) -> bool { pen.knots.len() >= 3 && pen.knots.first().map(|k| k[0]) == pen.knots.last().map(|k| k[0]) @@ -541,6 +564,18 @@ pub fn takes_path_mask(id: &str) -> bool { id.starts_with("layer.newFillLayer.") || id.starts_with("layer.newAdjustmentLayer.") } +/// The work or saved path explicitly selected in the Paths panel (not a layer's own path). +pub fn selected_document_path(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + let sel = app.ui.selected_path.as_deref()?; + let listed = path_rows(&st.doc, st.active_layer).iter().any(|r| match r.kind { + PathRow::Work => sel == "work", + PathRow::Saved => r.name == sel, + PathRow::Layer => false, + }); + listed.then(|| sel.to_string()) +} + /// New fill and adjustment layers take the path selected in the Paths panel as their vector mask, /// as in Photoshop (#1419): the command gets it as `"path"` unless the caller set one (`null` /// opts out). Only an explicitly selected work or saved path counts; deselect it by clicking the @@ -549,16 +584,7 @@ pub fn with_active_path(app: &PhotocraftApp, id: &str, params: Value) -> Value { if !takes_path_mask(id) || params.get("path").is_some() { return params; } - let Some(st) = app.session.active() else { return params }; - let Some(sel) = app.ui.selected_path.as_deref() else { return params }; - let listed = path_rows(&st.doc, st.active_layer).iter().any(|r| match r.kind { - PathRow::Work => sel == "work", - PathRow::Saved => r.name == sel, - PathRow::Layer => false, - }); - if !listed { - return params; - } + let Some(sel) = selected_document_path(app) else { return params }; match params { Value::Object(mut m) => { m.insert("path".into(), json!(sel)); @@ -757,6 +783,45 @@ fn bezier(p0: [f64; 2], c0: [f64; 2], c1: [f64; 2], p1: [f64; 2], t: f64) -> [f6 [f(p0[0], c0[0], c1[0], p1[0]), f(p0[1], c0[1], c1[1], p1[1])] } +/// `q` lies on the chord from `a` to `d` (within a thousandth of a pixel). +fn on_chord(a: [f64; 2], q: [f64; 2], d: [f64; 2]) -> bool { + let (dx, dy, vx, vy) = (d[0] - a[0], d[1] - a[1], q[0] - a[0], q[1] - a[1]); + let len = dx.hypot(dy); + if len < 1e-9 { + return vx.hypot(vy) < 1e-3; + } + let t = (vx * dx + vy * dy) / (len * len); + (vx * dy - vy * dx).abs() / len < 1e-3 && (-1e-9..=1.0 + 1e-9).contains(&t) +} + +/// How far (in screen pixels) a drawn chord may stray from the true curve. +const CURVE_TOLERANCE: f64 = 0.3; +/// Pieces per segment at most: 0.3 px holds for curves bending up to about 1.4 million px on screen. +const MAX_CURVE_PIECES: usize = 1024; + +/// Screen points along one cubic segment (both ends included). The control points are mapped to +/// the screen first (`xf` is affine, so the curve maps exactly), and the number of chords follows +/// how much the curve bends there: a straight segment is one chord, and a curve zoomed in stays +/// smooth instead of showing a fixed 16 chords (#4084). Uses the bound +/// `max|B''| / 8n² <= tol` with `max|B''| <= 6·max(|p0−2c0+c1|, |c0−2c1+p1|)`. +pub(crate) fn curve_points(p0: [f64; 2], c0: [f64; 2], c1: [f64; 2], p1: [f64; 2], xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec { + let [a, b, c, d] = [p0, c0, c1, p1].map(|q| { + let s = xf(q); + [f64::from(s.x), f64::from(s.y)] + }); + let second = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (p[0] - 2.0 * q[0] + r[0]).hypot(p[1] - 2.0 * q[1] + r[1]); + let n = (0.75 * second(a, b, c).max(second(b, c, d)) / CURVE_TOLERANCE).sqrt().ceil(); + // A straight segment (controls on the chord, like the Pen's retracted handles) is one chord; + // NaN (a non-finite point) falls back to one too. + let n = if n.is_finite() && !(on_chord(a, b, d) && on_chord(a, c, d)) { (n as usize).clamp(1, MAX_CURVE_PIECES) } else { 1 }; + (0..=n) + .map(|k| { + let q = bezier(a, b, c, d, k as f64 / n as f64); + pos2(q[0] as f32, q[1] as f32) + }) + .collect() +} + /// Screen polylines for a path (one per subpath). pub(crate) fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec, bool)> { path.subpaths @@ -767,11 +832,7 @@ pub(crate) fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec let segs = if s.closed { n } else { n.saturating_sub(1) }; for i in 0..segs { let (a, b) = (&s.knots[i], &s.knots[(i + 1) % n]); - for k in 0..=16 { - let q = - bezier([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], k as f64 / 16.0); - pts.push(xf(q)); - } + pts.extend(curve_points([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], xf)); } if pts.is_empty() && let Some(k) = s.knots.first() @@ -846,6 +907,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some((_, p)) = targeted_vector_mask(app) { draw_path(&p, tool == Tool::PathSelection); } + // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete. + crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. if let Some(pen) = &app.ui.pen { @@ -860,9 +923,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform first[2] = out; } for w in knots.windows(2) { - for k in 0..=16 { - pts.push(to_scr(bezier(w[0][0], w[0][2], w[1][1], w[1][0], k as f64 / 16.0))); - } + pts.extend(curve_points(w[0][0], w[0][2], w[1][1], w[1][0], &to_scr)); } painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) @@ -875,7 +936,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform next.knots.push([h; 3]); curvature_refresh(&mut next, false); if let [.., a, b] = next.knots.as_slice() { - let pts: Vec = (0..=24).map(|k| to_scr(bezier(a[0], a[2], b[1], b[0], k as f64 / 24.0))).collect(); + let pts = curve_points(a[0], a[2], b[1], b[0], &to_scr); painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, accent), 4.0, 3.0)); } } else { @@ -892,6 +953,16 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } draw_anchor(painter, c, last, accent); } + // Hovering the first anchor: Photoshop's close cue, a small ring beside the pointer. + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + if let Some(h) = app.hover_doc + && over_first_anchor(pen, h, tol) + && let Some(first) = pen.knots.first() + { + let c = to_scr(first[0]); + painter.rect_filled(Rect::from_center_size(c, vec2(8.0, 8.0)), 0.0, accent); + painter.circle_stroke(c + vec2(11.0, 11.0), 3.5, Stroke::new(1.5, accent)); + } } } @@ -951,6 +1022,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo o.stroke_width = enabled_stroke_width(o.stroke_width); } } + if tool == Tool::Pen { + crate::widgets::vline(ui, 22.0); + crate::widgets::checkbox(ui, &mut o.pen_auto_add_delete, tl!("Auto Add/Delete")); + } let enter = crate::shortcuts::pretty("Enter"); match tool { Tool::FreeformPen => { @@ -1691,6 +1766,60 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #4084: zoomed in, a curve's chords stay within a fraction of a pixel of the true curve + /// (16 fixed chords showed corners), and a straight segment stays one chord. + #[test] + fn path_curves_stay_smooth_at_any_zoom() { + let (p0, c0, c1, p1) = ([0.0, 100.0], [20.0, -60.0], [80.0, -60.0], [100.0, 100.0]); + for zoom in [0.25, 1.0, 8.0, 64.0, 320.0] { + let xf = move |q: [f64; 2]| pos2((q[0] * zoom) as f32 + 10.0, (q[1] * zoom) as f32 - 5.0); + let pts = curve_points(p0, c0, c1, p1, &xf); + let n = pts.len() - 1; + assert_eq!((pts[0], pts[n]), (xf(p0), xf(p1)), "the ends are the anchors"); + for (k, w) in pts.windows(2).enumerate() { + let curve = xf(bezier(p0, c0, c1, p1, (k as f64 + 0.5) / n as f64)); + let chord = w[0] + (w[1] - w[0]) * 0.5; + assert!((chord - curve).length() <= 0.35, "zoom {zoom}: chord {k} of {n} is {} px off", (chord - curve).length()); + } + // Zoomed out it stays cheap. + if zoom <= 1.0 { + assert!(n <= 32, "zoom {zoom}: {n} chords"); + } + } + let xf = |q: [f64; 2]| pos2((q[0] * 320.0) as f32, (q[1] * 320.0) as f32); + // Retracted handles, and handles along the line: one chord. + assert_eq!(curve_points([0.0, 0.0], [0.0, 0.0], [50.0, 50.0], [50.0, 50.0], &xf).len(), 2); + assert_eq!(curve_points([0.0, 0.0], [10.0, 10.0], [40.0, 40.0], [50.0, 50.0], &xf).len(), 2); + // A handle past the end is a curve that doubles back, not a line. + assert!(curve_points([0.0, 0.0], [0.0, 0.0], [80.0, 80.0], [50.0, 50.0], &xf).len() > 2); + // Hostile coordinates: no panic, a bounded number of points. + for bad in [f64::NAN, f64::INFINITY, 1e300] { + assert!(curve_points([0.0, 0.0], [bad, 0.0], [0.0, bad], [1.0, 1.0], &xf).len() <= MAX_CURVE_PIECES + 1); + } + // Through path_lines too: a closed subpath of two curved segments. + let mut path = Path::default(); + path.subpaths.push(photocraft_doc::vector::Subpath { + knots: vec![ + photocraft_doc::vector::Knot { + anchor: photocraft_geom::Point::new(0.0, 0.0), + in_ctrl: photocraft_geom::Point::new(0.0, 40.0), + out_ctrl: photocraft_geom::Point::new(0.0, -40.0), + smooth: true, + }, + photocraft_doc::vector::Knot { + anchor: photocraft_geom::Point::new(60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(60.0, -40.0), + out_ctrl: photocraft_geom::Point::new(60.0, 40.0), + smooth: true, + }, + ], + closed: true, + ..Default::default() + }); + let lines = path_lines(&path, &xf); + assert!(lines[0].1 && lines[0].0.len() > 2 * 17, "zoomed in, more than the old 16 chords per segment: {}", lines[0].0.len()); + } + #[test] fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() { for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape] { @@ -1881,6 +2010,21 @@ mod tests { assert!(photocraft_engine::vector_cmds::shape_path(&json!({"kind": "rect"})).is_err()); } + #[test] + fn pen_shows_the_close_cue_only_over_the_first_anchor_of_a_real_segment() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0); + pen_up(&mut app); + let one = app.ui.pen.clone().unwrap(); + assert!(!over_first_anchor(&one, [20.0, 20.0], 6.0), "a single anchor has nothing to close"); + pen_down(&mut app, 120.0, 20.0); + pen_up(&mut app); + let two = app.ui.pen.clone().unwrap(); + assert!(over_first_anchor(&two, [22.0, 21.0], 6.0)); + assert!(!over_first_anchor(&two, [60.0, 60.0], 6.0)); + } + #[test] fn pen_builds_and_closes_a_work_path() { let mut app = app(); @@ -1904,6 +2048,39 @@ mod tests { assert_eq!((wp.subpaths[0].knots[0].anchor.x, wp.subpaths[0].knots[0].anchor.y), (30.0, 25.0)); } + /// #4596: the close-path pointer shows where a click would close the path, and only there: + /// over the first anchor once the Pen has two anchors (the Curvature Pen three), not while an + /// anchor is being dragged. + #[test] + fn close_path_pointer_shows_where_a_click_closes() { + let mut app = app(); + app.ui.tool = Tool::Pen; + assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "no path yet"); + pen_down(&mut app, 20.0, 20.0); + pen_up(&mut app); + assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "one anchor: a click there doesn't close"); + pen_down(&mut app, 120.0, 20.0); + assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "not while dragging the new anchor"); + pen_up(&mut app); + assert!(closes_path_at(&app, Tool::Pen, 21.0, 22.0), "over the first anchor"); + assert!(!closes_path_at(&app, Tool::Pen, 120.0, 20.0) && !closes_path_at(&app, Tool::Pen, 60.0, 60.0), "the last anchor and empty canvas"); + assert!(!closes_path_at(&app, Tool::CurvaturePen, 20.0, 20.0), "a Pen path isn't the Curvature Pen's"); + pen_down(&mut app, 21.0, 22.0); + pen_up(&mut app); + assert!(app.ui.pen.is_none() && !closes_path_at(&app, Tool::Pen, 20.0, 20.0), "the click closed it"); + + app.ui.tool = Tool::CurvaturePen; + for (x, y) in [(20.0, 20.0), (120.0, 20.0)] { + curvature_down(&mut app, x, y); + curvature_up(&mut app); + } + assert!(!closes_path_at(&app, Tool::CurvaturePen, 20.0, 20.0), "two anchors: the Curvature Pen moves the first"); + curvature_down(&mut app, 120.0, 120.0); + curvature_up(&mut app); + assert!(closes_path_at(&app, Tool::CurvaturePen, 20.0, 20.0)); + assert!(!closes_path_at(&app, Tool::CurvaturePen, 120.0, 120.0)); + } + #[test] fn pen_point_undo_preserves_earlier_knots_and_document_history() { let mut app = app(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 33fc6affc5..f18c4730e2 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -96,6 +96,9 @@ pub struct ViewOptions { pub middle_eastern_composer: bool, /// Parameters from the last successfully applied New Guide Layout dialog. pub guide_layout: Value, + /// Developer overlay of the active layer's storage tiles (tile_overlay.rs), set with the + /// control channel's `ui.set {"tileGrid": true}`. + pub tile_grid: bool, } /// Factory New Guide Layout values (Alt+Reset and a fresh ViewOptions). @@ -121,6 +124,7 @@ impl Default for ViewOptions { language_features: "defaultFeatures".into(), middle_eastern_composer: false, guide_layout: default_guide_layout(), + tile_grid: false, } } } @@ -1084,7 +1088,7 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let mut s = s.clone(); match choices.get(&k).and_then(Value::as_array) { Some(opts) => { - let opts: Vec<(String, String)> = opts.iter().filter_map(Value::as_str).map(|o| (o.to_string(), label_of(o))).collect(); + let opts: Vec<(String, String)> = opts.iter().filter_map(Value::as_str).map(|o| (o.to_string(), label(o))).collect(); let refs: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); crate::widgets::dropdown(ui, &format!("form-{k}"), &mut s, &refs, 200.0); } @@ -1218,7 +1222,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { let (w, h, _) = doc?; - dialog(app, json!({"width": w, "height": h, "dontEnlarge": false}), json!({})) + dialog(app, json!({"width": w, "height": h, "dontEnlarge": false, "__compact": true}), json!({})) } "file.automate.conditionalModeChange" => dialog( app, @@ -1275,7 +1279,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option dialog(app, json!({"paths": dir}), json!({})), + "file.scripts.loadFilesIntoStack" => Some(Ok(json!({"dialog": crate::stack_dialog::open(app)}))), // Photography automation (photo_cmds / lens_cmds): a folder (or the open documents). "file.automate.photomerge" => dialog( app, @@ -1365,10 +1369,20 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { let mut masks = vec!["none".to_string()]; masks.extend(load.iter().cloned()); + // Source layer: Merged, then the pixel layers top to bottom (the engine takes + // the topmost layer of a name), shown by their own names. + let mut layers = vec!["merged".to_string()]; + for (_, _, l) in d.walk().into_iter().rev() { + if l.surface().is_some() && !layers.contains(&l.name) { + layers.push(l.name.clone()); + } + } + let names: Map = layers.iter().skip(1).map(|n| (n.clone(), json!(n))).collect(); + // Scale and Offset only affect Add and Subtract. dialog( app, - json!({"sourceChannel": chans[0], "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "preserveTransparency": false, "maskChannel": "none"}), - json!({"sourceChannel": load, "blending": blends, "maskChannel": masks}), + json!({"sourceLayer": "merged", "sourceChannel": chans[0], "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "scale": 1.0, "offset": 0, "preserveTransparency": false, "maskChannel": "none", "__labels": names}), + json!({"sourceLayer": layers, "sourceChannel": load, "blending": blends, "maskChannel": masks}), ) } _ => dialog( @@ -1383,7 +1397,8 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Result { let doc = app.session.active().ok_or("no document")?.doc.clone(); let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a); @@ -1394,27 +1409,40 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { let parked = app.with_document(doc.id, |app| { let ask = crate::targa_options_ui::wants_prompt(app, &path); if ask { - crate::targa_options_ui::park(app, path.clone(), true, false)?; + crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; } Ok(ask) })?; if parked { return Ok(json!({"path": path, "warnings": []})); } - let settings = app.save_settings(); - let export = app.services.export.as_ref().ok_or("no exporter configured")?; - let (bytes, warnings) = export(&doc, &path, &settings)?; - let write = app.services.write.as_mut().ok_or("no writer configured")?; - write(&path, &bytes)?; - let template = "Saved a copy as {path}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("path", &path)]); - app.ui.status_error = false; - let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(&path))]); - crate::notices::io_warnings(app, &what, &warnings); - Ok(json!({"path": path, "warnings": warnings})) + let r = write_copy(app, &doc, &path, None)?; + app.session.journal.push(("file.saveACopy".into(), json!({"path": path}))); + Ok(r) }) } +/// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's +/// path and saved state are untouched. `tga_bits` is the Targa answer, when there is one. +pub(crate) fn write_copy( + app: &mut PhotocraftApp, + doc: &photocraft_doc::Document, + path: &str, + tga_bits: Option, +) -> Result { + let settings = crate::ExportSettings { tga_bits, ..app.save_settings() }; + let export = app.services.export.as_ref().ok_or("no exporter configured")?; + let (bytes, warnings) = export(doc, path, &settings)?; + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(path, &bytes)?; + let template = "Saved a copy as {path}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path)]); + app.ui.status_error = false; + let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(path))]); + crate::notices::io_warnings(app, &what, &warnings); + Ok(json!({"path": path, "warnings": warnings})) +} + /// Layer › Smart Objects › Export Contents: saves the contents file under its own name by default. fn export_contents(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index b930e6e26e..7ef3100d9e 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -255,6 +255,45 @@ fn apply_image_and_calculations_open_dialogs_without_editing() { } } +#[test] +fn apply_image_dialog_offers_source_layer_scale_and_offset() { + let (mut app, ctx) = app_with(0); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.run("edit.fill", json!({"color": "#c08040"})).unwrap(); + app.run("layer.new.layer", json!({"name": "Skin High"})).unwrap(); + app.run("edit.fill", json!({"color": "#406080"})).unwrap(); + app.sync_views(); + let background = app.session.active().unwrap().doc.walk()[0].2.name.clone(); + let open = |app: &mut PhotocraftApp| menu(app, &ctx, "image.applyImage", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let d = open(&mut app); + let fields = &app.ui.dialog_mut(d).unwrap().fields; + assert_eq!(fields["sourceLayer"], "merged"); + assert_eq!(fields["scale"], 1.0); + assert_eq!(fields["offset"], 0); + // Merged first, then the layers top to bottom, shown by their own names. + assert_eq!(fields["__choices"]["sourceLayer"], json!(["merged", "Skin High", background])); + assert_eq!(fields["__labels"]["Skin High"], "Skin High"); + // The frequency separation recipe: a layer subtracted from itself at Scale 2, Offset 128 is mid grey. + for (k, v) in [("sourceLayer", json!("Skin High")), ("blending", json!("subtract")), ("scale", json!(2.0)), ("offset", json!(128))] { + app.ui.dialog_mut(d).unwrap().fields.insert(k.into(), v); + } + crate::dialogs::confirm(&mut app, d).unwrap(); + let st = app.session.active().unwrap(); + let p = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(3, 3); + assert!(p[..3].iter().all(|v| (v - 128.0 / 255.0).abs() < 0.003), "{p:?}"); + app.run("edit.undo", json!({})).unwrap(); + // The chosen layer is the source, not the merged image. + let d = open(&mut app); + for (k, v) in [("sourceLayer", json!(background)), ("blending", json!("subtract")), ("scale", json!(2.0)), ("offset", json!(128))] { + app.ui.dialog_mut(d).unwrap().fields.insert(k.into(), v); + } + crate::dialogs::confirm(&mut app, d).unwrap(); + let st = app.session.active().unwrap(); + let p = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(3, 3); + let want = (64.0 - 192.0) / 255.0 / 2.0 + 128.0 / 255.0; + assert!((p[0] - want).abs() < 0.003, "{p:?} vs {want}"); +} + #[test] fn new_guide_layout_remembers_successful_values_not_cancelled_edits() { let (mut app, ctx) = app_with(1); diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs index bdaad601c6..bd0353d678 100644 --- a/crates/ui-egui/src/vp_ui.rs +++ b/crates/ui-egui/src/vp_ui.rs @@ -19,6 +19,7 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::canvas::{ToolEvent, ViewXform}; +use crate::edit_history::{EditHistory, Step}; #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -63,8 +64,12 @@ pub struct VpSession { pub new_layer: bool, clip: Option, drag: Option, + history: EditHistory, } +/// What ⌘Z steps through: the planes and where the paste sits on them. +type VpEdit = (Vec, [f64; 2], f64); + /// Most planes a session holds (the control channel can't make it allocate without bound). const MAX_PLANES: usize = 64; /// Most grid lines drawn per direction. @@ -152,6 +157,24 @@ impl VpSession { } } + fn edit(&self) -> VpEdit { + (self.planes.clone(), self.paste_at, self.paste_width) + } + + fn settle(&mut self, busy: bool) { + let now = self.edit(); + self.history.settle(&now, busy || self.drag.is_some()); + } + + pub(crate) fn step(&mut self, step: Step) { + let mut now = self.edit(); + if self.history.step(&mut now, step) { + (self.planes, self.paste_at, self.paste_width) = now; + self.drag = None; + self.selected = self.selected.filter(|i| *i < self.planes.len()); + } + } + fn delete_selected(&mut self) { if let Some(i) = self.selected.take().filter(|i| *i < self.planes.len()) { self.planes.remove(i); @@ -300,6 +323,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> new_layer: false, clip, drag: None, + history: EditHistory::new(&(Vec::new(), [0.25, 0.25], 0.5)), }); app.ui.status = tl!("Vanishing Point: click four corners to create a plane").into(); Ok(()) @@ -369,6 +393,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if let Some(b) = ui.get("newLayer").and_then(Value::as_bool) { s.new_layer = b; } + s.settle(false); Ok(s.describe()) } @@ -383,6 +408,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) { s.drag = None; } } + s.settle(false); } /// Keys while the mode is active (↩ and Esc are handled by `distort_ui::keys`). @@ -400,6 +426,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { s.tool = VpTool::CreatePlane; } else if key(Key::Delete) || key(Key::Backspace) { s.delete_selected(); + s.settle(false); } } @@ -551,6 +578,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.distort.vanishing = None; } }); + if let Some(s) = app.distort.vanishing.as_mut() { + s.settle(ui.input(|i| i.pointer.any_down())); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 4d07537df1..42055a701f 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -11,7 +11,8 @@ //! [`configure`] makes egui fold ⌘/Ctrl + wheel into a sideways scroll as it does ⇧ + wheel, so //! only a real pinch (egui's `Event::Zoom`, or a touch screen) reaches `zoom_delta`. macOS and //! browsers deliver a pinch as its own event, but Windows hands a precision touchpad pinch to apps -//! as Ctrl + wheel in fractions of a notch: [`fold_legacy_pinch`] turns those back into a pinch +//! as Ctrl + wheel in fractions of a notch (also on some Linux Wayland systems): +//! [`fold_legacy_pinch`] turns those back into a pinch //! before egui sees them, so Ctrl + a notched mouse wheel scrolls sideways and the pinch still //! zooms. //! @@ -89,24 +90,28 @@ pub fn configure(ctx: &Context) { }); } -/// Turn a Windows touchpad pinch, which arrives as Ctrl + wheel in fractions of a notch, into +/// Turn a Windows or Linux touchpad pinch, which arrives as Ctrl + wheel in fractions of a notch or points, into /// pinch events (`Event::Zoom`) at egui's Ctrl-scroll zoom speed (`notch` points per line, /// `speed` per point). Ctrl + wheel in whole notches (a mouse wheel) is left to scroll sideways, /// unless it comes within [`PINCH_GAP`] of a pinch step. `last` is the time of the previous pinch /// step; returns the time of the latest one. pub fn legacy_pinch_to_zoom(events: &mut [Event], now: f64, last: Option, notch: f32, speed: f32) -> Option { let ctrl_wheel = |e: &Event| match e { - Event::MouseWheel { unit: MouseWheelUnit::Line, delta, modifiers, .. } if modifiers.ctrl => Some(*delta), + Event::MouseWheel { unit: MouseWheelUnit::Line, delta, modifiers, .. } if modifiers.ctrl => Some((*delta, false)), + // Wayland precision touchpads can use point units. These are already actual + // screen points, not line notches; normalize before the exponential below. + Event::MouseWheel { unit: MouseWheelUnit::Point, delta, modifiers, .. } if modifiers.ctrl => Some((*delta / notch.max(1.0), true)), _ => None, }; let fraction = |d: Vec2| d.x.fract() != 0.0 || d.y.fract() != 0.0; - let pinching = last.is_some_and(|t| (0.0..PINCH_GAP).contains(&(now - t))) || events.iter().filter_map(ctrl_wheel).any(fraction); + let pinching = + last.is_some_and(|t| (0.0..PINCH_GAP).contains(&(now - t))) || events.iter().filter_map(ctrl_wheel).any(|(d, high_res)| high_res || fraction(d)); if !pinching { return last; } let mut latest = last; for e in events.iter_mut() { - if let Some(d) = ctrl_wheel(e) { + if let Some((d, _)) = ctrl_wheel(e) { let f = (speed * notch * (d.x + d.y)).exp(); *e = Event::Zoom(if f.is_finite() && f > 0.0 { f } else { 1.0 }); latest = Some(now); @@ -119,7 +124,7 @@ fn pinch_id() -> Id { Id::new("pc-wheel-legacy-pinch") } -/// [`legacy_pinch_to_zoom`] for one frame's raw input (`raw_input_hook`, on Windows). +/// [`legacy_pinch_to_zoom`] for one frame's raw input (`raw_input_hook`, Windows and Linux). pub fn fold_legacy_pinch(ctx: &Context, raw: &mut RawInput) { let (notch, speed) = ctx.options(|o| (o.input_options.line_scroll_speed, o.input_options.scroll_zoom_speed)); let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time)); @@ -342,6 +347,18 @@ mod tests { assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "Ctrl + mouse wheel scrolls sideways: {pan:?} {zoom}"); } + #[test] + fn linux_wayland_point_unit_pinch_keeps_mouse_notches_for_sideways_scroll() { + // Some Wayland compositors report fractional Ctrl-wheel pinch steps in points + // rather than line units. The delta must not be multiplied by notch twice. + let mut point = [Event::MouseWheel { unit: MouseWheelUnit::Point, delta: Vec2::new(0.0, 8.0), phase: TouchPhase::Move, modifiers: CTRL }]; + assert_eq!(legacy_pinch_to_zoom(&mut point, 1.0, None, 40.0, 0.005), Some(1.0)); + assert!(matches!(point[0], Event::Zoom(f) if (f - (8.0f32 * 0.005).exp()).abs() < 1e-5)); + let mut wheel_only = [wheel(Vec2::new(0.0, 1.0), CTRL)]; + assert_eq!(legacy_pinch_to_zoom(&mut wheel_only, 2.0, Some(1.0), 40.0, 0.005), Some(1.0)); + assert!(matches!(wheel_only[0], Event::MouseWheel { .. }), "whole mouse notch is not a pinch"); + } + #[test] fn a_whole_notch_inside_a_pinch_stays_a_pinch() { let mut ev = [wheel(Vec2::new(0.0, 0.5), CTRL)]; diff --git a/crates/ui-egui/src/wide_angle_ui.rs b/crates/ui-egui/src/wide_angle_ui.rs index cd8bf0332c..35b996deea 100644 --- a/crates/ui-egui/src/wide_angle_ui.rs +++ b/crates/ui-egui/src/wide_angle_ui.rs @@ -18,6 +18,7 @@ use photocraft_raster::Surface; use serde_json::{Value, json}; use crate::PhotocraftApp; +use crate::edit_history::{EditHistory, Step}; use crate::theme::Tokens; use crate::widgets; @@ -41,6 +42,7 @@ pub struct WideAngleDialog { drag: Option<([f64; 2], [f64; 2])>, pub residual: f64, pub render_ms: f64, + history: EditHistory, } fn tex_image(s: &Surface, w: usize, h: usize) -> egui::ColorImage { @@ -85,6 +87,13 @@ impl WideAngleDialog { self.dirty = false; } + pub(crate) fn step(&mut self, step: Step) { + if self.history.step(&mut self.params, step) { + self.drag = None; + self.dirty = true; + } + } + fn command_params(&self) -> Value { let mut v = serde_json::to_value(&self.params).unwrap_or(json!({})); v["layer"] = json!(self.layer.0); @@ -122,6 +131,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> let mut d = WideAngleDialog { layer, layer_name: name, + history: EditHistory::new(¶ms), params, frame, scale: 1.0 / k as f64, @@ -188,6 +198,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val d.params.constraints.remove(i as usize); d.dirty = true; } + d.history.settle(&d.params, false); if d.dirty { d.render(ctx); } @@ -216,6 +227,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let shift = ctx.input(|i| i.modifiers.shift); egui::Area::new(egui::Id::new("awa-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.wide_angle.as_mut() else { return }; + d.history.settle(&d.params, ctx.input(|i| i.pointer.any_down())); if d.dirty { d.render(ctx); } @@ -445,4 +457,23 @@ mod tests { menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"cancel": true}})).unwrap().unwrap(); assert!(app.wide_angle.is_none()); } + + #[test] + fn command_z_steps_the_dialog_not_the_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 160, "height": 100})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#808080"})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({})).unwrap().unwrap(); + for y in [20, 60] { + menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"add": {"a": [20, y], "b": [140, y]}}})).unwrap().unwrap(); + } + crate::edit_history::tests::press_z(&mut app, &ctx, egui::Modifiers::COMMAND); + assert_eq!(app.wide_angle.as_ref().unwrap().params.constraints.len(), 1, "⌘Z removes the last constraint"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "the document beneath is untouched"); + crate::edit_history::tests::press_z(&mut app, &ctx, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(app.wide_angle.as_ref().unwrap().params.constraints.len(), 2, "⇧⌘Z puts it back"); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a3def4f2d..474bef66e4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -17,6 +17,9 @@ pub use type_ahead::list_open as dropdown_list_open; /// The setting is local to this popup's Ui and leaves other themes unchanged. pub fn style_spectrum_popup_menu(ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // The 6 pt gap between rows was outside both buttons, so a drag across a menu + // dropped the highlight (#4602). Padding stays inside the row, which is the hit target. + ui.spacing_mut().item_spacing.y = 0.0; if !t.pro { return; } @@ -26,7 +29,9 @@ pub fn style_spectrum_popup_menu(ui: &mut Ui) { v.widgets.hovered.bg_fill = t.accent; v.widgets.hovered.fg_stroke = Stroke::new(1.0, Color32::WHITE); v.widgets.hovered.bg_stroke = Stroke::NONE; - v.widgets.hovered.corner_radius = CornerRadius::same(3); + v.widgets.inactive.corner_radius = CornerRadius::ZERO; + v.widgets.hovered.corner_radius = CornerRadius::ZERO; + v.widgets.active.corner_radius = CornerRadius::ZERO; } /// An accent insertion line on one edge of `r` while a drag hovers it (vertical: on its left or, @@ -220,6 +225,25 @@ fn pro_panel( CardResponse::new(strip_resp, mresp, tabs_out) } +/// The panel menu button (hamburger in Pro, ellipsis in Studio), matching the dock and cards. +pub fn panel_menu_button(ui: &mut Ui) -> Response { + let t = Tokens::get(ui.ctx()); + let size = vec2(20.0, 20.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let tint = crate::icons::button_chrome(ui, rect, false, resp.hovered()); + if t.pro { + for k in 0..3 { + let y = rect.center().y - 3.5 + k as f32 * 3.5; + ui.painter().line_segment([pos2(rect.center().x - 5.0, y), pos2(rect.center().x + 5.0, y)], Stroke::new(1.0, tint)); + } + } else { + crate::icons::paint(ui, rect, "ellipsis", 14.0, tint); + } + let tip = tl!("Panel menu"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.on_hover_text(tip) +} + pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); @@ -276,6 +300,11 @@ pub fn compact_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeI value_field_box(ui, value, range, suffix, vec2(width, height), 0.0, FieldOpts::default()).0 } +/// A compact field whose mouse scrub wraps at the native fullscreen viewport's horizontal edges. +pub(crate) fn compact_wrapping_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, size: Vec2) -> Response { + value_field_box(ui, value, range, suffix, size, 0.0, FieldOpts { wrap_drag: true, ..Default::default() }).0 +} + /// A size dialog's unit key (`px`, `in`, `cm`, `mm`, `pt`, `pica`, `percent`) as a unit. pub fn unit_of_key(key: &str) -> photocraft_engine::prefs::Unit { use photocraft_engine::prefs::Unit; @@ -300,7 +329,7 @@ pub fn unit_decimals(key: &str) -> u32 { /// A [`value_field`] showing `decimals` decimal places instead of the one (or two, for a small /// range) it picks itself: the ruler units need their own precision (#2434). pub fn value_field_prec(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32, decimals: u32) -> Response { - value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts { decimals: Some(decimals), units: None }).0 + value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts { decimals: Some(decimals), ..Default::default() }).0 } /// A field's unit and the resolution a typed unit converts at (`1in` in a px field). @@ -316,7 +345,7 @@ pub struct FieldUnits { /// A [`value_field_prec`] without a suffix, whose unit is chosen beside it (Image Size and New /// Document's unit menus) and converts typed units at that dialog's own resolution. pub fn value_field_units(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, width: f32, decimals: u32, units: FieldUnits) -> Response { - value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units) }).0 + value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units), ..Default::default() }).0 } fn field_resolution_id() -> egui::Id { @@ -444,6 +473,7 @@ struct FieldOpts { decimals: Option, /// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]). units: Option, + wrap_drag: bool, } /// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and @@ -470,7 +500,7 @@ fn value_field_box( trailing: f32, opts: FieldOpts, ) -> (Response, Rect) { - let FieldOpts { decimals, units } = opts; + let FieldOpts { decimals, units, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); let scrub = take_label_scrub(ui, rect); @@ -518,7 +548,7 @@ fn value_field_box( ui.spacing_mut().button_padding = Vec2::ZERO; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let layout = egui::Layout::centered_and_justified(ui.layout().main_dir()); - ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals, units)).inner + ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals, units, wrap_drag)).inner }) .inner } @@ -726,6 +756,7 @@ fn number_edit( fine: bool, decimals: Option, units: crate::numeric_expression::Units, + wrap_drag: bool, ) -> Response { let (id, ctx) = (ui.next_auto_id(), ui.ctx().clone()); // Focused before the DragValue is drawn, so it gains focus this frame and selects its text. @@ -740,10 +771,14 @@ fn number_edit( let math = ui.memory(|m| m.has_focus(id)) && ui.data(|d| d.get_temp(held)).unwrap_or(false); ui.data_mut(|d| d.insert_temp(held, math)); let before = *value; + let speed = if fine { 0.01 } else { 0.5 }; + let wrapping = wrap_drag && crate::scrub_wrap::active(ui); + let wrapped_drag = wrapping && ui.input(|i| i.pointer.primary_down()); let mut resp = ui.add( egui::DragValue::new(value) - .range(range) - .speed(if fine { 0.01 } else { 0.5 }) + .range(range.clone()) + // A warp is an absolute-position jump. The scoped scrub below removes that jump. + .speed(if wrapped_drag { 0.0 } else { speed }) .custom_formatter(move |v, _| match decimals { Some(d) => fmt_num_prec(v, d), None if fine => fmt_num2(v), @@ -761,6 +796,9 @@ fn number_edit( v }), ); + if wrap_drag { + crate::scrub_wrap::update(ui, &resp, value, before, range, speed, wrapping); + } resp.flags.set(egui::response::Flags::CHANGED, *value != before); resp } @@ -973,6 +1011,22 @@ pub fn slider_row_with( suffix: &str, gradient: Option<&[Color32]>, g: RowGestures, +) -> Response { + slider_row_prec(ui, label, value, range, suffix, gradient, g, None) +} + +/// [`slider_row_with`] whose field shows `decimals` places instead of the one (or two, for a +/// small range) it picks itself: Exposure's ±20 stops show two, its Offset four (#4164). +#[allow(clippy::too_many_arguments)] +pub fn slider_row_prec( + ui: &mut Ui, + label: &str, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + gradient: Option<&[Color32]>, + g: RowGestures, + decimals: Option, ) -> Response { let t = Tokens::get(ui.ctx()); let mut changed_resp = None; @@ -981,7 +1035,7 @@ pub fn slider_row_with( let l = ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); scrub_label_within(ui, &l, f32::INFINITY); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed_resp = Some(value_field(ui, value, range.clone(), suffix, 74.0)); + changed_resp = Some(value_field_in(ui, value, range.clone(), suffix, 74.0, 0.0, FieldOpts { decimals, ..Default::default() }).0); }); }); let s = slider_with(ui, value, range, gradient, g); @@ -1549,21 +1603,12 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { ui.spacing_mut().item_spacing.x = 6.0; let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - let p = ui.painter(); - if *on { - p.rect_filled(rect, 2.0, t.accent); - let a = rect.left_center() + vec2(3.0, 0.5); - let b = rect.center_bottom() + vec2(-1.0, -3.5); - let c = rect.right_top() + vec2(-3.0, 3.5); - p.line_segment([a, b], Stroke::new(1.8, Color32::WHITE)); - p.line_segment([b, c], Stroke::new(1.8, Color32::WHITE)); - } else { - p.rect_filled(rect, 2.0, t.field); - p.rect_stroke(rect, 2.0, Stroke::new(1.5, if resp.hovered() { t.text_dim } else { t.text_faint }), StrokeKind::Inside); - } let l = ui .add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).selectable(false).sense(Sense::click())) .on_hover_cursor(egui::CursorIcon::PointingHand); + // Pressing the label presses the box (#4476). + let pressed = resp.is_pointer_button_down_on() || l.is_pointer_button_down_on(); + check_box(ui.painter(), rect, *on, resp.hovered() || l.hovered(), pressed, &t); resp.union(l) }) .inner; @@ -1574,6 +1619,34 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { resp } +/// The fill of a check box: the accent when `on`, else the field colour. While it's pressed +/// (the mouse is down on it or on its label) Photoshop shows that a release will act: an +/// unchecked box darkens a little and a checked one turns a little less opaque (#4476). +pub fn check_box_fill(on: bool, pressed: bool, t: &Tokens) -> Color32 { + match (on, pressed) { + (true, false) => t.accent, + (true, true) => t.accent.gamma_multiply(0.75), + (false, false) => t.field, + (false, true) => t.field.lerp_to_gamma(Color32::BLACK, 0.18), + } +} + +/// Paint a check box in `rect`: [`check_box_fill`] with a white tick when `on`, else an +/// outline that brightens while `hovered` or `pressed`. +pub fn check_box(p: &egui::Painter, rect: Rect, on: bool, hovered: bool, pressed: bool, t: &Tokens) { + p.rect_filled(rect, 2.0, check_box_fill(on, pressed, t)); + if on { + let a = rect.left_center() + vec2(3.0, 0.5); + let b = rect.center_bottom() + vec2(-1.0, -3.5); + let c = rect.right_top() + vec2(-3.0, 3.5); + let tick = Stroke::new(rect.width() * 0.125, Color32::WHITE); + p.line_segment([a, b], tick); + p.line_segment([b, c], tick); + } else { + p.rect_stroke(rect, 2.0, Stroke::new(1.5, if hovered || pressed { t.text_dim } else { t.text_faint }), StrokeKind::Inside); + } +} + /// Small chevron for dropdowns (replaces egui's filled triangle). pub fn chevron_icon(ui: &Ui, rect: Rect, visuals: &egui::style::WidgetVisuals, _open: bool) { let c = rect.center(); @@ -1702,6 +1775,7 @@ mod tests { let original_padding = ui.spacing().button_padding; let original_hover = ui.visuals().widgets.hovered.fg_stroke; super::style_spectrum_popup_menu(ui); + assert_eq!(ui.spacing().item_spacing.y, 0.0, "{kind:?}: menu rows meet so the gap is selectable"); if tokens.pro { assert_eq!(ui.spacing().button_padding, egui::vec2(10.0, 4.0)); assert_eq!(ui.visuals().widgets.hovered.bg_fill, tokens.accent); diff --git a/crates/ui-egui/src/work_area.rs b/crates/ui-egui/src/work_area.rs index 0ac79ea6a2..df4bc7a77e 100644 --- a/crates/ui-egui/src/work_area.rs +++ b/crates/ui-egui/src/work_area.rs @@ -19,11 +19,19 @@ pub const TASKBAR: f32 = 48.0; const MIN_VISIBLE: f32 = 160.0; /// Where the window's content can be seen, in egui points. `monitor` is the monitor's size, -/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]); `fills_screen` -/// is true for a maximized or full-screen window (already inside the work area). -pub fn visible_part(content: Rect, monitor: Option, inner: Option, fills_screen: bool) -> Rect { +/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]). `state` says +/// whether the window is maximized or full screen. +pub fn visible_part(content: Rect, monitor: Option, inner: Option, state: WindowState) -> Rect { let Some(m) = monitor else { return content }; - if fills_screen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 { + if state == WindowState::FullScreen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 { + return content; + } + // A maximized window is usually inside the work area already, but not always (#3737): on X11 + // a window manager that doesn't reserve a panel's space (Cinnamon's Muffin with PhotoCraft's + // own title bar, a panel without a strut) maximizes it to the whole monitor, and the panel then + // covers its bottom. Where the window is known, it is checked like any other; only a maximized + // window with no position (Wayland) is taken to fit. + if state == WindowState::Maximized && inner.is_none() { return content; } // #3737: Wayland never tells a window where it is (winit's `inner_position` fails), so egui has @@ -49,6 +57,27 @@ pub fn visible_part(content: Rect, monitor: Option, inner: Option, f trusted(content, local) } +/// How the window sits on its monitor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowState { + Normal, + Maximized, + /// Full screen covers the taskbar on purpose: all of it can be seen. + FullScreen, +} + +impl WindowState { + fn of(v: &egui::ViewportInfo) -> Self { + if v.fullscreen == Some(true) { + Self::FullScreen + } else if v.maximized == Some(true) { + Self::Maximized + } else { + Self::Normal + } + } +} + /// `content` cut to `usable`, unless that leaves too little to be a sensible guess. fn trusted(content: Rect, usable: Rect) -> Rect { let visible = content.intersect(usable); @@ -63,8 +92,7 @@ pub fn visible_rect(ctx: &egui::Context) -> Rect { let content = ctx.content_rect(); ctx.input(|i| { let v = i.viewport(); - let fills = v.maximized == Some(true) || v.fullscreen == Some(true); - visible_part(content, v.monitor_size, v.inner_rect, fills) + visible_part(content, v.monitor_size, v.inner_rect, WindowState::of(v)) }) } @@ -107,6 +135,7 @@ pub fn fit_window(ctx: &egui::Context) -> bool { #[cfg(test)] mod tests { use super::*; + use WindowState::{FullScreen, Maximized, Normal}; use egui::{Rect, pos2, vec2}; const MONITOR: Vec2 = vec2(1366.0, 768.0); @@ -119,29 +148,46 @@ mod tests { fn a_window_taller_than_its_display_is_cut_at_the_taskbar() { // 1440 × 900 at the desktop origin on 1366 × 768: right and bottom are off-screen. let (content, inner) = window(0.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); // Below a 31 pt title bar, a full-height window reaches under the taskbar. let (content, inner) = window(0.0, 31.0, 1366.0, 737.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR - 31.0); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal).bottom(), 768.0 - TASKBAR - 31.0); // A small window clear of the taskbar is all visible. let (content, inner) = window(100.0, 80.0, 800.0, 500.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), content); // On a second monitor to the right, the same. let (content, inner) = window(1366.0 + 100.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal).bottom(), 768.0 - TASKBAR); } #[test] fn maximized_unknown_or_nonsense_input_keeps_the_window() { let (content, inner) = window(0.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), true), content, "maximized: already in the work area"); - assert_eq!(visible_part(content, None, Some(inner), false), content); - assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), false), content); - assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), false), content); - assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), FullScreen), content, "full screen covers the taskbar on purpose"); + assert_eq!(visible_part(content, None, Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), Normal), content); // A guess that would leave almost nothing visible is not trusted. let (content, inner) = window(0.0, 600.0, 1000.0, 300.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), content); + } + + #[test] + fn a_maximized_window_under_a_panel_is_still_cut_at_the_taskbar() { + // #3737, X11 (Linux Mint): the window manager maximized the window to the whole 1920 × 1080 + // monitor without reserving the bottom panel's space, so the panel covers it. + for scale in [1.0_f32, 1.5] { + let monitor = vec2(1920.0, 1080.0) / scale; + let content = Rect::from_min_size(pos2(0.0, 0.0), monitor); + let bottom = monitor.y - TASKBAR; + assert_eq!(visible_part(content, Some(monitor), Some(content), Maximized).bottom(), bottom, "at {scale}×"); + // A window manager that reserves the panel: the maximized window already ends above it. + let fitted = Rect::from_min_size(pos2(0.0, 0.0), vec2(monitor.x, bottom)); + assert_eq!(visible_part(fitted, Some(monitor), Some(fitted), Maximized), fitted, "at {scale}×"); + // Full screen is meant to cover the panel. + assert_eq!(visible_part(content, Some(monitor), Some(content), FullScreen), content, "at {scale}×"); + } } #[test] @@ -149,13 +195,14 @@ mod tests { // #3737, Wayland: only the monitor's size is known. A window taller than the monitor less // the taskbar can be seen at most down to there, wherever it is. let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(1366.0, 740.0)); - assert_eq!(visible_part(content, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(content, Some(MONITOR), None, Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); let big = Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0)); - assert_eq!(visible_part(big, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(big, Some(MONITOR), None, Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); // One that fits, or a maximized one, is all visible. let small = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)); - assert_eq!(visible_part(small, Some(MONITOR), None, false), small); - assert_eq!(visible_part(content, Some(MONITOR), None, true), content); + assert_eq!(visible_part(small, Some(MONITOR), None, Normal), small); + assert_eq!(visible_part(content, Some(MONITOR), None, Maximized), content); + assert_eq!(visible_part(content, Some(MONITOR), None, FullScreen), content); } fn frame(ctx: &egui::Context, monitor: Option, outer: Option, maximized: Option) -> Vec { diff --git a/crates/ui-egui/tests/crop_default_mode.rs b/crates/ui-egui/tests/crop_default_mode.rs index b16e628214..7c426410db 100644 --- a/crates/ui-egui/tests/crop_default_mode.rs +++ b/crates/ui-egui/tests/crop_default_mode.rs @@ -106,6 +106,9 @@ fn setup() -> Option { #[test] fn a_ratio_dropdown_updates_the_preview_and_enter_without_touching_a_handle() { + // Windows WARP can access-violate when GPU harnesses initialize or tear down concurrently: + // hold the GPU lock for the whole test, harness included. + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = setup() else { return }; h.get_by_value("Ratio").click(); h.run_steps(3); @@ -124,6 +127,9 @@ fn a_ratio_dropdown_updates_the_preview_and_enter_without_touching_a_handle() { #[test] fn the_swap_button_and_x_apply_the_same_inscribed_ratio() { + // Windows WARP can access-violate when GPU harnesses initialize or tear down concurrently: + // hold the GPU lock for the whole test, harness included. + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = setup() else { return }; let original = [30.0, 20.0, 270.0, 180.0]; h.state_mut().ui.crop_rect = Some(original); @@ -148,6 +154,9 @@ fn the_swap_button_and_x_apply_the_same_inscribed_ratio() { #[test] fn a_resize_renders_the_frame_and_image_with_the_same_camera() { + // Windows WARP can access-violate when GPU harnesses initialize or tear down concurrently: + // hold the GPU lock for the whole test, harness included. + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = setup() else { return }; let box0 = screen_box(&h); let c = mid(box0); diff --git a/crates/ui-egui/tests/custom_print_paper.rs b/crates/ui-egui/tests/custom_print_paper.rs new file mode 100644 index 0000000000..7361468bbc --- /dev/null +++ b/crates/ui-egui/tests/custom_print_paper.rs @@ -0,0 +1,142 @@ +//! #3820: edit custom paper through the real Print dialog, preview it, and print safely to PDF. +use egui::{Event, Key, Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_ui_egui::{PhotocraftApp, file_ui::open_print}; +use serde_json::{Map, Value, json}; + +fn app() -> (PhotocraftApp, u64) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default()); + app.run("file.new", json!({"width": 120, "height": 80, "name": "Custom paper"})).unwrap(); + let id = open_print(&mut app); + // Every UI path in these fixtures is safe even if an unexpected Enter confirms the dialog. + app.ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true)); + (app, id) +} + +fn show(ui: &mut egui::Ui, app: &mut PhotocraftApp) { + if ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + photocraft_ui_egui::dialogs::show(app, ui.ctx()); + } +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1000.0, 900.0)).with_max_steps(64).build_ui_state(show, app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) { + if let Some(node) = h.query_by_label(label) { + node.click(); + } else { + h.get_by_value(label).click(); + } + h.run_steps(3); +} + +fn shown(h: &Harness<'_, PhotocraftApp>, text: &str) -> bool { + h.query_by_label(text).is_some() || h.query_by_value(text).is_some() +} + +fn enter_length(h: &mut Harness<'_, PhotocraftApp>, label: &str, value: &str) { + // Anchor the pointer to the real label; the existing length widget immediately follows it. + let pos = h.get_by_label(label).rect().right_center() + vec2(35.0, 0.0); + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text(value.into())); + h.run_steps(1); + h.key_press(Key::Tab); + h.run_steps(3); +} + +fn fields(h: &Harness<'_, PhotocraftApp>, id: u64) -> Map { + h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone() +} + +#[test] +fn custom_paper_ui_edits_reach_preview_and_pdf_and_restore_when_reopened() { + let (mut app, id) = app(); + let output = std::env::temp_dir().join(format!("pc-custom-paper-{}.pdf", std::process::id())); + let dialog = app.ui.dialog_mut(id).unwrap(); + dialog.fields.insert("output".into(), json!(output.to_string_lossy())); + dialog.fields.insert("dryRun".into(), json!(true)); + let mut h = harness(app); + click(&mut h, "letter"); + assert!(shown(&h, "a4"), "the real preset menu must be open"); + assert!(shown(&h, "Custom"), "the paper menu must offer editable custom dimensions"); + click(&mut h, "Custom"); + assert_eq!(fields(&h, id)["paper"], json!([612.0, 792.0]), "Custom starts at the current preset dimensions"); + click(&mut h, "Centimeters"); + click(&mut h, "Inches"); + enter_length(&mut h, "Paper Width", "7.25"); + enter_length(&mut h, "Paper Height", "9.25"); + let edited = fields(&h, id); + assert_eq!(edited["paper"], json!([522.0, 666.0]), "the engine takes points, not the displayed units"); + click(&mut h, "Custom"); + click(&mut h, "a4"); + assert_eq!(fields(&h, id)["paper"], "a4"); + assert!(!shown(&h, "Paper Width")); + click(&mut h, "a4"); + click(&mut h, "Custom"); + assert_eq!(fields(&h, id)["paper"], edited["paper"], "returning from a preset restores the custom dimensions"); + click(&mut h, "Inches"); + click(&mut h, "Millimeters"); + assert_eq!(fields(&h, id)["paper"], edited["paper"], "changing units must not change the physical paper"); + click(&mut h, "Millimeters"); + click(&mut h, "Inches"); + assert_eq!(fields(&h, id)["paper"], edited["paper"]); + let preview = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(edited.clone()), "file.print").unwrap(); + assert_eq!(preview.paper, (522.0, 666.0)); + click(&mut h, "Portrait"); + click(&mut h, "Landscape"); + let edited = fields(&h, id); + assert_eq!(edited["paper"], json!([522.0, 666.0]), "orientation must not rewrite the unrotated custom dimensions"); + let preview = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(edited), "file.print").unwrap(); + assert_eq!(preview.paper, (666.0, 522.0)); + click(&mut h, "Print"); + let pdf = std::fs::read(&output).unwrap(); + assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 666.00 522.00]")); + assert_eq!(h.state().session.file_menu.last_print.as_ref().unwrap()["paper"], json!([522.0, 666.0])); + let id = open_print(h.state_mut()); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true)); + h.run_steps(3); + assert!(shown(&h, "Custom"), "reopening array-valued settings must show Custom"); + assert_eq!(fields(&h, id)["paper"], json!([522.0, 666.0])); + std::fs::remove_file(output).unwrap(); +} + +#[test] +fn remembered_custom_paper_is_shown_without_becoming_a_preset() { + let (mut app, id) = app(); + app.ui.dialog_mut(id).unwrap().fields.insert("paper".into(), json!([432.0, 576.0])); + let h = harness(app); + assert!(shown(&h, "Custom"), "an array paper size must not display as letter"); + assert!(shown(&h, "Paper Width")); + assert!(shown(&h, "Paper Height")); + assert_eq!(fields(&h, id)["paper"], json!([432.0, 576.0])); +} + +#[test] +fn invalid_custom_paper_is_rejected_without_printing_or_changing_the_document() { + let (mut app, id) = app(); + let output = std::env::temp_dir().join(format!("pc-invalid-custom-paper-{}.pdf", std::process::id())); + app.ui.dialog_mut(id).unwrap().fields.insert("output".into(), json!(output.to_string_lossy())); + let before = app.session.active().unwrap().doc.clone(); + let mut h = harness(app); + click(&mut h, "letter"); + click(&mut h, "Custom"); + enter_length(&mut h, "Paper Width", "0"); + let edited = fields(&h, id); + assert!(photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(edited), "file.print").is_err()); + click(&mut h, "Print"); + let error = &h.state().ui.status; + assert!(error.contains("paper is too small"), "{error}"); + assert_eq!(h.state().session.active().unwrap().doc, before); + assert!(h.state().session.file_menu.last_print.is_none()); + assert!(!output.exists()); +} diff --git a/crates/ui-egui/tests/layout_perf.rs b/crates/ui-egui/tests/layout_perf.rs index 502390c979..fb17833e18 100644 --- a/crates/ui-egui/tests/layout_perf.rs +++ b/crates/ui-egui/tests/layout_perf.rs @@ -132,6 +132,9 @@ fn auto_select_moves_the_layer_under_the_pointer() { actual_pixels(&mut h); h.state_mut().ui.tool = Tool::Move; h.state_mut().ui.tool_options.move_auto_select = true; + // The default target is Group (#4056); these layers sit in groups, and the test is about the + // layer under the pointer. + h.state_mut().ui.tool_options.move_target = "layer".into(); h.state_mut().run("layer.select", json!({"layer": k.pixel.0})).expect("select"); h.run_steps(2); let (pixel_before, text_before) = (bounds(&h, k.pixel), bounds(&h, k.text)); @@ -150,6 +153,7 @@ fn a_move_tool_click_selects_without_moving() { let (mut h, k) = app(); h.state_mut().ui.tool = Tool::Move; h.state_mut().ui.tool_options.move_auto_select = true; + h.state_mut().ui.tool_options.move_target = "layer".into(); for id in [k.text, k.shape, k.pixel, k.text] { let before = (bounds(&h, id), h.state().session.active().expect("doc").history.entries().len()); let p = screen(&h, point_on(&h, id)); diff --git a/crates/ui-egui/tests/marquee_autoscroll.rs b/crates/ui-egui/tests/marquee_autoscroll.rs new file mode 100644 index 0000000000..2fc5772604 --- /dev/null +++ b/crates/ui-egui/tests/marquee_autoscroll.rs @@ -0,0 +1,183 @@ +//! #4183: dragging a Rectangular Marquee past the edge of a zoomed-in canvas scrolls the view, so +//! the selection can be larger than what is on screen (as in Photoshop). +//! +//! Before the fix the view stayed put and the selection stopped at the visible edge. The real app +//! runs offscreen on wgpu with real egui pointer events. Skips when no GPU adapter exists (like +//! `drag_preview_canvas.rs`). + +use egui::{Modifiers, PointerButton, Pos2}; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + harness_with_size(egui::vec2(900.0, 640.0)) +} + +fn harness_with_size(size: egui::Vec2) -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(size).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A 3000 × 2000 document at 100 %, centred, with `tool` active: far larger than the canvas. +fn setup(h: &mut Harness, tool: &str) { + { + let app = h.state_mut(); + while app.session.active().is_some() { + app.run("file.close", json!({"discard": true})).expect("close"); + } + app.run("file.new", json!({"width": 3000, "height": 2000, "background": "white"})).expect("new"); + app.sync_views(); + app.ui.extras.rulers = false; + } + control(h, "ui.set", json!({"tool": tool, "zoom": 1.0, "center": [1500, 1000]})); + h.run_steps(3); +} + +fn xf(h: &Harness) -> ViewXform { + ViewXform::active(h.state()).expect("an active document view") +} + +fn pointer_at(h: &mut Harness, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.step(); +} + +fn button_at(h: &mut Harness, p: Pos2, pressed: bool) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +/// Selection bounds `[x0, y0, x1, y1]` in document pixels. +fn selection(h: &Harness) -> Option<[i32; 4]> { + let st = h.state().session.active()?; + let b = st.doc.selection.as_ref()?.content_bounds(); + (!b.is_empty()).then_some([b.x0, b.y0, b.x1, b.y1]) +} + +#[test] +fn marquee_dragged_past_the_right_edge_scrolls_and_selects_beyond_the_view() { + let Some(mut h) = harness() else { return }; + setup(&mut h, "rectMarquee"); + let start = xf(&h); + let rect = start.rect; + // The document column at the canvas's right edge before the drag: the most a selection + // could reach without scrolling. + let visible_right = start.to_doc(egui::pos2(rect.right(), rect.center().y))[0]; + + let from = rect.center(); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + for i in 1..=6 { + let t = i as f32 / 6.0; + pointer_at(&mut h, from + egui::vec2((rect.right() - from.x - 10.0) * t, 60.0 * t)); + } + // Past the edge, then held still for a while: the view keeps scrolling. + let outside = egui::pos2(rect.right() + 40.0, from.y + 60.0); + pointer_at(&mut h, outside); + for _ in 0..30 { + h.step(); + } + let moved = xf(&h).center[0] - start.center[0]; + assert!(moved > 50.0, "the view scrolls right while the drag is held past the edge (moved {moved})"); + assert!((xf(&h).center[1] - start.center[1]).abs() < 1e-3, "and only horizontally"); + + button_at(&mut h, outside, false); + h.run_steps(3); + let sel = selection(&h).expect("a selection"); + assert!(sel[2] as f64 > visible_right + 50.0, "the selection reaches past the column that was visible at the press: {sel:?} vs {visible_right}"); + + // Once released, the view no longer scrolls even with the pointer outside. + let after = xf(&h).center; + pointer_at(&mut h, outside); + h.run_steps(10); + assert_eq!(xf(&h).center, after); +} + +#[test] +fn a_drag_inside_the_canvas_does_not_scroll() { + let Some(mut h) = harness() else { return }; + setup(&mut h, "rectMarquee"); + let start = xf(&h); + let from = start.rect.center(); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + for i in 1..=6 { + pointer_at(&mut h, from + egui::vec2(15.0 * i as f32, 10.0 * i as f32)); + } + for _ in 0..20 { + h.step(); + } + assert_eq!(xf(&h).center, start.center); + button_at(&mut h, from + egui::vec2(90.0, 60.0), false); +} + +#[test] +fn rotated_marquee_scrolls_on_the_axis_that_is_visibly_clipped() { + let Some(mut h) = harness_with_size(egui::vec2(1200.0, 640.0)) else { return }; + setup(&mut h, "rectMarquee"); + let rect = xf(&h).rect; + assert!(rect.width() > rect.height()); + // Fits horizontally before rotation, but at 90 degrees it overflows vertically. + let width = ((rect.width() + rect.height()) / 2.0) as u32; + let height = (rect.width() * 2.0) as u32; + for overscroll in [true, false] { + { + let app = h.state_mut(); + app.run("file.close", json!({"discard": true})).expect("close"); + app.run("file.new", json!({"width": width, "height": height, "background": "white"})).expect("new"); + app.run("prefs.set", json!({"path": "tools.overscroll", "value": overscroll})).expect("overscroll"); + app.sync_views(); + } + control(&mut h, "ui.set", json!({"tool": "rectMarquee", "zoom": 1.0, "center": [width / 2, height / 2]})); + h.state_mut().ui.views[0].rotation = 90.0; + h.run_steps(3); + let start = xf(&h); + let from = start.rect.center(); + let outside = egui::pos2(from.x + 30.0, start.rect.bottom() + 40.0); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + pointer_at(&mut h, outside); + h.run_steps(30); + let moved = xf(&h).center[0] - start.center[0]; + if let Some(dir) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).expect("fixture directory"); + h.render().expect("render").save(dir.join(format!("rotated-marquee-overscroll-{overscroll}.png"))).expect("save fixture"); + } + assert!(moved > 10.0, "rotated vertical overflow must scroll document x, overscroll={overscroll}, moved={moved}"); + let limit = width as f32 - start.rect.height() / (2.0 * start.zoom); + assert!((xf(&h).center[0] - limit).abs() < 0.01, "stops at the rotated image edge, overscroll={overscroll}"); + assert!((xf(&h).center[1] - start.center[1]).abs() < 0.01); + button_at(&mut h, outside, false); + h.run_steps(3); + let after = xf(&h).center; + h.run_steps(10); + assert_eq!(xf(&h).center, after); + } +} diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index a459ba816e..b154a47325 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -29,9 +29,11 @@ pub fn clamp_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] { r.map(|v| v * f) } -/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`. +/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`. A negative +/// width or height extends from the origin to the left or up, like [`ellipse`] and [`polygon`]. pub fn rounded_rect(x: f64, y: f64, w: f64, h: f64, radii: [f64; 4]) -> Path { - let (w, h) = (w.abs(), h.abs()); + let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; + let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; let [tl, tr, br, bl] = clamp_radii(w, h, radii); if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 { return rect(x, y, w, h); @@ -143,6 +145,20 @@ mod tests { assert_eq!(clamp_radii(10.0, 10.0, [10.0; 4]), [5.0; 4]); } + #[test] + fn negative_size_rect_extends_from_its_origin_like_the_ellipse() { + // Issue #4203: a negative width/height drew the rectangle on the far side of its origin. + let want = ellipse(50.0, 10.0, -20.0, 30.0).control_bounds().unwrap(); + let want_neg_h = ellipse(50.0, 40.0, -20.0, -30.0).control_bounds().unwrap(); + for radii in [[0.0; 4], [5.0; 4]] { + let b = rounded_rect(50.0, 10.0, -20.0, 30.0, radii).control_bounds().unwrap(); + assert_eq!(b, want, "radii {radii:?}"); + assert_eq!(b, (30.0, 10.0, 50.0, 40.0), "radii {radii:?}"); + let b = rounded_rect(50.0, 40.0, -20.0, -30.0, radii).control_bounds().unwrap(); + assert_eq!(b, want_neg_h, "radii {radii:?}"); + } + } + #[test] fn star_vertices() { assert_eq!(polygon(0.0, 0.0, 10.0, 10.0, 5, 0.5).subpaths[0].knots.len(), 10); diff --git a/docs/brush-parity.md b/docs/brush-parity.md index ad1ea5c83c..39efd36371 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (checkbox-only Brush Settings rows and non-destructive Smoothing switch) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 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 @@ -25,6 +25,7 @@ tablets against Photoshop side by side. | Dual Brush | done | | | Color Dynamics (FG/BG, hue/sat/brightness jitter, purity, per tip) | done | | | Transfer (opacity/flow/wetness/mix jitter and controls) | done | | +| Shape Dynamics and Transfer minimum fields disabled when their Control is Off, retaining their values | done | #3825 (minimum-field interaction only); `brush_sections.rs` UI regressions cover all six fields, reactivation and disabled input | | Brush Pose (tilt, rotation, pressure overrides) | done | | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | @@ -55,7 +56,7 @@ tablets against Photoshop side by side. | Interpolation and smoothing match Photoshop | partial | #2138, #2057 | | Soft-edge falloff matches Photoshop's curve | partial | #2149, #1619 | | Brush preview and cursor crisp, not pixelated | partial | #1433 | -| Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364 | +| Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364; shaped tips trace their outline (#3467), turned and flipped with a rotated or flipped view | | Latency: dab to screen within a frame | partial | budgets P13/P16/P33 over (scorecard); big-brush lag #2619, #1994; cursor lag #2566, #1343 | | No hitch at mouse-up on a long stroke | not measurable | P32 needs a frame harness | | Live stroke for Brush and Eraser | done | | @@ -116,3 +117,5 @@ tablets against Photoshop side by side. | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | +| 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | +| 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 425c91f42f..76d6724b7e 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line and revision history added; linked from ui-parity.md) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (layer-mask thumbnail menu, #4575) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -27,7 +27,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 | | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | -| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Spelling suggestions for the word under the pointer and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | +| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | | Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | @@ -77,13 +77,13 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho | Surface / target | Desired options and state | PhotoCraft now | Source and confidence | |---|---|---|---| | Layers panel row | Blending Options, Duplicate/Delete, Group, export, convert/rasterize, mask, clipping, link, style copy/paste/clear, merge/flatten, Rename; filter by layer kind and selection | Implemented in layer_menu_ui.rs; item enablement uses menu commands | PhotoCraft inventory. Adobe documents layer rename/delete, color tagging, rasterization, and flattening [here](https://helpx.adobe.com/photoshop/using/layers.html); exact combined ordering **inferred** without a live capture | -| Layers panel color label / thumbnail / mask thumbnail / effect row | Color label; context actions specific to hit icon | Generic row on some targets; inspect before extending | Adobe explicitly documents right-click on layer/group to assign a color ([layers](https://helpx.adobe.com/photoshop/using/layers.html)); other icon menus need capture | +| Layers panel color label / thumbnail / mask thumbnail / effect row | Color label; context actions specific to hit icon | Layer-mask thumbnail: Disable/Delete/Apply Layer Mask, Add/Subtract/Intersect Mask with Selection, Select and Mask… (layer_menu_ui.rs `show_mask`, #4575; Mask Options… missing). Generic row on the other targets; inspect before extending | Adobe explicitly documents right-click on layer/group to assign a color ([layers](https://helpx.adobe.com/photoshop/using/layers.html)); other icon menus need capture | | Channels panel row | Duplicate/Delete/Rename, channel display and overlay color, spot conversion; new/split/merge | Implemented in channels_panel.rs | PhotoCraft inventory; exact Photoshop order **inferred** | | Brushes and preset panels | Rename/Delete brush or group, export selected brushes, view/reset preset choices | Rename/Delete, and preset group/item menus exist | Adobe explicitly documents Export Selected Brushes from brush/group right-click ([brushes](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html)); Photoshop pop-up-panel rename/delete/reset/view options are [documented](https://helpx.adobe.com/photoshop/using/panels-menus.html), though some are panel-button menus | | Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity | | Document tab | Close, Close Others, Close All, Reveal in file manager, duplicate/move to window where supported | Close, Close Others, Close All, Reveal in Finder, and drag to reorder implemented; duplicate/move to window where supported remains open | **Inferred** exact list; P1 | | Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | No menu | Adobe [documents right-clicking outside the image for canvas color](https://helpx.adobe.com/ph_en/photoshop/using/filling-stroking-selections-layers-paths.html); exact current layout still needs a capture | -| Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions missing: `edit.checkSpelling` (`suggest`) is not wired into the menu yet (UI-217-8) | P1; **inferred** exact spelling list | +| Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions live in the Type tool's edit menu (canvas_tool_menu.rs), not in generic fields, which hold no type layer text (UI-217-8) | P1; **inferred** exact spelling list | | Tool icon in Options bar | Reset Tool, Reset All Tools | Audit | Explicitly documented in [Adobe Photoshop reference](https://helpx.adobe.com/archive/en/photoshop/cc/2015/photoshop_reference.pdf); historical source, verify modern label | | Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Current right-click deletes constraint | P1 behavior mismatch: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) | @@ -100,6 +100,7 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | | 2026-10-07 | minor | Pen canvas reference capture | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f23745efdb..e571e94f8b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,7 @@ Each reply is one JSON line with the same `id`: {"id": 2, "ok": false, "error": "unknown tool `foo`"} ``` -The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. The timeout error names the likely causes: the command may still be running, or the request was queued while the window was not drawing frames — on Wayland the frame update (and with it the control drain) only runs on compositor frame callbacks, so a display that is asleep or a fully occluded window makes every request time out while the app itself is healthy. +The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. The two timeouts read differently, both starting with `timeout`: a request that was never dispatched (still queued at the deadline) says `timeout: not run` and is safe to re-send, while `timeout: no reply in 60s` means the command was dispatched and may still be running and still apply, so check the document before re-sending it. The timeout error names the likely causes: the command may still be running, or the request was queued while the window was not drawing frames — on Wayland the frame update (and with it the control drain) only runs on compositor frame callbacks, so a display that is asleep or a fully occluded window makes every request time out while the app itself is healthy. The transport is `apps/photocraft/src/control_server.rs`, and the handlers are in `crates/ui-egui/src/control.rs`. The MCP server (`photocraft-cli mcp --bridge 127.0.0.1:`, crate `photocraft-automation`) wraps this same protocol. See [MCP bridge](#mcp-bridge) below. @@ -52,6 +52,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}` - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. +- `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. @@ -64,6 +65,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). + - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows @@ -73,6 +75,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` - `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default +- Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) - `ui.resize {width, height}`: resize the main window - `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch; Retry GPU saves GPU mode and asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback diff --git a/docs/development.md b/docs/development.md index 73a3bde0bf..bb0de12690 100644 --- a/docs/development.md +++ b/docs/development.md @@ -104,6 +104,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | Variable | Effect | |---|---| | `PHOTOCRAFT_CONTROL_PORT` | Same as `--control ` | +| `PHOTOCRAFT_NO_PRINT` | Set to anything but `0`: `file.print` / `file.printOneCopy` refuse to spool (`dryRun` still works). CI sets it, with `LPDEST=photocraft-no-such-printer` as a second net; set both when running the panic hunt or fuzzers locally | | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) | | `PHOTOCRAFT_CONTROL_TOKEN_FILE` | Read, or create for a server, the control bearer-token file | @@ -144,6 +145,7 @@ See `docs/control-protocol.md` for every method. Tips: - **macOS does not render occluded windows.** `ui.screenshot` raises the window first (`focus: true` by default). Use `ui.focus` before other visual checks. - **Capture only our own window.** For native captures (to see the real title bar and traffic lights), capture by window id (`screencapture -l `), never a screen region, which can grab other apps. - **Pointer gestures:** `ui.pointer` takes events in *document* coordinates and runs them through the same tool state machine as the mouse. +- **Seeing tile storage:** `ui.set {"tileGrid": true}` overlays the active layer's stored tiles, which shows how sparse a layer is and which tiles share pixels (`ui-egui/src/tile_overlay.rs`). ## Headless CLI diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d6e3bef284..d2c9d8ccde 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 133/256, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469, #2465 (#2374, text layers, closed 2026-10-10) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 145/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -43,7 +43,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | no (#1049) | no | | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon no | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; no Cineon, no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | @@ -90,6 +90,7 @@ D5100 colour profile. Counts and the G14 estimate are unchanged. | Paint.NET `.pdn` | yes | no | [pdn.md](pdn.md) | | OpenRaster `.ora` | yes | yes | [ora.md](ora.md) | | ICO, QOI | yes | yes | | +| DirectDraw Surface `.dds` | yes | no | Top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in | | Krita `.kra`, GIMP `.xcf`, Procreate | no | no | Not in Photoshop either | ### Affinity import notes (moved from `roadmap.md`) @@ -130,9 +131,10 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (108 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (112 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | -| Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | +| Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | +| Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | | C2PA Content Credentials | R/W | no | #1050 | @@ -140,7 +142,13 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | +| 2026-10-12 | minor | DDS textures open, read-only (#1327) | +| 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits | +| 2026-10-12 | minor | CMYK Invert, Posterize and Threshold work on the inks and 16-bit Lab Invert mirrors a and b around neutral (oracle bad pixels: CMYK 71–77% → 9–10%, 37% → 1.6%; lab16 invert 52% → 1.2%); Photoshop-oracle floor 133 → 145 of 258 | +| 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | +| 2026-10-12 | minor | Presets table: Contours `.shc` read and write through the Contour Editor (FILE-215-15); `.csh` and `.tpl` stay missing | | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | diff --git a/docs/gaps.md b/docs/gaps.md index 47aa04221d..79d72bcc25 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; 2026-10-11: G8: the live Warp Text preview is done, TYPE-218-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; 2026-10-11: G8: the live Warp Text preview is done, TYPE-218-7) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,10 +43,10 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 133/256 (52%), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% + oracles 145/258 (56%; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not - opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465), PSB smart object + opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object blank (#1764), perspective placed layers flattened (#579), PSB > 4 GB fails on Windows (#375). No test opens our exports in a third-party editor (FILE-216-4). - **Impact:** PSD is the format professionals exchange. Beta requires it to be reliable. @@ -67,8 +67,9 @@ what an agent can't finish alone. ### G7. Workspaces shown as parameter dialogs - **Missing:** Select and Mask workspace layout (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area - (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the - transform box (TOOL-214-7); Content-Aware Move's Transform On Drop (TOOL-213-4). + (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview + of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, + TOOL-214-7, are done). - **Evidence:** scorecard rows above, all partial or missing. - **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a substitute. @@ -90,14 +91,24 @@ what an agent can't finish alone. ### G11. Actions and scripting - **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the - mapped events (paths, scripts, Generative Fill, offsets in points…) load as kept Photoshop - steps that fail at playback by name. + mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, + Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps + that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 108 Photoshop events map to commands both ways (Transform, + and `actions.load`/`actions.save`; 112 Photoshop events map to commands both ways (Transform, Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional since the third - and fourth PRs), Play, Stop and conditional steps included; layer steps replay by layer name in any - document. The panel groups actions + and fourth PRs; Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill + layers, targeting the layer mask or composite channel, loading the targeted channel, Step + Backward/Forward, removing a layer from the selection, nudges in points, Layer From Background, + Grayscale and Bitmap mode objects and Image Size with Resample keeping the print size since the + fifth), Play, Stop and conditional steps included; layer steps replay by layer name in any + document. Four TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine, + Transparency) import with 3 of 533 steps unmapped and play end to end. Filter Gallery Spatter's Smoothness + now smooths the spray's displacement, so high values tear an edge without detaching specks, as + Photoshop's does (calibrated by eye on the Distress Press guide's screenshots). Crystallize + fills each cell with the colour at its point instead of the cell's average, so a black-and-white + mask keeps hard edges (not yet checked against a Photoshop render). The panel groups actions in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents (AUTO-219-2 done). @@ -109,7 +120,7 @@ what an agent can't finish alone. - **Missing:** CR3 (detected, unsupported), compressed RAF, lossy DNG (FILE-215-11), .xmp sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG cameras, lens profiles. -- **Evidence:** `crates/raw/src/lib.rs:219` (CR3), scorecard FILE-215-11/12, #50. +- **Evidence:** `crates/raw/src/lib.rs:228` (CR3), scorecard FILE-215-11/12, #50. - **Progress (2026-10-11):** on the embedded-JPEG rendering work in #2135, Camera Raw tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a @@ -141,6 +152,9 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Shape Dynamics' Minimum Diameter / Minimum + Roundness and Transfer's four Minimum rows disable editing when their Control is Off, + retaining the configured values. The other layout and control gaps in #3825 remain open. - **Progress (2026-10-12, #4283):** the five checkbox-only Brush Settings rows keep the open controls selected; Smoothing can be disabled without discarding its amount or mode. This closes that panel interaction gap, not the stroke-quality or performance gaps below. @@ -154,23 +168,27 @@ what an agent can't finish alone. - **Estimate:** 100–180 h. **Human:** checking feel on real tablets. ### G5. Transform, handles and modifiers -- **Missing:** right-click menu during Free Transform (TOOL-214-5); commit/cancel buttons in the - options bar (#2442); H/V skew fields and the full interpolation list; transform keeps layer - order and blend mode in its preview (#2439); Hand tool interrupting a transform (#2302); - blend mode/opacity edits during a transform (#1384); locked layers refused before the drag - (#2585); ⌘-drag temporary Move without box or snapping (#2681), Auto-Select box follows the - previous layer (#2666); ⌘-arrow layer nudge (#2474); path-point nudge; polygonal lasso Shift - angles (#2352); autoscroll at the edge while selecting (#2231); hold-a-key spring-loaded tools - (#2672); double-click Zoom tool to 100% (#2508); wheel and pinch behaviour (#2485, #2463, - #1989); toolbar press-and-hold flyouts (#2301); tool-key memory in a group (#2608). +- **Missing:** the full interpolation list; blend mode/opacity edits during + a transform (#1384); path-point nudge; autoscroll at the edge while selecting (#2231); + wheel and pinch behaviour (#2485, #2463, #1989); + toolbar press-and-hold flyouts (#2301). +- **Done since the re-measure (2026-10-10 to 12):** right-click menu during Free Transform + (TOOL-214-5); commit/cancel buttons in the options bar (#2442); transform keeps the layer's + place in the stack (#2439, #2480); Hand tool no longer interrupts a transform (#2302, #2327); + locked layers refused before the drag (#2585, #2609); ⌘-drag temporary Move with box and + snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow + layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool + to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625); hold-a-key spring-loaded + tools (#2672); H/V skew fields in the Free Transform options bar. - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace -- **Missing:** 46 of 149 preferences do nothing (scorecard prefs audit); panel groups can't +- **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), + with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). +- **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and - slider modes (UI-217-17, #2569); UI scale presets (UI-217-9, #2492); more themes (UI-217-11, - #2661); Stamp Visible (UI-217-22); layer clipboard + slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. @@ -187,8 +205,7 @@ what an agent can't finish alone. loaded machine): layer move with styles 321 ms p50 against 25 ms, opacity change 276 ms against 20 ms, paste 12 MP 1.2 s against 100 ms, layer move on 15000×10000 16-bit 2.7 s, Content-Aware Scale 24 MP 954 s against 3 s, Select Subject 559 ms against 100 ms; the - 150-layer nudge crashes the GPU compositor (P12); a 50 MP PSB with feathered masks redraws in - ~10 s (#2377); menus slow with thousands of layers (#2460); old machines lag (#2687). + 150-layer nudge crashes the GPU compositor (P12); menus slow with thousands of layers (#2460); old machines lag (#2687). - **Estimate:** 200–350 h. Serial in places (compositor architecture). ## File-format gaps @@ -196,9 +213,9 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - DPX/Cineon (#1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, - .shc, .csh presets (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). + .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Estimate:** 120–220 h. @@ -217,7 +234,7 @@ what an agent can't finish alone. ### G13. Hindi, Arabic, Vietnamese, RTL and engine messages - **Missing:** catalogs for Hindi, Arabic and Vietnamese (3 of the 12 key languages); right-to- - left UI layout; Devanagari/Thai/Khmer fallback fonts; engine error and status messages in + left UI layout; Devanagari fallback fonts (Thai #1909 and Khmer, Lao and Myanmar #3259 landed); engine error and status messages in English in every language; browser-locale detection and CJK fonts on the web; Japanese IME on Windows (#590); installed Japanese fonts missing from the font list (#2330); Arabic layer names as tofu (#1408, #1693). @@ -229,8 +246,8 @@ what an agent can't finish alone. ## Platform gaps ### G17. Platforms and distribution -- **Missing:** Windows code signing (material not obtained), Windows ARM64 (DIST-220-2), in-app - update check (DIST-220-1, #2650, #2375), in-app bug report (DIST-220-3), Flathub (#173), +- **Missing:** Windows code signing (material not obtained), in-app update check (DIST-220-1, + #2650, #2375), in-app bug report (DIST-220-3), Flathub (#173), starting without a GPU adapter (#2519, #2412), Windows 7 (#2310, out of scope), Intel Macs with OpenCore (#1859). - **Estimate:** 30–60 h. **Human:** signing certificates. @@ -239,9 +256,18 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | +| 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | +| 2026-10-12 | minor | G9: toolbar tool shortcut editing (#4086); the remaining workspace gaps stay open | +| 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | +| 2026-10-12 | minor | G1: CMYK Invert, Posterize and Threshold work on the inks, 16-bit Lab Invert keeps greys neutral; Photoshop-oracle floor 133 → 145 of 258 (145 already passed on main) | +| 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | +| 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-11 | minor | G8: the live Warp Text preview (TYPE-218-7) moved to done; TYPE-218-9 listed as done; Type evidence recounted to 5/2/2 | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | +| 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | diff --git a/docs/gpu-composite-reuse.md b/docs/gpu-composite-reuse.md index 47b1f5c06d..39dad36107 100644 --- a/docs/gpu-composite-reuse.md +++ b/docs/gpu-composite-reuse.md @@ -13,10 +13,12 @@ existing planner before this boundary. Only the accumulated backdrop is live the it into the checkpoint's slot is sufficient to execute the remaining passes. No checkpoint is placed inside a group, clipping unit or adjustment program. -Blend-If currently returns `Unsupported` from the GPU planner and uses the existing CPU path. -The same holds for Advanced Blending (on main since b0680fd): it must keep falling back to the -CPU, or keep the checkpoint boundary rule — a later GPU implementation of advanced blending -preserves the boundary invariant either way. +Blend If runs on the GPU (#4395) within this rule: like channel restrictions, it reads only the +backdrop from before its own unit (retained across the unit, released at its end), so nothing +else is live at the boundary. Advanced Blending (on main since b0680fd) returns `Unsupported` +from the GPU planner and uses the CPU path: it must keep falling back to the CPU, or keep the +checkpoint boundary rule — a later GPU implementation of advanced blending preserves the +boundary invariant either way. ## Invalidation diff --git a/docs/kra.md b/docs/kra.md new file mode 100644 index 0000000000..847a1f32d2 --- /dev/null +++ b/docs/kra.md @@ -0,0 +1,77 @@ +# Krita documents + +PhotoCraft opens Krita's native documents (`.kra`, and the `.krz` archive variant) with their +layers. File › Open, recent files, drops, the CLI (`convert`, `info`, `batch`) and automation +(`doc.open`, MCP) all use the reader in `photocraft-io`; the browser build accepts them too. A +file is recognised by its `mimetype` entry (`application/x-krita`). Krita files are import-only: +Save suggests OpenRaster (`.ora`), which Krita opens with its layers, and `.pcraft` keeps +everything. + +## What is read + +| Krita | PhotoCraft | +|---|---| +| Paint layers, 8 and 16-bit RGBA, 8 and 16-bit gray | pixel layers, at the document's depth (a gray layer in an RGB document is converted) | +| Layer position (`x`, `y`), tiles anywhere, also off-canvas | kept | +| Groups, nested; pass-through groups; collapsed state | groups; pass-through; expanded state | +| Names, visibility, opacity, lock | kept | +| Blend modes | Krita's ids, mapped as for OpenRaster (`docs/ora.md`); a few are approximations, reported | +| Transparency masks | the layer mask (the first one; more are reported and left out) | +| Selection masks | ignored (a layer's local selection, not part of its look) | +| The image's ICC profile, resolution | kept | + +Left out with a warning per layer: vector, filter, fill, file and clone layers, filter, transform +and colorize masks, layers in another colour space than those above, Inherit Alpha, per-channel +blending flags (channel locks), and animation beyond the frame saved in the layer. Drawing +assistants and reference images are reported as left out. Krita's merged image still shows +the original look, so the result can differ where those were used. + +Documents whose image colour space we don't read yet (CMYK, Lab, XYZ, YCbCr, 16 and 32-bit +float) open as Krita's merged image (`mergedimage.png`), flagged as a preview, with a warning. + +## The format, as observed + +A ZIP whose first, stored entry is `mimetype`; `maindoc.xml` holds the node tree (topmost first) +and points each paint layer and mask at a file under `/layers/`. Those files are a +text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, `PIXELSIZE`, `DATA `) and per tile a +`x,y,LZF,` line followed by a compression flag and the tile's pixels in byte planes, LZF +compressed. RGB pixels are B, G, R, A; 16-bit samples are little-endian. A `.defaultpixel` file +next to each holds the colour outside its tiles. A group's own `x`/`y` does not move its +children (checked against Krita's merged image). + +## Limits and safety + +The reader uses the bounded ZIP reader of `photocraft-format` (no ZIP64, CRC-checked), parses +`maindoc.xml` with `roxmltree` (32 MiB cap; the DTD is declared by public id and not fetched), +and decodes tiles with a bounds-checked LZF decoder that must produce exactly one tile. Tiles +are at most 1024 px a side; canvases at most 300,000 px a side and 1 GiB of decoded pixels; +8,192 nodes; nesting at most `MAX_GROUP_DEPTH`. Version 1 tile files (Krita 1.x) are refused. +Malformed archives, XML, numbers, tile headers and tile data return errors; truncated and +corrupted files are covered by tests. + +## Verification + +Unit tests (`crates/io/src/kra/tests.rs`) build Krita archives in code (raw and LZF tiles) and +check layers, groups, pass-through, offsets, opacity, visibility, locks, masks, default pixels, +16-bit and gray conversion, the merged-image fallback, LZF edge cases and malformed input. +`crates/automation/tests/krita.rs` opens a `.kra` through the agent surface, edits it and saves +it as OpenRaster. `crates/engine/tests/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a +session, checks it is read-only, edits with undo and saves as `.ora` and `.pcraft`. + +Real-file checks use the Krita-authored oracles in +[photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`krita/`, made by +`tools/krita-oracles/generate.py`, which removes Krita's embedded third-party ICC profiles so +the files are sRGB): + +```sh +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test kra -- --ignored +``` + +2026-10-09, Krita 5.2.9: 31 of 35 `.kra` files render within 3/255 of Krita's merged image; the +others differ for known reasons (the three blend-mode tie-breaks as for OpenRaster, and the +file with a vector layer, which is left out). Before the profiles were stripped, a linear-light +16-bit document with its embedded profile matched Krita's merged image within 0.5/255 after +flattening and converting to sRGB. + +The reader is written from files Krita writes; no Krita code was used. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 58c96b4dc6..9c73eb094b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Italian native review; previously minor: Turkish catalog) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Serbian catalog; previously minor: Italian native review) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), @@ -49,7 +49,9 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` -(3,731 rows) also covers every measured key. +(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every +measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse +the Russian rule. Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and transform accessibility names, rasterization prompts, profile mismatch messages and preset @@ -82,3 +84,4 @@ engine localization or native review is complete. | 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent | | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | +| 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | diff --git a/docs/localization.md b/docs/localization.md index f05b261b36..36e09f8a55 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -27,6 +27,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Nederlands | `nl` | one / other | | Magyar | `hu` | one form | | Tiếng Việt | `vi` | one form | +| Srpski | `sr` | one / few / many (teens use many) | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is @@ -47,7 +48,8 @@ The existing preference store saves the selection for future launches. `auto` fo native system locale; an unsupported code follows the same fallback. Regional tags such as `fr-CA`, `ko-KR`, `vi-VN` and `zh-CN` resolve to the corresponding registered catalog. Traditional and Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the -Brazilian Portuguese catalog. +Brazilian Portuguese catalog. The Serbian catalog is written in Latin script (Ekavian); every +Serbian locale (`sr-RS`, `sr-Latn-RS`, `sr_RS@latin`, and Cyrillic `sr-Cyrl-RS`) resolves to it. ### First launch and system language diff --git a/docs/ora.md b/docs/ora.md index ac83b847b8..19f3c7503e 100644 --- a/docs/ora.md +++ b/docs/ora.md @@ -23,10 +23,18 @@ entry, so an OpenRaster file with another extension still opens as one. Saving reports what OpenRaster can't hold, layer by layer: pixel masks are applied to the layer's pixels, fill opacity is folded into the pixels, text, shape, fill and smart object layers are written as their rendered pixels, and adjustment layers, layer styles, vector masks, -clipping, alpha channels, metadata and the colour profile are left out. Xor and Paint.NET's +clipping, alpha channels, metadata and a colour profile other than sRGB are left out. Xor and Paint.NET's Color Burn and Color Dodge variants have no OpenRaster name and are saved as Normal. CMYK, Lab and other modes are refused with a message to convert first; Grayscale saves as RGB. +An OpenRaster file is the document's own file only while it holds everything in it (#3806). +`photocraft_io::ora_losses` lists what a save would leave out, from the same checks the writer +warns with, without encoding anything. When that list isn't empty, File › Save doesn't overwrite +the `.ora`: Save As opens on a PSD of the same name, with a notice listing what OpenRaster would +drop. A save to an `.ora` path then writes a copy, as Photoshop does for formats that can't keep +everything: the document keeps its file and its unsaved changes. Automation follows the same rule +(`doc.save` and `app.save` without a path refuse, saying why). + OpenRaster elements other than `stack` and `layer` (MyPaint's text, for example), missing layer images and unknown composite operations open with a warning instead of failing the file. Krita's W3C Soft Light (`svg:soft-light`, `krita:soft_light_svg`) and its own Hard Mix open as @@ -46,7 +54,8 @@ are covered by tests. Unit tests (`crates/io/src/ora/tests.rs`) round-trip layers, groups, pass-through, offsets, opacity, visibility, locks and every blend mode at 8 and 16 bit, check masks, rendered layer kinds, the archive layout and malformed input. `crates/automation/tests/openraster.rs` saves, -reopens, edits and saves in place through the agent surface; the CLI test converts both ways. +reopens, edits and saves in place through the agent surface, and checks that a document with an +adjustment layer never overwrites its `.ora`; the CLI test converts both ways. Real-file checks use Krita-authored oracles from [photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`krita/`, made by @@ -57,13 +66,12 @@ Real-file checks use Krita-authored oracles from KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture ``` -2026-10-09, Krita 5.2.9: 30 of 34 `.ora` files render within 3/255 of Krita's merged image; the -other four differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's -tie-break in Darker and Lighter Color, Hard Mix at a channel sum of exactly 1, and a -linear-light 16-bit document that Krita blends in linear light). In the other direction, -Krita opened every PhotoCraft re-export of those files and rendered 33 of them identically to -its original; the exception is Photoshop's Soft Light, which is written as `svg:soft-light`. -MyPaint and GIMP files have not been checked yet. +2026-10-09, Krita 5.2.9: 32 of 35 `.ora` files render within 3/255 of Krita's merged image; the +other three differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's +tie-break in Darker and Lighter Color, and Hard Mix at a channel sum of exactly 1). In the other +direction, Krita opened every PhotoCraft re-export of the first 34 of those files and rendered 33 +of them identically to its original; the exception is Photoshop's Soft Light, which is written +as `svg:soft-light`. MyPaint and GIMP files have not been checked yet. Format reference: the [OpenRaster specification](https://www.openraster.org/). The reader and writer are written from the specification and from files Krita writes; no application code diff --git a/docs/scorecard.md b/docs/scorecard.md index a7accede67..23c4d6c412 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,10 +7,10 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 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 | +| [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 ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 29 | 13 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | -| [Type](#type) | 5 | 2 | 2 | | +| [Workspace and UI](#workspace-and-ui) | 13 | 7 | 3 | settings that do nothing: 26 of 156 (target 0) | +| [Type](#type) | 5 | 3 | 1 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 146 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 133 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 145 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -115,7 +115,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | -| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults | +| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | @@ -133,11 +133,11 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | | TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | -| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only) | +| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | -| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing | +| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | | TOOL-213-11 | Editable pressure curve, global and per control | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; monotone cubic, engine prefs::PressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Missing: per-control curves (each brush dynamic's own response to pressure, tilt, rotation) | @@ -189,10 +189,10 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs | | UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move | | UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay | -| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet | +| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | -| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes | +| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | | UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | | UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) | @@ -210,9 +210,9 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only | +| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | -| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test) | +| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3c18f66343..45d357445d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` load and save; 2026-10-11: Type evidence: kerning, the Warp Text preview and variable-font axes are done, percentages unchanged; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (`.atn` import maps 112 events; Free Transform H/V skew fields; 2026-10-11: Type evidence: kerning, the Warp Text preview and variable-font axes are done, percentages unchanged; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -49,7 +49,7 @@ architecture and the PSD writer are the serial parts. `Locales/` language packs, `Default Keyboard Shortcuts.kys` (67 toolbox tools, 107 menu commands with default shortcuts), `Required/` plug-ins (85 filter entries, 12 format plug-ins), presets, and strings in the main binary (81 "… Tool" titles; HDR/EDR, Metal, Touch Bar, pen features). -- PhotoCraft's own Photoshop menu tree (`crates/ui-egui/src/menu_catalog.rs`, 628 items) was +- PhotoCraft's own Photoshop menu tree (`crates/ui-egui/src/menu_catalog.rs`, 628 items on 2026-10-10, 631 on 2026-10-12) was written from Photoshop's menus; it is the denominator of the menu number. Photoshop's binary `Default Menus.mnu` was not parsed, so items added in 27.x that the catalog lacks would not show (Generative and cloud items are the likely ones). @@ -60,13 +60,13 @@ architecture and the PSD writer are the serial parts. | Component | Weight | Ours / Photoshop | % | Kind | |---|---:|---|---:|---| -| Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) | +| Menu commands | 30% | 631 / 631 catalog items dispatch a live command (628 / 628 on 2026-10-10) | 100% | measured (`cargo xtask parity`) | | Toolbox tools | 15% | 59 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 87% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | | Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) | | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (108 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (112 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -76,14 +76,14 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Dimension | Weight | % ready | Remaining (agent-h) | Evidence | Detail | |---|---:|---:|---:|---|---| | Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | -| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 5 / 7 / 11; 46 of 149 preferences do nothing; no floating panel groups, thin Color panel, no transform context menu, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | +| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 27 of 156 preferences do nothing (was 46 of 149); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | | Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | -| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | -| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 108 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | +| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); no in-app updates | [scorecard.md](scorecard.md#distribution) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 112 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -114,7 +114,7 @@ Discounts for what still stops real work (estimated, each with evidence): | Discount | Factor | Evidence | |---|---:|---| -| Interaction fidelity | ×0.92 | ~150 open UI issues; transform context menu, spring-loaded tools, panel tear-off missing ([ui-parity.md](ui-parity.md)); brush lag and pressure reports ([brush-parity.md](brush-parity.md)) | +| Interaction fidelity | ×0.92 | ~150 open UI issues; spring-loaded tools, panel tear-off missing ([ui-parity.md](ui-parity.md)); brush lag and pressure reports ([brush-parity.md](brush-parity.md)) | | Stability and speed on real machines | ×0.88 | 3 of 25 performance budgets met; 150-layer GPU crash (P12); Type tool freeze (#2518); GPU start failures (#2519, #2412, #2020); 41 open performance issues | | Exchanging files with Photoshop users | ×0.92 | Photoshop-authored oracle 133/256; exports refused by Photoshop (#1281, #2469); smart-filter re-render 5/30 | @@ -144,7 +144,7 @@ workflows, PSD exchange edge cases, and everything excluded above. | Layers (add, reorder, hide, opacity) | 12% | 80% | | | New / open a photo | 10% | 90% | | | Select (marquee, lasso, magic wand, object) | 10% | 75% | | -| Move and Free Transform | 10% | 70% | no commit/cancel buttons (#2442) | +| Move and Free Transform | 10% | 70% | commit and cancel buttons in the options bar (#2442, closed) | | Brush and eraser | 10% | 70% | lag with big brushes (#2619) | | Undo, History | 8% | 90% | | | Basic adjustments (brightness, levels, curves, hue/saturation) | 8% | 80% | | @@ -167,17 +167,17 @@ parity for the area and are already contained in the dimension rows above. | Area | % | Hours | Notes | |---|---:|---:|---| -| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: multi-layer Smart Object conversion (#2604, #2648), layer clipboard between documents (UI-217-23), Stamp Visible, contour editor, smart-object perspective (FILE-215-6), mask targeting by command (#2458) | -| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a generic dialog (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: layer clipboard between documents (UI-217-23: `layer.copyToDocument` exists, no copy/paste), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | +| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | -| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) | +| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); no type on a path, RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); missing on-canvas corner radius (TOOL-214-12), path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (108 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | @@ -563,8 +563,11 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | +| 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | | 2026-10-11 | minor | Type row evidence: manual/optical kerning and the live Warp Text preview are done (TYPE-218-6/7; variable-font axes TYPE-218-4 too); the 40% estimate is not re-measured | | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | +| 2026-10-12 | minor | Rows checked against `main` and the regenerated scorecard: menu count 631, Workspace checklist and preference count, transform context menu, Puppet Warp mesh, Select and Mask, anchor-point tools, Smart Object conversion, Stamp Visible, mask targeting, Free Transform commit/cancel, Vietnamese catalog, Windows ARM64; percentages, weights and hours not re-measured | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | diff --git a/docs/ui-design.md b/docs/ui-design.md index 91fac93374..4636cdb7cd 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -14,6 +14,8 @@ | Solarized Dark | Ethan Schoonover's Solarized palette: base03 canvas, base02 panels, base0 text, Solarized blue accent | | Breeze Light | An adaptation of KDE Plasma's Breeze Light on the Studio layout: cool light blue-grey surfaces a few levels from Breeze's so the app blends into a Plasma desktop, Breeze-style blue accent, 5 px radii, red close button on hover; text and status colours are darker than Breeze's for contrast | | Breeze Dark | The same for Breeze Dark: cool dark blue-grey surfaces (darkest canvas, then dock, cards, chrome and controls) near Breeze's, blue accent, 5 px radii; secondary text and status colours are lighter than Breeze's for contrast | +| Soft Dark | Low-contrast dark on the Studio layout: muted violet-grey surfaces (never near-black), softened off-white text, periwinkle accent, generous 7/10/14 px radii; text still holds 4.5:1 on every surface (`theme.rs` tests) | +| Soft Light | The light counterpart: pale violet-grey surfaces, softened dark-violet text instead of black, periwinkle accent, the same radii | Both Breeze themes are our own values close to Plasma's (not a copy of its colour schemes) and put text readability first: every text colour holds 4.5:1 on every surface and on hovered, pressed and selected states (`theme.rs` tests), so their secondary text, accent and status colours are darker (Light) or lighter (Dark) than Breeze's own, and the primary button is a deeper blue with white text. @@ -21,7 +23,7 @@ Edit → Preferences → Interface shows an Appearance Mode selector (Sync with Window → Theme and macOS PhotoCraft → Appearance also offer Sync with system. Its checkmark follows the saved Auto mode rather than the displayed palette; choosing a manual theme selects its fixed appearance mode. -The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light and Breeze Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. +The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light, Breeze Dark, Soft Dark and Soft Light (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. ## Rules diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7f44d93731..70755cfcb2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (selection-modifier and ⌥-wheel rows match their tests; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; selection-modifier and ⌥-wheel rows match their tests) · **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, @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | @@ -66,7 +66,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌘-corner distort, ⌘-edge skew, ⌘⌥⇧ perspective, ⇧ rotate in 15° steps | done | | | Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | | | Options bar X/Y, W/H % with link, angle | done | | -| Options bar H/V skew fields | missing | | +| Options bar H/V skew fields | done | `transform_tool.rs` (`Parts`, `skew_about_pivot`): W/H, angle and skew read as Photoshop decomposes the box; `skew_fields_lean_the_box_about_the_reference_point` | | Interpolation in the options bar | partial | Bicubic/Bilinear/Nearest; no Bicubic Smoother/Sharper/Automatic, Preserve Details | | Commit/cancel buttons in the options bar | missing | #2442 | | Right-click menu during transform (Scale, Rotate, Skew, Distort, Perspective, Warp, flips, rotates) | missing | TOOL-214-5 | @@ -118,6 +118,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | partial | TOOL-214-10 | | Handle on the last point, close-drag handle | partial | #1482 | +| Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | missing | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | @@ -161,7 +162,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held | done | `hold_keys.rs` | -| Hold any tool letter for a temporary tool | missing | #2672 | +| Hold any tool letter for a temporary tool | done | `spring_tools.rs`: held ≥ 300 ms or used on the canvas, release gives the previous tool back; ⇧ never springs (#2672) | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | | Animated zoom | missing | unread pref; #566 | @@ -182,9 +183,11 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here | | `.kys` import | done (`engine/src/kys.rs`) | | | Shortcut glyphs per platform | done (UI-217-19) | | +| Toolbar tool shortcut editing | done (#4086): each tool has a row under Tools; assignments persist, removed tools leave the default key group, and unchanged tools keep Shift cycling. Toolbar and flyout hints follow the binding | `tool_shortcuts.rs`, `shortcut_capture_tests.rs` | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | +| Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | ## Panels and workspace @@ -207,6 +210,7 @@ Paragraph; the rest open as in-app floating windows. | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | +| Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) | ## Revision history @@ -219,4 +223,10 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | +| 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | +| 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-11 | minor | Selection modifiers at press: evidence now names the marquee, Lasso and Polygonal Lasso tests (still partial: no marquee ⇧⌥ intersect test). ⌥-wheel zoom: missing → partial (works at ×1.1 a notch; #217 asked for ~5 %) | +| 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | +| 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | +| 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H | diff --git a/packaging/linux/ai.storyteller.photocraft.desktop b/packaging/linux/ai.storyteller.photocraft.desktop index a32b55cef5..bee0496f39 100644 --- a/packaging/linux/ai.storyteller.photocraft.desktop +++ b/packaging/linux/ai.storyteller.photocraft.desktop @@ -12,7 +12,7 @@ StartupNotify=false StartupWMClass=ai.storyteller.photocraft Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;image;editor;psd;layers;retouch;paint; -MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; +MimeType=application/x-photocraft;image/x-paintnet;image/openraster;application/x-krita;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; # AppRun registers the desktop entry and icons itself (#593); tell external # AppImage integrators (AppImageLauncher, appimaged) not to add a duplicate (#3623). X-AppImage-Integrate=false diff --git a/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in b/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in index 6fbba13511..932ff44e4c 100644 --- a/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in @@ -64,6 +64,7 @@ application/x-photocraft image/x-paintnet image/openraster + application/x-krita image/vnd.adobe.photoshop image/x-psb diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 5dec67ee2e..d325b2b9d6 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,32 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + ja + zh-Hans + zh-Hant + es + ru + uk + sr + cs + fr + id + ko + pl + de + pt-BR + el + nl + it + hu + tr + vi + CFBundleDisplayName PhotoCraft CFBundleName @@ -87,6 +113,21 @@ image/openraster + + UTTypeIdentifier + ai.storyteller.photocraft.kra + UTTypeDescription + Krita Document + UTTypeConformsTo + public.datapublic.image + UTTypeTagSpecification + + public.filename-extension + krakrz + public.mime-type + application/x-krita + + UTTypeIdentifier ai.storyteller.photocraft.pdn @@ -187,6 +228,7 @@ ai.storyteller.photocraft.qoi ai.storyteller.photocraft.pdn ai.storyteller.photocraft.ora + ai.storyteller.photocraft.kra diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index 98dc1fdfd3..8be9ff893c 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -103,6 +103,8 @@ + + @@ -126,6 +128,8 @@ + + diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 3c93a9d5b3..780bee4f16 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -129,7 +129,7 @@ id = "FILE-215-18" target = "Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling" status = "partial" issue = 215 -note = "file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults" +note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling" [[item]] id = "FILE-215-19" diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 380a9b53a8..e4c4d5568f 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -31,7 +31,7 @@ id = "TOOL-213-4" target = "Content-Aware Move tool reusing inpaint completion" status = "partial" issue = 213 -note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only)" +note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections" [[item]] id = "TOOL-213-5" @@ -59,7 +59,7 @@ id = "TOOL-213-8" target = "Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor" status = "partial" issue = 213 -note = "Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing" +note = "Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298)" [[item]] id = "TOOL-213-9" diff --git a/scorecard/type.toml b/scorecard/type.toml index 223368656d..1cf4adaba3 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -7,9 +7,9 @@ order = 5 [[item]] id = "TYPE-218-1" target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip" -status = "missing" +status = "partial" issue = 218 -note = "doc text.rs:209 TextShape has Point and Box only" +note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)" [[item]] id = "TYPE-218-2" @@ -23,7 +23,7 @@ id = "TYPE-218-3" target = "Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL" status = "partial" issue = 218 -note = "Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test)" +note = "Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test)" [[item]] id = "TYPE-218-4" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index da44bb27ae..6da4fd34eb 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -59,7 +59,7 @@ id = "UI-217-8" target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields" status = "partial" issue = 217 -note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet" +note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" [[item]] id = "UI-217-9" @@ -78,9 +78,9 @@ note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes e [[item]] id = "UI-217-11" target = "Soft dark and soft light themes with picker previews; outlined knobs in light themes" -status = "missing" +status = "partial" issue = 217 -note = "theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes" +note = "theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing" [[item]] id = "UI-217-12" diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 595fc65568..ee337c0b9b 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -582,6 +582,7 @@ mod tests { ("hu", "hu.tsv"), ("tr", "tr.tsv"), ("vi", "vi.tsv"), + ("sr", "sr.tsv"), ] { let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); let text = fs::read_to_string(&catalog_path).unwrap_or_default();