From 7250e6e29a9248ac17bc0cb20296e03e7cbed86a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 09:21:47 -0400 Subject: [PATCH 001/199] Tests: autosave retry waits for the finished write, not a mid-write listing Since ed7df56a, list_recovery also lists a committed bundle whose sidecar is not yet published (revision 0). The worker commits the manifest before it writes the sidecar, so the retry test could catch the bundle between the two and read revision 0 (seen on windows-latest; 215/300 local runs on Linux under load). Wait until the entry carries the expected revision. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,12 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From c699e3cda69f4852b43c0b191ab0091aaeff9d6a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 10:16:05 -0400 Subject: [PATCH 002/199] Release 0.9.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 58 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7bfc1112c..54085cf447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4613,7 +4613,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.8.2" +version = "0.9.0" dependencies = [ "arboard", "async-io", @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.8.2" +version = "0.9.0" dependencies = [ "crc32fast", "flate2", @@ -4667,7 +4667,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4680,7 +4680,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.8.2" +version = "0.9.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4702,7 +4702,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4716,7 +4716,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.8.2" +version = "0.9.0" dependencies = [ "moxcms", "rayon", @@ -4724,7 +4724,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.8.2" +version = "0.9.0" dependencies = [ "exr", "flate2", @@ -4745,7 +4745,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-cms", "serde", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.8.2" +version = "0.9.0" dependencies = [ "criterion", "photocraft-color", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4779,7 +4779,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "craft-print", @@ -4814,7 +4814,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "crc32fast", @@ -4835,14 +4835,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.8.2" +version = "0.9.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.8.2" +version = "0.9.0" dependencies = [ "half", "log", @@ -4859,14 +4859,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.8.2" +version = "0.9.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.8.2" +version = "0.9.0" dependencies = [ "blake3", "crc32fast", @@ -4895,14 +4895,14 @@ dependencies = [ [[package]] name = "photocraft-ops" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4913,7 +4913,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4928,7 +4928,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "photocraft-psd", @@ -4939,7 +4939,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4949,7 +4949,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "image", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.8.2" +version = "0.9.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4976,7 +4976,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.8.2" +version = "0.9.0" dependencies = [ "memory-stats", "photocraft-color", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.8.2" +version = "0.9.0" dependencies = [ "flate2", "parley", @@ -5001,7 +5001,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.8.2" +version = "0.9.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5042,7 +5042,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.8.2" +version = "0.9.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5053,7 +5053,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.8.2" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -8651,7 +8651,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.8.2" +version = "0.9.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2c329d980d..663d268c3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.8.2" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From e34cdcbcca143ccb60bffbad6641697681174d91 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 03:44:47 +1100 Subject: [PATCH 003/199] Keep channel pastes transactional when pixel allocation fails. (#4588) Co-authored-by: Brandon Thomas --- crates/engine/src/channel_clip.rs | 11 +++++++---- crates/engine/src/channel_clip/tests.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index fea1048235..86a49b0be6 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -96,10 +96,13 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); } if !area.is_empty() { - let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = (area.width() as usize).checked_mul(area.height() as usize).ok_or_else(|| EngineError::Other("the channel paste is too large".into()))?; + let mut rgba = crate::allocation::filled(len, [0.0f32; 4], "pasting into a channel")?; src.read_rgba_into(area, &mut rgba); - let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1))); - let mut px = surf.read_region(area); + let weight = limit + .map(|m| crate::allocation::read_region(m, area, "reading the channel paste selection").map(|v| (v, m.format().channels().max(1)))) + .transpose()?; + let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?; for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() { let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]); @@ -107,7 +110,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: *c += (v - *c) * w.clamp(0.0, 1.0); } } - surf.write_region(area, &px); + surf.try_write_region(area, &px)?; surf.prune(); } doc.selection = None; diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index be7e51dfd7..3d89c8f647 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -164,6 +164,25 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() { } } +#[test] +fn channel_paste_allocation_failure_is_transactional() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("edit.copy", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = paste(&mut s, &json!({}), 1, true, None, "Paste"); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + paste(&mut s, &json!({}), 1, true, None, "Paste").unwrap(); + assert!(s.undo()); + assert_eq!(*s.active().unwrap().doc, *before); + } +} + #[test] fn channel_copy_allocation_failure_keeps_clipboard_and_document() { for depth in [8, 16, 32] { From c4bf9eb04526f7c261a1834d24639ee7166d7c48 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 03:44:54 +1100 Subject: [PATCH 004/199] Bound selection sampling buffers and report allocation failures. (#4594) Co-authored-by: Brandon Thomas --- crates/engine/src/selection_cmds.rs | 41 ++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index ecc812a51c..eda55c7d73 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -50,10 +50,17 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; let area = d.doc.bounds(); + // Match the decoders' raster budget before either the layer or composite path allocates. + let len = area.size().area(); + let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { + return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); + } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => { - let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = usize::try_from(len).map_err(|_| EngineError::Other("the canvas is too large to sample for a selection".into()))?; + let mut px = crate::allocation::filled(len, [0.0f32; 4], "sampling pixels for a selection")?; surf.read_rgba_into(area, &mut px); Ok((area, px)) } @@ -770,6 +777,38 @@ mod tests { assert!(err.to_string().contains("detectFaces"), "{err}"); } + #[test] + fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { + let mut s = Session::new(); + s.add_document( + photocraft_doc::Document::new( + "large", + photocraft_geom::Size::new(300_000, 300_000), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ), + None, + ); + for all_layers in [false, true] { + assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample")); + } + assert!(!s.active().unwrap().history.can_undo()); + } + + #[test] + fn selection_sampling_allocation_failure_keeps_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = s.execute("select.similar", json!({"sampleAllLayers": false})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + } + #[test] fn modify_rejects_oversized_canvases_before_reading_the_mask() { let mut s = Session::new(); From 93a16218de5d6567e41f7edc909360e0a3a73b10 Mon Sep 17 00:00:00 2001 From: Timothy Daly <100327160+timothydaly1@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:44:59 -0400 Subject: [PATCH 005/199] Gradient Map is driven by the W3C luminosity, as in Photoshop (#5018) Photoshop's Gradient Map picks each pixel's place on the gradient by 0.3 R + 0.59 G + 0.11 B, the luminosity of the blend modes. We used the Rec. 601 grey (0.299, 0.587, 0.114). That is a few thousandths off, but it is enough to land two levels away on steep stretches of a gradient. The photoshop corpus's rgb8 and rgb16 gradient-map.psd now pass (they were 2 levels off on 0.8-1.7% of pixels). Sampling the gradient through a 256- or 1024-entry table didn't help; the weights did. CPU (compose::adjust) and GPU (the shader's lum) both change. Lab documents keep their Gradient Map driven by L, and grey pixels map as before. Photoshop oracles 145 -> 147 of 258; floor 145 -> 147. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/compose/src/adjust.rs | 17 ++++++++++++++++- crates/gpu/src/compose.wgsl | 4 ++-- crates/io/tests/corpus.rs | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/scorecard.md | 4 ++-- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 22a8cf063a..726b375e36 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -204,7 +204,10 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth if p[3] <= 0.0 { continue; } - let mut t = rgb_to_gray([p[0], p[1], p[2]]); + // Driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: photoshop + // corpus rgb8 and rgb16 gradient-map.psd match within a level (2 levels off with + // 0.299, 0.587, 0.114). + let mut t = photocraft_color::blend::lum([p[0], p[1], p[2]]); if *reverse { t = 1.0 - t; } @@ -1525,6 +1528,18 @@ mod tone_tests { } } + #[test] + fn gradient_map_follows_the_w3c_luminosity() { + // Photoshop's Gradient Map is driven by 0.3 R + 0.59 G + 0.11 B (photoshop corpus rgb8 and + // rgb16 gradient-map.psd), not the Rec. 601 grey. + let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; + for (c, t) in [([0.0, 1.0, 0.0], 0.59), ([1.0, 0.0, 0.0], 0.3), ([0.0, 0.0, 1.0], 0.11), ([0.2, 0.6, 0.9], 0.513)] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut b); + assert!((b.px[0][0] - t).abs() < 1e-5, "{c:?}: {}", b.px[0][0]); + } + } + #[test] fn gradient_map_dither_stays_close() { let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index e9d1b370ca..03a8690032 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -707,8 +707,8 @@ fn adjust(c: vec3) -> vec3 { } return vec3(g); } - case 13: { // Gradient map - var t = gray(c); + case 13: { // Gradient map (driven by the W3C luminosity) + var t = lum(c); if (p0.x > 0.5) { t = 1.0 - t; } var o = vec3(lut(0, t), lut(1, t), lut(2, t)); if (p0.y > 0.5) { o = clamp(o + bayer4(adj_px) / 255.0, vec3(0.0), vec3(1.0)); } diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 776f27f89d..0689c03ea2 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 145, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 147, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 590b9febc5..5229842b6e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 145/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 147/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | diff --git a/docs/gaps.md b/docs/gaps.md index c952bac81b..b932848afb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 145/258 (56%; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% + oracles 147/258 (57%; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -262,6 +262,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | diff --git a/docs/scorecard.md b/docs/scorecard.md index 56a16654bd..2ee12e28ea 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 147/256, round trip ≥ 258/256 | | [Tools](#tools) | 29 | 13 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 146 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 145 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 147 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| From d12dc6b7188204889b6bac4d5fca7e1fa24f8af3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:45:05 +0200 Subject: [PATCH 006/199] fix(layers): merging layers keeps pixels that lie outside the canvas (#5067) (#5124) --- crates/engine/src/commands.rs | 2 +- crates/engine/src/layer_multi_cmds.rs | 33 ++++++++++++++++++++++++++- crates/engine/src/pixels.rs | 33 ++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a954eca7e2..38a1b4d7e0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1550,7 +1550,7 @@ fn merge_group(s: &mut Session, id: LayerId) -> Result { solo.clipped = false; let mut tmp = doc.clone(); tmp.layers = vec![solo]; - let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None))); + let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers_uncropped(&tmp, fmt))); merged.id = group.id; merged.opacity = group.opacity; merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend }; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 80da3bdd81..2accc4ea86 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1270,7 +1270,7 @@ fn merge_layers(s: &mut Session) -> Result { let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); let mut merged = Layer::raster(top.name.clone(), fmt); merged.locks = top.locks; - *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); let mid = merged.id; // Hidden selected layers are discarded, as in Photoshop. for id in &ids[..ids.len() - 1] { @@ -2051,6 +2051,37 @@ mod tests { assert!(!s.is_enabled("layer.distribute.leftEdges")); } + #[test] + fn merge_keeps_pixels_outside_the_canvas() { + // #5067: the merged layer was cropped to the 100x80 canvas. + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let m = LayerId(s.execute("layer.mergeLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let ml = doc(&s).layer(m).unwrap(); + assert_eq!(layer_bounds(ml).unwrap(), Rect::new(-30, -20, 140, 120)); + assert_eq!(ml.surface().unwrap().pixel(-10, -5)[3], 1.0); + assert_eq!(ml.surface().unwrap().pixel(130, 110)[3], 1.0); + } + + #[test] + fn merge_down_and_merge_group_keep_pixels_outside_the_canvas() { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + s.execute("layer.mergeDown", json!({"layer": b.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(a).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.mergeGroup", json!({"layer": g.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(g).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + } + #[test] fn merge_selected_into_top_most() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index cde7fa41db..b2641a5c16 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -28,6 +28,36 @@ pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, photocraft_compose::composite_to_surface(solo, area, format, background) } +/// [`composite_layers`] without cropping to the canvas: pixels of the layers that lie outside it are kept +/// (merging layers must not lose them, #5067). +pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::PixelFormat) -> Surface { + let canvas = solo.bounds(); + // The canvas grown to every visible layer's content and effects, so no layer is clipped by `composite_bounds`. + let reach = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| { + let margin = if photocraft_compose::effects::has_effects(layer) { photocraft_compose::effects::margin(layer) } else { 0 }; + let bounds = photocraft_compose::layer_bounds(layer, canvas); + if bounds.is_empty() { bounds } else { bounds.inflate(margin) } + }) + .filter(|bounds| !bounds.is_empty()) + .fold(canvas, |a, b| a.union(&b)); + let area = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| photocraft_compose::composite_bounds(layer, reach).unwrap_or(canvas)) + .filter(|bounds| !bounds.is_empty()) + .reduce(|a, b| a.union(&b)) + .unwrap_or(Rect::EMPTY); + if area.is_empty() { + return Surface::new(format); + } + photocraft_compose::composite_to_surface(solo, area, format, None) +} + /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the /// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale). @@ -339,7 +369,8 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // The pair is read (and a CMYK one separated again) through the document's own profile. tmp.icc_profile = doc.icc_profile.clone(); tmp.layers = stack; - let s = composite_layers(&tmp, format, None); + // A Background stays canvas-sized; any other layer keeps the pixels outside the canvas (#5067). + let s = if doc.is_background(lower.id) { composite_layers(&tmp, format, None) } else { composite_layers_uncropped(&tmp, format) }; let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s)); merged.id = lower.id; merged.blend = lower.blend; From 1ab33d5113c5df285f1199ea31e84eab85f16288 Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:15:11 +0530 Subject: [PATCH 007/199] Map .atn duplicate-into-new-document (Mk) to layer.duplicate (#5371) * Map .atn duplicate-into-new-document (Mk) to layer.duplicate Photoshop records Layer > Duplicate Layer with Destination: New as a Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy name), verified against a ScriptListener/XTensions export. Import maps it to layer.duplicate {document: new}; export writes it back, so the steps round-trip instead of becoming kept Photoshop steps that fail at playback. A Dplc with a T document reference (another open doc) now imports as a kept step rather than misplaying as a same-doc copy. * Fix Rust formatting in ATN command tests * Fix rustfmt formatting in ATN command tests --- crates/engine/src/atn_cmds.rs | 45 +++++++++++++++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 21 ++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 391c21abd8..ebeda48687 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -983,6 +983,26 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { + // Duplicate layer/group into a new document: `Mk` with a document class, a layer + // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` + // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another + // open document) stays a kept Photoshop step: document indices mean nothing at replay. + if let Some(r) = get_ref(d, "null") + && ref_class(r).as_deref() == Some("Dcmn") + && matches!(target(r), Target::Class) + && let Some(u) = get_ref(d, "Usng") + && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) + && let Some(mut p) = layer_target(u) + { + p.insert("document".into(), "new".into()); + if let Some(n) = get_text(d, "Nm ") { + p.insert("documentName".into(), n.into()); + } + if let Some(n) = get_text(d, "LyrN") { + p.insert("name".into(), n.into()); + } + return step("layer.duplicate", Value::Object(p)); + } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1117,6 +1137,21 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.duplicate" => { + // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). + // Anything else falls through to the `Dplc` event below. + if jstr(p, "document") != Some("new") { + return None; + } + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + if let Some(n) = jstr(p, "documentName") { + d = d.with("Nm ", text(n)); + } + if let Some(n) = jstr(p, "name") { + d = d.with("LyrN", text(n)); + } + d + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1876,6 +1911,11 @@ fn duplicate_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match ref_class(r).as_deref() { Some("Lyr") => { + // A `T` document reference (duplicate into another open document) has no + // replayable meaning here: keep the step verbatim rather than misplay it. + if get_ref(d, "T ").is_some() { + return Mapped::Unsupported { harmless: false }; + } let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; if let Some(n) = get_text(d, "Nm ") { p.insert("name".into(), n.into()); @@ -1897,6 +1937,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { + // Duplicate into a new document is a `Mk` (handled above); let it fall through. + if id == "layer.duplicate" && jstr(p, "document") == Some("new") { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -2696,6 +2740,7 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", + "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index bdb839fa30..871300372c 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -212,6 +212,12 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), + // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of + // a recorded Duplicate Layer with Destination: New, plus a named layer). + ("layer.duplicate", json!({"document": "new"})), + ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), + ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), + ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -448,6 +454,17 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + // Duplicate into another open document: the `T` reference means nothing at + // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. + item( + "Dplc", + "Duplicate", + Some( + desc("null") + .with("null", target_ref("Lyr ")) + .with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }])), + ), + ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -457,8 +474,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); - assert_eq!(imp.unsupported.len(), 4); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } From 3888e95e477f5f1e62581d8360057267b4dae9b9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:45:19 +0200 Subject: [PATCH 008/199] Text: non-finite numbers and negative indices from PSD files are sanitized (#4429) (#4433) Co-authored-by: Claude Opus 5.5 --- crates/text/src/engine_data.rs | 3 +- crates/text/src/layout.rs | 2 +- crates/text/src/lib.rs | 2 +- crates/text/src/psd.rs | 76 +++++++++++++++++++++++++--------- crates/text/src/raster.rs | 4 ++ crates/text/src/served.rs | 10 ++++- 6 files changed, 73 insertions(+), 24 deletions(-) diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index c8115c3e1b..a68d093669 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -46,7 +46,8 @@ impl Value { pub fn as_f64(&self) -> Option { match self { Value::Int(i) => Some(*i as f64), - Value::Real(r) => Some(*r), + // `inf`, `nan` and `1e999` parse as reals but are never a valid style number. + Value::Real(r) if r.is_finite() => Some(*r), _ => None, } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 55f9e0a539..e2bd223b3b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -454,7 +454,7 @@ impl Layouter { } pub fn layout(&mut self, fonts: &mut FontDb, t: &TextLayer, dpi: f32) -> TextLayout { - let k = if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; let runs = t.char_runs(); let paras = t.paragraph_runs(); let vertical = t.orientation == Orientation::Vertical; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7909199a6f..ad5e90ae35 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -92,7 +92,7 @@ impl TextEngine { acc += r.len; at < acc })?; - let em = style_run.style.size_pt * if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let em = style_run.style.size_pt * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; if em.is_nan() || em <= 0.0 { return None; } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 3f8ce72902..0092fcf92e 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -105,8 +105,8 @@ fn enum_value(d: &Descriptor, key: &str) -> Option { fn dnum(v: Option<&D>) -> Option { match v { - Some(D::Double(x)) => Some(*x), - Some(D::UnitFloat { value, .. }) => Some(*value), + Some(D::Double(x)) if x.is_finite() => Some(*x), + Some(D::UnitFloat { value, .. }) if value.is_finite() => Some(*value), Some(D::Integer(i)) => Some(f64::from(*i)), _ => None, } @@ -360,7 +360,7 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { }); } } - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; if let Some(e) = engine_data(&t.text) { // Without an `Ornt` key, the engine data's writing direction says it (2 = vertical). if enum_value(&t.text, "Ornt").is_none() && e.path(&["EngineDict", "Rendered", "Shapes", "WritingDirection"]).and_then(E::as_f64) == Some(2.0) { @@ -582,6 +582,8 @@ fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle { let g = |key: &str| lookup(d, base, key); let num = |key: &str| g(key).and_then(E::as_f64); + // An f32 that a huge finite f64 overflowed to infinity is dropped too. + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); let flag = |key: &str| g(key).and_then(E::as_bool); let mut s = CharStyle::default(); if let Some(ps) = g("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)) { @@ -591,18 +593,18 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C s.italic = r.italic; s.postscript_name = Some(ps.clone()); } - if let Some(v) = num("FontSize") { - s.size_pt = v as f32 * k; + if let Some(v) = numk("FontSize", k) { + s.size_pt = v; } if flag("AutoLeading") == Some(false) - && let Some(v) = num("Leading") + && let Some(v) = numk("Leading", k) { - s.leading_pt = Some(v as f32 * k); + s.leading_pt = Some(v); } - s.tracking = num("Tracking").unwrap_or(0.0) as f32; - s.baseline_shift_pt = num("BaselineShift").unwrap_or(0.0) as f32 * k; - s.horizontal_scale = num("HorizontalScale").unwrap_or(1.0) as f32; - s.vertical_scale = num("VerticalScale").unwrap_or(1.0) as f32; + s.tracking = numk("Tracking", 1.0).unwrap_or(0.0); + s.baseline_shift_pt = numk("BaselineShift", k).unwrap_or(0.0); + s.horizontal_scale = numk("HorizontalScale", 1.0).unwrap_or(1.0); + s.vertical_scale = numk("VerticalScale", 1.0).unwrap_or(1.0); s.faux_bold = flag("FauxBold").unwrap_or(false); s.faux_italic = flag("FauxItalic").unwrap_or(false); s.underline = flag("Underline").unwrap_or(false); @@ -615,14 +617,17 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C } } s.kerning = if flag("AutoKerning") == Some(false) { Kerning::Off } else { Kerning::Metrics }; - s.kern = num("Kerning").filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + s.kern = numk("Kerning", 1.0).unwrap_or(0.0); s.caps = match g("FontCaps").and_then(E::as_i64) { Some(1) => Caps::SmallCaps, Some(2) => Caps::AllCaps, _ => Caps::Normal, }; if let Some(c) = g("FillColor") { - let v = arr_f(c.get("Values")); + // Positions matter (alpha, then the channels): a non-finite entry voids the colour. + let v: Vec = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default(); + let n_raw = c.get("Values").and_then(E::as_array).map_or(0, <[E]>::len); + let v = if v.len() == n_raw && v.iter().all(|x| (*x as f32).is_finite()) { v } else { Vec::new() }; let a = v.first().copied().unwrap_or(1.0) as f32; s.color = match v.len() { 5 => Color { mode: ColorMode::Cmyk, c: [v[1] as f32, v[2] as f32, v[3] as f32, v[4] as f32], alpha: a }, @@ -636,6 +641,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { let num = |key: &str| lookup(d, base, key).and_then(E::as_f64); + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); ParagraphStyle { align: match num("Justification").map(|v| v as i64) { Some(1) => TextAlign::Right, @@ -646,12 +652,12 @@ pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { Some(6) => TextAlign::JustifyAll, _ => TextAlign::Left, }, - first_line_indent_pt: num("FirstLineIndent").unwrap_or(0.0) as f32 * k, - start_indent_pt: num("StartIndent").unwrap_or(0.0) as f32 * k, - end_indent_pt: num("EndIndent").unwrap_or(0.0) as f32 * k, - space_before_pt: num("SpaceBefore").unwrap_or(0.0) as f32 * k, - space_after_pt: num("SpaceAfter").unwrap_or(0.0) as f32 * k, - auto_leading: num("AutoLeading").unwrap_or(1.2) as f32, + first_line_indent_pt: numk("FirstLineIndent", k).unwrap_or(0.0), + start_indent_pt: numk("StartIndent", k).unwrap_or(0.0), + end_indent_pt: numk("EndIndent", k).unwrap_or(0.0), + space_before_pt: numk("SpaceBefore", k).unwrap_or(0.0), + space_after_pt: numk("SpaceAfter", k).unwrap_or(0.0), + auto_leading: numk("AutoLeading", 1.0).unwrap_or(1.2), hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false), ..Default::default() } @@ -1089,7 +1095,7 @@ pub fn set_text_index(tysh: &[u8], index: i32) -> Option> { /// Updates (or creates) an EngineData tree for `layer`. pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E { - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; let fresh = || E::Dict(vec![("EngineDict".into(), E::dict()), ("ResourceDict".into(), E::dict())]); // The template comes from the PSD: it may not be a dictionary or may lack `EngineDict`. let mut e = match template { @@ -1334,3 +1340,33 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec let bounds = if warped { [l, t, r, b] } else { [0.0; 4] }; write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } + +#[cfg(test)] +mod nonfinite_tests { + use super::*; + use crate::engine_data::parse; + + #[test] + fn non_finite_style_numbers_fall_back_to_defaults() { + let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap(); + let s = char_style(None, &d, &[], 1.0); + let dflt = CharStyle::default(); + assert_eq!(s.size_pt, dflt.size_pt); + assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0)); + assert_eq!(s.color, Color::BLACK); + // A finite f64 too big for f32 is dropped as well. + let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt); + assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0); + let d = parse(b"<< /FontSize 24.0 >>").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); + } + + #[test] + fn infinite_dpi_does_not_write_a_zero_size() { + let layer = + TextLayer { text: "x".into(), runs: vec![TextRun { len: 1, style: CharStyle { size_pt: 12.0, ..Default::default() } }], ..Default::default() }; + let out = String::from_utf8_lossy(&crate::engine_data::write(&build_engine_data(&layer, None, f32::INFINITY))).into_owned(); + assert!(out.contains("/FontSize 12.0"), "{out}"); + } +} diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index 431d020b23..bca52e38cf 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -88,6 +88,10 @@ impl Coverage { } let (dir, (x0, y0), (x1, y1)) = if p0.1 < p1.1 { (1.0f32, p0, p1) } else { (-1.0f32, p1, p0) }; let (x0, y0, x1, y1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); + // A finite f64 beyond f32 range becomes infinite here. + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return; + } if y1 <= 0.0 || y0 >= h { return; } diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 73c5d7f30b..0198aea97a 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -127,7 +127,7 @@ pub fn language_script(tag: &str) -> Option<&'static str> { /// The Subresource Integrity value (`sha256-`) for a 64-digit hex SHA-256. fn sri(hex: &str) -> Option { - if hex.len() != 64 { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let bytes: Option> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); @@ -444,4 +444,12 @@ mod tests { assert_eq!(language_script(tag), want, "{tag}"); } } + + #[test] + fn sri_rejects_non_hex_digits() { + let ok = "0".repeat(64); + assert!(sri(&ok).is_some()); + let plus = format!("+f{}", "0".repeat(62)); + assert!(sri(&plus).is_none()); + } } From 56c5b355a4cdc8d5cb1d92fe79b34a1b7a36ec3b Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 18:45:24 +0200 Subject: [PATCH 009/199] feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5140) * feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5060) The Elliptical and Single Row/Column Marquee are options of select.rect ("ellipse":true, "line"), but the command-list filters only looked at the id and the label ("Rectangular Selection"), so 'ellipse' and 'marquee' found nothing. Commands can now carry search keywords. command.list / engine.commands rows include them as "keywords", and the three filters (CLI commands --filter, the engine.commands RPC and the MCP command_list) share one matcher over id, label and keywords. select.rect gets the Photoshop marquee names as keywords. * Command list replies: document the optional keywords key The reply-shape test from #3684 pins the exact keys of each row; select.rect now also carries keywords, so the test and docs/control-protocol.md allow that one optional string. --- apps/photocraft-cli/src/lib.rs | 4 +--- apps/photocraft-cli/tests/cli.rs | 5 +++++ crates/automation/src/rpc.rs | 20 +++++++------------- crates/automation/src/server.rs | 7 +++---- crates/automation/tests/mcp.rs | 3 +++ crates/engine/src/commands.rs | 26 ++++++++++++++++++++++++-- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ docs/control-protocol.md | 3 ++- 8 files changed, 77 insertions(+), 23 deletions(-) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index a81cce61ce..b93d4ccc8f 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -541,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R { .map(|v| v.iter().collect::>()) .unwrap_or_default() .into_iter() - .filter(|c| { - needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n)) - }) + .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) .collect(); if a.has("--json") { return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false); diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 231eb2c321..3c28a8196e 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() { // Every flag still works in both forms. let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"])); assert!(out.contains("filter.blur.gaussianBlur"), "{out}"); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + for needle in ["ellipse", "marquee"] { + let (out, _) = ok(bin().args(["commands", "--filter", needle])); + assert!(out.contains("select.rect"), "{needle}: {out}"); + } ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([ "--format=jpg", "--quality", diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 507010b9c8..dc01f0f2b4 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -91,17 +91,9 @@ impl Headless { let all = self.command_list(); Ok(match str_of(&p, "filter").map(str::to_lowercase) { None => all, - Some(n) => Value::Array( - all.as_array() - .into_iter() - .flatten() - .filter(|c| { - let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")); - hay.to_lowercase().contains(&n) - }) - .cloned() - .collect(), - ), + Some(n) => { + Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect()) + } }) } "session.list" => Ok(self.session_list()), @@ -365,7 +357,7 @@ mod tests { /// Pins the reply shape documented in docs/control-protocol.md › Command list replies /// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with - /// exactly these keys, from `engine.commands` (filtered or not) and `command.list`. + /// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`. #[test] fn command_list_replies_are_documented_array_of_objects() { let h = session(); @@ -377,8 +369,10 @@ mod tests { let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}")); assert!(!items.is_empty(), "{name}"); for c in items { - let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect(); + // `keywords` is optional: only commands with other search names carry it (#5060). + let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect(); keys.sort_unstable(); + assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}"); assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}"); assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}"); assert!(c["needsDocument"].is_boolean(), "{c}"); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b31507b005..f115473c3e 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -205,7 +205,7 @@ pub struct SelectParams { #[derive(Debug, Default, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ListParams { - /// Only commands whose id or label contains this text (case-insensitive). + /// Only commands whose id, label or keywords contain this text (case-insensitive). #[serde(default)] pub filter: Option, /// Only commands that can run right now. @@ -821,9 +821,8 @@ impl PhotocraftMcp { .unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default()) .into_iter() .filter(|c| { - let hay = - format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase(); - needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) + needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)) + && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) }) .collect(); Ok(ok_json(&Value::Array(items))) diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index ca34d9f915..7386f3d58c 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -167,6 +167,9 @@ async fn command_list_filters() { let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await); assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all); assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string())); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await); + assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}"); let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await); assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new")); assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 38a1b4d7e0..48ce4a9332 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -143,10 +143,32 @@ pub fn command_specs() -> &'static [CommandSpec] { pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option) -> Value { let needs_document = s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p))); - json!({ + let mut entry = json!({ "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document, - }) + }); + if let (Some(k), Some(o)) = (search_keywords(c.id), entry.as_object_mut()) { + o.insert("keywords".into(), json!(k)); + } + entry +} + +/// Other names a command is looked for by, beyond its id and label: the Photoshop tools and +/// options it stands for (#5060). `select.rect` is also the Elliptical and Single Row/Column +/// Marquee, which an agent searches for as "ellipse" or "marquee". +pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] = + &[("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")")]; + +/// [`SEARCH_KEYWORDS`] of command `id`, if it has any. +pub fn search_keywords(id: &str) -> Option<&'static str> { + SEARCH_KEYWORDS.iter().find(|(k, _)| *k == id).map(|(_, words)| *words) +} + +/// Does a [`list_entry`] row match a command-list filter (`commands --filter`, `engine.commands`, +/// MCP `command_list`)? Its id, label or keywords contain `needle`, ignoring case. +pub fn list_entry_matches(entry: &Value, needle: &str) -> bool { + let needle = needle.to_lowercase(); + ["id", "label", "keywords"].iter().any(|k| entry.get(*k).and_then(Value::as_str).is_some_and(|v| v.to_lowercase().contains(&needle))) } pub fn find(id: &str) -> Option<&'static CommandSpec> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 83458f05a6..ee7dd3435b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -767,6 +767,38 @@ fn command_list_reports_enablement() { assert_eq!(find("layer.new.layer")["enabled"], false); } +/// The Elliptical and Single Row/Column Marquee are `select.rect` options: a command-list filter +/// for "ellipse" or "marquee" finds it through its keywords (#5060). +#[test] +fn command_list_filter_finds_the_marquees_by_keyword() { + let mut s = Session::new(); + let list = s.execute("command.list", json!({})).unwrap(); + let rows = list.as_array().unwrap(); + let found = |needle: &str| -> Vec { + rows.iter().filter(|c| crate::commands::list_entry_matches(c, needle)).map(|c| c["id"].as_str().unwrap().to_string()).collect() + }; + for needle in ["ellipse", "Elliptical Marquee", "marquee", "single row", "SELECT.RECT"] { + assert!(found(needle).contains(&"select.rect".to_string()), "{needle}: {:?}", found(needle)); + } + let rect = rows.iter().find(|c| c["id"] == "select.rect").unwrap(); + assert!(rect["keywords"].as_str().is_some_and(|k| k.contains("Elliptical Marquee")), "{rect}"); + // Commands without keywords don't get an empty field, and the filter still reads id and label. + assert!(rows.iter().find(|c| c["id"] == "file.new").unwrap().get("keywords").is_none()); + assert_eq!(found("gaussian blur"), ["filter.blur.gaussianBlur"]); + assert!(found("no command is called this").is_empty()); +} + +/// Every keyword entry names a real command, so a renamed id can't leave one behind. +#[test] +fn search_keywords_name_real_commands() { + for (id, words) in crate::commands::SEARCH_KEYWORDS { + assert!(crate::commands::find(id).is_some(), "keywords for unknown command {id}"); + assert!(!words.trim().is_empty(), "{id}"); + } + assert!(crate::commands::search_keywords("select.rect").is_some()); + assert!(crate::commands::search_keywords("no.such.command").is_none()); +} + /// With no document open, a document command says so instead of only `enabled: false` (#3664). #[test] fn command_list_tells_no_document_from_other_preconditions() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 771fec92e9..4fa1c60c6e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -196,9 +196,10 @@ Each element describes one command, in registry order: | `enabled` | boolean | Whether the command can run in the current state | | `disabledReason` | string or `null` | Why the command can't run now (`null` when `enabled`) | | `needsDocument` | boolean | `true` when it is off because no document is open (#3664) | +| `keywords` | string, optional | Other names the command is found by (#5060), e.g. `select.rect`'s Elliptical Marquee; only present on commands that have them | Filtering narrows the same array without changing its elements: `serve`'s `engine.commands -{filter}` and the MCP tool's `filter` keep commands whose id or label contains the text +{filter}` and the MCP tool's `filter` keep commands whose id, label or keywords contain the text (case-insensitive), and the MCP tool's `enabled_only` keeps those with `enabled: true`. The desktop control channel's `engine.commands` takes no filter and always returns every command. The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). From 887dcd7ef94e7c91040e39f487433163a6d4b6b4 Mon Sep 17 00:00:00 2001 From: Roberto Dutra <139057693+dutraroberto@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:45:30 -0300 Subject: [PATCH 010/199] Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284) (#5295) * Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284) On Wayland the app opens its window through Xwayland when a pen is attached (#639), and it took any input device whose key bitmap has BTN_TOOL_PEN for a pen. A virtual keyboard made with uinput (RustDesk's, for one) claims every key code, that one included, so the window went through Xwayland on machines with no pen at all. On a fractionally scaled desktop (GNOME 46 at 125%) the X11 window is drawn at the logical size and upscaled by the compositor, so the UI came out blurred even though Auto UI Scale already followed the system's 1.25 (#1237): native Wayland, where winit negotiates wp_fractional_scale_v1 and renders at the physical 3840 px, was never used. A pen now also needs ABS_X and ABS_Y in its absolute-axis bitmap, as libinput asks of a tablet tool; a keyboard has no axes. Regression tests with the virtual keyboard's own bitmaps, a pen beside it, and a pen display. Co-Authored-By: Claude Fable 5.1 * Docs: on a fractionally scaled Wayland desktop, Xwayland is blurred and native Wayland has no file drops (#5284) Say where the trade-off is: a native Wayland window is sharp at 125%, 150% and 175% but takes no dropped files (#386); an Xwayland window takes drops and pen input (#639) but is enlarged by the compositor. List the ways to open through Xwayland and what counts as a pen. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 --- README.md | 2 +- apps/photocraft/src/tablet.rs | 73 ++++++++++++++++++++++++++++++++--- docs/development.md | 2 +- docs/ui-design.md | 9 +++++ 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b272705e40..d1e839323c 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. -On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. +On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). On macOS, the command-line tool comes as `photocraft-cli--macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself: diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index 75c1388f94..fc063d2fbc 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option< #[cfg(any(target_os = "linux", test))] const BTN_TOOL_PEN: u32 = 0x140; -/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line -/// is its key bitmap as hex words of the kernel's `long`, most significant first. +/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports. +#[cfg(any(target_os = "linux", test))] +const ABS_X: u32 = 0x00; +#[cfg(any(target_os = "linux", test))] +const ABS_Y: u32 = 0x01; + +/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose +/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y` +/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard +/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but +/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a +/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639). #[cfg(any(target_os = "linux", test))] pub fn has_pen(devices: &str) -> bool { - let bits = usize::BITS; - let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX); - devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| { - keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1) + devices.split("\n\n").any(|device| { + let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name)); + bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y)) }) } +/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most +/// significant first. Short or malformed bitmaps have no bits set. +#[cfg(any(target_os = "linux", test))] +fn bit_set(bitmap: &str, bit: u32) -> bool { + let bits = usize::BITS; + let word = usize::try_from(bit / bits).unwrap_or(usize::MAX); + bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1) +} + /// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland, /// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another /// X app would outlive a mouse stroke here), so nothing starts. @@ -238,6 +256,49 @@ mod tests { for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] { assert!(!has_pen(keys), "{keys:?}"); } + for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] { + assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}"); + } + } + + /// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has + /// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window + /// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are + /// the device's own, from `/proc/bus/input/devices`. + const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\ + N: Name=\"RustDesk UInput Keyboard\"\n\ + P: Phys=\n\ + S: Sysfs=/devices/virtual/input/input54\n\ + U: Uniq=\n\ + H: Handlers=sysrq rfkill kbd event256 leds \n\ + B: PROP=0\n\ + B: EV=20013\n\ + B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\ + B: MSC=10\n\ + B: LED=7\n"; + + #[test] + fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() { + assert!( + bit_set( + "ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe", + BTN_TOOL_PEN + ), + "the fixture claims the key" + ); + assert!(!has_pen(VIRTUAL_KEYBOARD)); + // Beside a real pen, the pen is still found; the keyboard's key bits never leak into the + // pen's device and the pen's axes never into the keyboard's. + let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}"); + assert!(has_pen(&both)); + let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::>().join("\n"); + assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions"); + // A pen display or pen-enabled touchscreen reports the pen with its own axes. + let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"; + assert!(has_pen(pen_display)); + // Axes without `ABS_X` or `ABS_Y` don't count. + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n")); + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n")); } #[test] diff --git a/docs/development.md b/docs/development.md index 3c539f70bc..f685f653cc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -111,7 +111,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root. Headless CLI modes require the `--automation-read-root` flag | | `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | -| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639) | +| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | diff --git a/docs/ui-design.md b/docs/ui-design.md index 13fd8b99f1..96a389d7f7 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -173,6 +173,15 @@ monitors. A 4K display the system runs at 100% stays at 100% (#1278). The fixed 300%) set an absolute UI scale, for example 200% on an unscaled 4K display. Canvas zoom shortcuts continue to control the document independently of UI scaling. +On a Wayland session with a fractional display scale, the interface is sharp only in a native +Wayland window, where the window is drawn at the display's physical resolution +(`wp_fractional_scale_v1`). Through Xwayland the compositor enlarges a lower-resolution window, so +everything is blurred and looks larger than the chosen scale (#1072). The window opens through +Xwayland when a pen is attached (#639), with Preferences › Performance › Linux display server = +X11, or with `WAYLAND_DISPLAY=` cleared; those are also the ways to get file drag and drop, which +native Wayland lacks (#386). Sharp text or drag and drop: on such a desktop it is one or the other +until winit supports Wayland drops. + Interface → UI Font Size changes interface text independently of UI Scale and canvas zoom. Tiny, Small (the default), Medium and Large use 10/12, 1, 14/12 and 16/12 of the theme's original font sizes, preserving the relative sizes of headings, captions and numeric fields. Apply or OK From 7b8e5034d04b4171da5ea0aa9bb0fe3e397099f3 Mon Sep 17 00:00:00 2001 From: shmuel biller Date: Wed, 14 Oct 2026 12:45:36 -0400 Subject: [PATCH 011/199] Fix/scale marker rollback layer state 4975 (#5391) * fix(engine): restore document targeting on failed compound edits * test(engine): prove failed scale-marker rollback preserves layer targets * fix(engine): resync tool mask target after restoring channel view * test(engine): cover channel and tool-mask rollback after failed compound --- crates/engine/src/analysis_cmds.rs | 22 ++++++++- crates/engine/src/analysis_cmds/tests.rs | 63 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 64af849e59..6ad25951db 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -177,6 +177,12 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> { pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target()); + // An inner command may select a layer that disappears when the compound rolls back. + // Selection, channel targeting and coalescing must roll back with the document (#4975). + let anchor = st.layer_anchor; + let channel_view = st.channel_view.clone(); + let coalesce = st.coalesce.clone(); + let was_clean = !st.is_dirty(); let journal = s.journal.len(); // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so // a failed second step can't leave them pinned to pixels the restored document no longer has. @@ -187,7 +193,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } let st = s.active_mut().ok_or(EngineError::NoDocument)?; st.history = history; - match r { + let result = match r { Ok(v) => { // As in `Session::edit`: undo targets what was selected just before the step (#1356). st.history.set_current_layers(prior); @@ -198,12 +204,26 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } Err(e) => { st.doc = before; + st.active_layer = prior.active; + st.selected_layers = prior.selected; + st.layer_anchor = anchor; + st.channel_view = channel_view; + st.coalesce = coalesce; st.color_samplers = samplers; st.revision += 1; + // A failed compound does not make an otherwise untouched document dirty. + if was_clean { + st.saved_revision = st.revision; + } st.last_damage = None; Err(e) } + }; + if result.is_err() { + // The app-wide tool colours/mask mode follow the restored channel target. + s.sync_tools_mask_target(); } + result } /// Current time as ISO 8601 UTC (`2026-10-01T12:34:56Z`); empty on the web (no clock). diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index e5272d558d..124f9abf1e 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -11,6 +11,69 @@ fn d(s: &Session) -> Arc { s.active().unwrap().doc.clone() } +#[test] +fn failed_compound_restores_layer_target_history_and_journal() { + let mut s = session(80, 60, 8); + let background = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Selected"})).unwrap(); + let selected = s.active().unwrap().active_layer.unwrap(); + { + let st = s.active_mut().unwrap(); + st.selected_layers = vec![background, selected]; + st.layer_anchor = Some(background); + st.coalesce = Some("previous-gesture".into()); + } + + let st = s.active().unwrap(); + let prior_doc = st.doc.clone(); + let prior_history = st.history.past_len(); + let prior_channel_view = st.channel_view.clone(); + let prior_mask_targeted = s.tools.mask_targeted; + let prior_journal = s.journal.len(); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + // Mirrors a scale-bar creation followed by a failed type.create: the + // inner command selects a new layer, which rollback must remove. + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask; + s.sync_tools_mask_target(); + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + + assert!(error.to_string().contains("synthetic second step failed")); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&prior_doc, &st.doc), "the original document is restored"); + assert_eq!(st.history.past_len(), prior_history, "no undo step remains"); + assert_eq!(s.journal.len(), prior_journal, "no rolled-back command is journaled"); + assert_eq!(st.active_layer, Some(selected)); + assert_eq!(st.selected_layers, vec![background, selected]); + assert_eq!(st.layer_anchor, Some(background)); + assert_eq!(st.channel_view, prior_channel_view); + assert_eq!(s.tools.mask_targeted, prior_mask_targeted, "tool mask target follows the restored channel"); + assert_eq!(st.coalesce.as_deref(), Some("previous-gesture")); + assert!(st.doc.layer(selected).is_some()); + + // Subsequent ordinary commands target the original layer, not the + // nonexistent scale bar. + s.execute("layer.setProps", json!({"layer": selected.0, "visible": false})).unwrap(); + assert!(!s.active().unwrap().doc.layer(selected).unwrap().visible); +} + +#[test] +fn failed_compound_preserves_a_clean_document() { + let mut s = session(80, 60, 8); + assert!(!s.active().unwrap().is_dirty()); + let prior_doc = d(&s); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + assert!(error.to_string().contains("synthetic second step failed")); + assert!(Arc::ptr_eq(&prior_doc, &d(&s))); + assert!(!s.active().unwrap().is_dirty(), "a rolled-back edit does not need saving"); +} + #[test] fn record_measurements_refuses_an_area_too_large_to_measure() { // #933: with no selection the whole document is measured, and `width * height` overflowed From 6b9e173477432430a09a5bc4ec7c76fab45f8e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A2=E6=96=87=E5=BD=AC?= <156820648+lu123bing@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:45:41 +0800 Subject: [PATCH 012/199] affinity: decoded layers are admitted against an aggregate pixel budget (#4960) (#5266) The archive limits bound what one import extracts (256 MiB an entry, 1 GiB in total), and one pixel layer is skipped past 64 megapixels, but nothing bounded what the layers materialized out of that cost: each bitmap's declared `BmpW`/`BmpH` sizes its RGBA output and its decode planes, with or without tile data, so a small document with many layers could still allocate without end. `Limits` gains `max_pixel_bytes` (1 GiB by default, the budget the other importers allow decoded pixels). `Reader` admits a layer before `decode` allocates it: the layer's RGBA joins the layers already materialized, and its working planes are counted with them, since every earlier layer is still live while this one decodes. Past the budget the import fails with `Error::Limit`, which `photocraft-io` reports and falls back from to the embedded preview. Tests: a synthetic 300-byte document declaring layers with no tiles at all - one layer inside the budget imports, the second past it is rejected before allocation, and a declared 2048x2048 layer is rejected under a 1 MiB budget. The fuzz targets get a small pixel budget so mutations reach the new rejection path. Co-authored-by: DeepSeek --- crates/affinity/README.md | 6 +- crates/affinity/fuzz/fuzz_targets/preview.rs | 2 +- crates/affinity/fuzz/fuzz_targets/stream.rs | 2 +- crates/affinity/src/container.rs | 7 +- crates/affinity/src/model.rs | 22 +++++- crates/affinity/src/raster.rs | 5 ++ crates/affinity/src/tests.rs | 83 +++++++++++++++++++- 7 files changed, 117 insertions(+), 10 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 4f0f9afe52..3e4e066156 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -132,8 +132,10 @@ test fixtures, not an exporter. ## Safety limits -Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by -default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the +Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import +and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels +are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB +zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve diff --git a/crates/affinity/fuzz/fuzz_targets/preview.rs b/crates/affinity/fuzz/fuzz_targets/preview.rs index 31e2bf973a..63858fe717 100644 --- a/crates/affinity/fuzz/fuzz_targets/preview.rs +++ b/crates/affinity/fuzz/fuzz_targets/preview.rs @@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = photocraft_affinity::container::header(data); let _ = photocraft_affinity::preview(data); - let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/fuzz/fuzz_targets/stream.rs b/crates/affinity/fuzz/fuzz_targets/stream.rs index fd6128a66a..1f3895279f 100644 --- a/crates/affinity/fuzz/fuzz_targets/stream.rs +++ b/crates/affinity/fuzz/fuzz_targets/stream.rs @@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|stream: &[u8]| { let _ = photocraft_affinity::stream::parse(stream); let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None); - let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index b1a60ed430..880b7236d1 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -26,11 +26,16 @@ pub struct Limits { pub max_entry: usize, /// Total bytes extracted over the whole import. pub max_total: usize, + /// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the + /// temporary planes the layer being read decodes through. The entry limits bound what is + /// extracted, not what the layers materialized out of it cost (#4960). + pub max_pixel_bytes: usize, } impl Default for Limits { fn default() -> Self { - Self { max_entry: 256 << 20, max_total: 1 << 30 } + // The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`). + Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 } } } diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 07d0adf788..7fba5a4597 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result { let mut archive = Archive::open(bytes, limits)?; let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; - Reader::new(&s, &mut archive).document() + Reader::new(&s, &mut archive, limits).document() } pub(crate) struct Reader<'s, 'a, 'b> { @@ -298,11 +298,27 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, active: HashSet, + /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so + /// far hold). + pixels_left: usize, } impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() } + fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self { + Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes } + } + + /// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's + /// output, which stays with the document; `planes` is the working set it decodes through and + /// releases. Every layer read so far is still live while this one decodes, so the peak is all + /// three together. + pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> { + let peak = rgba.saturating_add(planes); + if peak > self.pixels_left { + return Err(Error::Limit("decoded pixel layers exceed the pixel budget")); + } + self.pixels_left -= rgba; + Ok(()) } pub(crate) fn warn(&mut self, what: impl Into) { diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 8cdf9b833a..bb1ecc112f 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi return Ok(None); } }; + // A layer is materialized from its declared size, whatever the tiles hold: admit the output + // and the planes it decodes through before either is allocated (#4960). + let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?; + let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?; + r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?; let mut planes = Vec::with_capacity(channels); let mut cache = HashMap::new(); for c in 1..=channels { diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index b28e30f3d6..59ae99044a 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -251,9 +251,9 @@ mod archive { broken[90] ^= 0x55; let mut a = Archive::open(&broken, Limits::default()).unwrap(); assert!(a.read("doc.dat").is_err()); - let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap(); a.read("doc.dat").unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } @@ -459,3 +459,82 @@ mod shapes { assert_eq!(sub.segments[3][2], p(5.0, 20.0)); } } + +/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive +/// extracts; these cover what the decoded layers and their working planes cost, which is declared +/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all. +mod pixels { + use crate::Error; + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::synth::{self, F, Method, tag}; + + /// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles. + fn layer(id: u32, w: i32, h: i32) -> F { + F::Def( + id, + vec![tag(b"Rstr")], + vec![ + (tag(b"Desc"), F::Str(format!("Layer {id}"))), + (tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])), + ], + ) + } + + /// A document whose one page holds a pixel layer per `sizes` entry. + fn document(sizes: &[(i32, i32)]) -> Vec { + let children: Vec = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect(); + let data = synth::stream(&[( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))], + )]), + )], + ), + )]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) + } + + fn imported_layers(d: &Document) -> usize { + d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count() + } + + /// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two. + const ONE_LAYER: usize = 40 << 10; + + #[test] + fn a_pixel_layer_within_the_budget_imports() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap(); + assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings); + } + + #[test] + fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + // The document is a few hundred bytes: the sizes are in the headers, not the entries. + let bytes = document(&[(64, 64), (64, 64)]); + assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len()); + assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized"); + // Two layers fit once the budget covers their pixels together. + let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() }; + assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2); + } + + #[test] + fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() { + // 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget. + let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() }; + assert!( + matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))), + "16 MiB of declared pixels materialized under a 1 MiB budget" + ); + } +} From b059a6d8b3149af61f8276ee0be6751511603903 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:45:46 -0400 Subject: [PATCH 013/199] ICC parser: checked arithmetic on offsets and counts from the file (#4976) (#5227) Offsets and counts stored in a profile reach u32::MAX, which is usize::MAX on 32-bit targets such as wasm32. Several places added to or multiplied them before any bounds check, so a malformed profile could panic (overflow checks on) or read from a wrapped offset (checks off). - curv: reject an over-long count before computing the element length. - mAB/mBA: the matrix and CLUT read through one bounds-checked slice at the stored offset instead of adding to the offset. - mluc: the record offset is computed with checked arithmetic. - XYZ: one bounds-checked slice instead of off + 4 and off + 8. Co-authored-by: Claude Opus 5.5 --- crates/cms/src/profile.rs | 184 ++++++++++++++++++++++++++++++-------- 1 file changed, 148 insertions(+), 36 deletions(-) diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 99f5710e5d..cdd7d5273a 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -429,10 +429,18 @@ impl<'a> Reader<'a> { Ok(self.u32(off)? as i32 as f64 / 65536.0) } fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> { - Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?]) + let r = Reader { b: self.slice(off, 12)? }; + Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?]) } } +/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not +/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32): +/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976). +fn extent(base: usize, n: usize, size: usize) -> Result { + n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated) +} + fn sig(s: &[u8; 4]) -> u32 { u32::from_be_bytes(*s) } @@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { match r.slice(0, 4)? { b"curv" => { let n = r.u32(8)? as usize; + // Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976). + if n > 1 << 20 { + return Err(CmsError::Invalid("curve too long".into())); + } let len = 12 + n * 2; match n { 0 => Ok((Curve::Identity, len)), 1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)), _ => { - if n > 1 << 20 { - return Err(CmsError::Invalid("curve too long".into())); - } let s = r.slice(12, n * 2)?; let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(); Ok((Curve::Table(t), len)) @@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { return Err(CmsError::Invalid("bad lutAB dimensions".into())); } let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize); - let matrix = |off: usize| -> Result { - let mut m = vec![0.0; 9]; - for (k, v) in m.iter_mut().enumerate() { - *v = r.s15f16(off + k * 4)?; - } - let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?]; - Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) - }; - let clut = |off: usize, cin: usize, cout: usize| -> Result { - let mut grid = Vec::with_capacity(cin); - for k in 0..cin { - let g = r.u8(off + k)? as usize; - if g < 1 { - return Err(CmsError::Invalid("zero grid points".into())); - } - grid.push(g); - } - let prec = r.u8(off + 16)? as usize; - let nodes: u128 = grid.iter().map(|g| *g as u128).product(); - if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { - return Err(CmsError::Invalid("CLUT too large".into())); - } - let total = nodes as usize * cout; - let data: Vec = match prec { - 1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(), - 2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), - }; - Ok(Stage::Clut(Clut::new(grid, cout, data))) - }; + let matrix = |off: usize| lut_ab_matrix(&r, off); + let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout); let mut stages = Vec::new(); if ty == b"mAB " { // A curves → CLUT → M curves → matrix → B curves. @@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { } } +/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_matrix(r: &Reader, off: usize) -> Result { + // One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`. + let e = Reader { b: r.slice(off, 48)? }; + let mut m = vec![0.0; 9]; + for (k, v) in m.iter_mut().enumerate() { + *v = e.s15f16(k * 4)?; + } + let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?]; + Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) +} + +/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result { + // Grid points (16 bytes), precision and padding; the samples follow. + let head = Reader { b: r.slice(off, 20)? }; + let mut grid = Vec::with_capacity(cin); + for k in 0..cin { + let g = head.u8(k)? as usize; + if g < 1 { + return Err(CmsError::Invalid("zero grid points".into())); + } + grid.push(g); + } + let prec = head.u8(16)? as usize; + let nodes: u128 = grid.iter().map(|g| *g as u128).product(); + if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { + return Err(CmsError::Invalid("CLUT too large".into())); + } + let total = nodes as usize * cout; + let samples = extent(off, 20, 1)?; + let data: Vec = match prec { + 1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(), + 2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), + }; + Ok(Stage::Clut(Clut::new(grid, cout, data))) +} + /// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`. fn parse_text(d: &[u8]) -> String { let r = Reader { b: d }; @@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String { let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() }; let mut best: Option<(usize, usize)> = None; for k in 0..(n as usize).min(256) { - let o = 16 + k * size as usize; - let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break }; + // The record size comes from the file: `k * size` can overflow a 32-bit `usize`. + let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break }; + let rec = Reader { b: rec }; + let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break }; if best.is_none() || lang == u16::from_be_bytes(*b"en") { best = Some((off as usize, len as usize)); if lang == u16::from_be_bytes(*b"en") { @@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn be(v: u32) -> [u8; 4] { + v.to_be_bytes() + } + + /// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on + /// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow. + #[test] + fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() { + let bytes = [0u8; 64]; + let r = Reader { b: &bytes }; + for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] { + assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}"); + assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}"); + assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}"); + } + assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated)); + assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated)); + assert_eq!(extent(16, 3, 12), Ok(52)); + } + + #[test] + fn a_curve_count_is_checked_before_its_length_is_computed() { + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into()))); + // Controls: the identity and a two-point table still parse with their lengths. + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(0)); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12)); + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend([0, 0, 0xFF, 0xFF]); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16)); + } + + #[test] + fn lut_ab_elements_at_hostile_offsets_are_rejected() { + // A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT + // offsets in turn point at the end of a 32-bit file. + let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| { + let mut d = b"mAB \0\0\0\0".to_vec(); + d.extend([3, 3, 0, 0]); + for v in [b, matrix, m, clut, a] { + d.extend(be(v)); + } + for _ in 0..3 { + d.extend(b"curv\0\0\0\0"); + d.extend(be(0)); + } + d + }; + assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses"); + let top = u32::MAX - 8; + assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset"); + assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset"); + assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset"); + } + + #[test] + fn multilingual_records_with_a_huge_record_size_give_the_first_record() { + let record = |lang: &[u8; 2], len: u32, off: u32| { + let mut r = lang.to_vec(); + r.extend(b"US"); + r.extend(be(len)); + r.extend(be(off)); + r + }; + // Two records of the standard 12 bytes: the English one wins. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend(be(12)); + d.extend(record(b"de", 4, 40)); + d.extend(record(b"en", 4, 44)); + d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']); + assert_eq!(parse_text(&d), "EN"); + // A record size of u32::MAX: the second record lies past any file, so the first is used. + d[12..16].copy_from_slice(&be(u32::MAX)); + assert_eq!(parse_text(&d), "DE"); + // No records at all. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + d.extend(be(u32::MAX)); + assert_eq!(parse_text(&d), ""); + } +} From a37fa8171b65e543a12295601974786a21e3bf05 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:45:52 -0400 Subject: [PATCH 014/199] GPU compositor: pattern textures are freed on close, when idle and over a budget (#4971) (#5232) The compositor kept every pattern texture it ever uploaded (RGBA32F, 16 bytes per pixel) until the device was lost: `forget_doc` and `retain_docs` skipped them and no budget applied, so a long session that opened and closed documents with pattern fills or pattern effects only ever grew. Each cached pattern now records the document that last painted with it and the frame it last painted in: - closing a document (`forget_doc`, `retain_docs`) frees its patterns; - a pattern no frame has painted with for 240 frames is dropped; - over PATTERN_BUDGET (256 MiB) the least recently painted go first, never one the current frame paints with. Co-authored-by: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 83 +++++++++++++++++++++++++++++++++++--- crates/gpu/tests/parity.rs | 65 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 223d4ed983..45e07500ba 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -75,6 +75,11 @@ pub const FX_BUDGET: usize = 1536 << 20; pub const PAGE: u32 = 2048; /// GPU memory resident layer pages may hold before the least recently used are evicted. pub const RESIDENT_BUDGET: u64 = 4 << 30; +/// GPU memory pattern textures may hold before the least recently painted are dropped (those the +/// current frame paints with always stay). +pub const PATTERN_BUDGET: u64 = 256 << 20; +/// A pattern texture no frame has painted with for this many frames is dropped. +pub const PATTERN_IDLE_FRAMES: u64 = 240; /// Uploads (bytes) [`Compositor::render`] stages before submitting the work recorded so far, so /// a refresh of a huge document never holds all its uploads in staging memory at once. const FLUSH_BYTES: u64 = 512 << 20; @@ -183,6 +188,12 @@ struct CachedPattern { format: PixelFormat, default_bits: Vec, texture: Tex, + /// The document that last painted with it: closing that document frees the texture (#4971). + doc: DocId, + /// [`Compositor::frame`] when it last painted. + last_used: u64, + /// GPU bytes of the texture (RGBA32F). + bytes: u64, } struct Resident { @@ -423,6 +434,10 @@ pub struct Compositor { /// The colour mode of the document being encoded: patterns paint in it /// (`photocraft_compose::pattern::pattern_rgba`). pattern_mode: photocraft_color::ColorMode, + /// The document being encoded, which owns the patterns it paints with. + pattern_doc: DocId, + /// GPU memory pattern textures may hold ([`PATTERN_BUDGET`] by default). + pattern_budget: u64, /// Texture side limit the paging is planned for (the device's, or smaller in tests). max_dim: u32, /// The device's real texture side limit. @@ -670,6 +685,8 @@ impl Compositor { temps: Vec::new(), patterns: HashMap::new(), pattern_mode: photocraft_color::ColorMode::Rgb, + pattern_doc: DocId(0), + pattern_budget: PATTERN_BUDGET, max_dim: 0, device_max: device.limits().max_texture_dimension_2d, page: PAGE, @@ -791,6 +808,17 @@ impl Compositor { self.residents.values().map(Resident::bytes).sum() } + /// GPU memory held by cached pattern textures (bytes). + pub fn pattern_cache_bytes(&self) -> u64 { + self.patterns.values().map(|p| p.bytes).sum() + } + + /// Set the GPU memory pattern textures may hold (default [`PATTERN_BUDGET`]). It takes effect + /// at the end of the next refresh. + pub fn set_pattern_budget(&mut self, bytes: u64) { + self.pattern_budget = bytes; + } + /// Whether `doc` can be composited on the GPU. Documents and layers larger than the texture /// limit are fine: surfaces are stored in page-sized textures and effect maps of regions /// larger than the limit are built per page cell. @@ -865,14 +893,22 @@ impl Compositor { pub fn forget_doc(&mut self, doc: DocId) { self.residents.retain(|_, r| r.doc != doc); self.fx.retain(|_, e| e.doc != doc); + self.patterns.retain(|_, p| p.doc != doc); self.prefix.forget(doc); } /// [`Self::forget_doc`] for every document `keep` rejects: what the compositor holds for /// documents that were closed, however their canvas entry went (#4529). pub fn retain_docs(&mut self, keep: impl Fn(DocId) -> bool) { - let mut gone: Vec = - self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).filter(|d| !keep(*d)).collect(); + let mut gone: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .filter(|d| !keep(*d)) + .collect(); gone.sort_by_key(|d| d.0); gone.dedup(); for d in gone { @@ -880,9 +916,17 @@ impl Compositor { } } - /// Documents the compositor holds pages, effect maps or a lower-stack composite for. + /// Documents the compositor holds pages, effect maps, pattern textures or a lower-stack + /// composite for. pub fn held_docs(&self) -> Vec { - let mut docs: Vec = self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).collect(); + let mut docs: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .collect(); docs.sort_by_key(|d| d.0); docs.dedup(); docs @@ -994,6 +1038,7 @@ impl Compositor { let plan = plan(doc)?; self.check_fx(doc, &plan)?; self.pattern_mode = doc.pixel_format().mode; + self.pattern_doc = doc.id; let mut stats = Stats { passes: plan.passes.len(), slots: plan.slots, ..Default::default() }; if region.is_empty() { return Ok(stats); @@ -1093,9 +1138,31 @@ impl Compositor { self.fx.retain(|(id, _), e| e.doc != doc.id || e.last_used == frame || live.contains(id)); stats.evicted += self.evict().0; self.temps.retain(|(_, used)| frame.saturating_sub(*used) < 240); + self.evict_patterns(); Ok(stats) } + /// Drop the pattern textures no frame has painted with for [`PATTERN_IDLE_FRAMES`] frames, + /// then the least recently painted ones over the pattern budget. Never one this frame painted + /// with: it would only be uploaded again on the next frame. + fn evict_patterns(&mut self) { + let frame = self.frame; + self.patterns.retain(|_, p| frame.saturating_sub(p.last_used) < PATTERN_IDLE_FRAMES); + let mut total = self.pattern_cache_bytes(); + if total > self.pattern_budget { + let mut lru: Vec<(u64, String, u64)> = + self.patterns.iter().filter(|(_, p)| p.last_used != frame).map(|(k, p)| (p.last_used, k.clone(), p.bytes)).collect(); + lru.sort_unstable(); + for (_, key, bytes) in lru { + if total <= self.pattern_budget { + break; + } + self.patterns.remove(&key); + total = total.saturating_sub(bytes); + } + } + } + /// Keep resident pages within the resident budget and the effect cache within /// its budget, least recently used first. Never drops what the current cell uses, nor /// whole-region effect maps drawn this frame. Returns how many textures were dropped and @@ -1579,11 +1646,14 @@ impl Compositor { let format = pat.surface.format(); // Unstored tiles read the default pixel, interpreted in the source format. let default_bits: Vec = pat.surface.default_pixel().iter().map(|v| v.to_bits()).collect(); - if let Some(cached) = self.patterns.get(&key) + let (doc, last_used) = (self.pattern_doc, self.frame); + if let Some(cached) = self.patterns.get_mut(&key) && cached.fingerprint == fp && cached.format == format && cached.default_bits == default_bits { + cached.doc = doc; + cached.last_used = last_used; return cached.texture.view.clone(); } let (w, h) = (pat.width, pat.height); @@ -1597,7 +1667,8 @@ impl Compositor { wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); let v = t.view.clone(); - self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t }); + let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16; + self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes }); v } diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index c61aee9b67..44836c233f 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -2135,6 +2135,71 @@ fn pattern_cache_tracks_default_pixels() { } } +/// A document that paints one `side` x `side` pattern named `name`. +fn pattern_fill_doc(name: &str, side: u32) -> Document { + let mut d = Document::new(name, Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + let pat = Pattern::new(name, photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]), side, side); + d.layers.push(Layer::new( + "pattern", + LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); + d.patterns.push(pat); + d +} + +/// #4971: a pattern texture belongs to the document that last painted with it, and closing that +/// document frees it. Before, pattern textures were kept until the device was lost. +#[test] +fn closed_documents_release_their_pattern_textures() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let one = 4 * 4 * 16; + assert_eq!(g.comp.pattern_cache_bytes(), 0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA32F texture"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), 2 * one); + g.comp.forget_doc(a.id); + assert_eq!(g.comp.pattern_cache_bytes(), one, "closing a document frees its pattern"); + assert_eq!(g.comp.held_docs(), vec![b.id]); + g.comp.retain_docs(|_| false); + assert_eq!(g.comp.pattern_cache_bytes(), 0); + check(&mut g, &a, "the pattern is uploaded again after being forgotten"); +} + +/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and +/// by age. What the current frame paints with is never dropped. +#[test] +fn pattern_textures_are_evicted_over_budget_and_when_idle() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let mut plain = base_doc(8, 6); + plain.id = photocraft_doc::DocId(a.id.0 + 2); + let one = 4 * 4 * 16; + // Room for one pattern and a half: painting the second drops the first. + g.comp.set_pattern_budget(one + one / 2); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "the least recently painted pattern went"); + // A budget below a single pattern still keeps the one being painted. + g.comp.set_pattern_budget(0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b again"); + assert_eq!(g.comp.pattern_cache_bytes(), one); + // Idle: frames that paint no pattern age it out. + g.comp.set_pattern_budget(photocraft_gpu::PATTERN_BUDGET); + for _ in 0..photocraft_gpu::PATTERN_IDLE_FRAMES - 1 { + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + } + assert_eq!(g.comp.pattern_cache_bytes(), one, "still within the idle limit"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + assert_eq!(g.comp.pattern_cache_bytes(), 0, "dropped after {} idle frames", photocraft_gpu::PATTERN_IDLE_FRAMES); + check(&mut g, &b, "the pattern is uploaded again after being evicted"); +} + #[test] fn pattern_cache_tracks_source_format_and_cow_tiles() { let Some(mut g) = gpu() else { return }; From d9eb46b4851796f51b663c3735a30d2a7538e78c Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 18:45:59 +0200 Subject: [PATCH 015/199] Performance: the lower-stack cache follows an edit up the stack (P9 / P11, #5286) (#5288) Repeating a duplicate or paste of a 12 MP layer cost as much as the first time because the GPU compositor's lower-stack cache stayed at the checkpoint of the first edit after it was filled: an edit higher up only checked that the layers below that checkpoint were unchanged, so every refresh re-ran all passes above it (in the #209 layered document, 87 of 110 passes over the 12 MP damage rect; the duplicate itself takes 0.3 ms, its tiles are already shared). Each cached chunk now remembers its own checkpoint. A render captures at the highest checkpoint below the edit, also where it resumed from a lower cached one, so the next refresh of that edit (its undo, redo or repetition) resumes just below it. Chunks a refresh doesn't reach keep their lower prefix; a chunk moved by a partial capture holds only that rectangle afterwards. Validity is unchanged: a chunk survives only while the layers below its checkpoint are untouched. Release perf_scenarios, layered group, Windows 11, RTX 5070 Ti, 7 interleaved runs: P9 p50 22-29 -> 12-17 ms, P11 p50 37-41 -> 11-18 ms; P8 and P10 fall too; P1-P7, P12 unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/gpu/src/lib.rs | 20 ++++++--- crates/gpu/src/prefix.rs | 83 +++++++++++++++++++++++++----------- crates/gpu/tests/parity.rs | 87 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 32 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 45e07500ba..de6ca3e56e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -24,7 +24,9 @@ //! staged uploads and evicted pages stay bounded. //! - **Stack reuse.** A complete root stack unit can checkpoint its backdrop into bounded chunk //! textures. Exact COW snapshots invalidate the lower prefix; edits above it restore those -//! pixels and execute only the remaining passes. Groups and clipping units stay indivisible. +//! pixels and execute only the remaining passes, capturing on the way the highest checkpoint +//! below the edit, so repeating it (a duplicate or paste and its undo) skips that much more. +//! Groups and clipping units stay indivisible. //! //! Vector masks (rasterised once per mask state into a combined mask texture), layers clipped to //! pass-through groups, stroked shapes with clipped layers (fill and stroke split once per shape @@ -1302,7 +1304,10 @@ impl Compositor { if chunks.is_empty() { return; } - let first_pass = checkpoint.filter(|_| chunks.iter().all(|c| self.prefix.contains(*c))).map_or(0, |c| c.pass_end); + // Chunks may hold different checkpoints: bind from the lowest one any of them resumes at. + let first_pass = checkpoint + .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) + .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); // Chunk pool. @@ -1372,7 +1377,7 @@ impl Compositor { for (ci, c) in chunks.iter().enumerate() { let mut resume = 0; - if let Some(cp) = checkpoint + if let Some(cp) = checkpoint.and_then(|_| self.prefix.restore_point(*c)) && let Some((dst, _)) = self.pool.get(cp.slot as usize) && self.prefix.copy_out(encoder, *c, dst) { @@ -1397,11 +1402,12 @@ impl Compositor { return; } } - if resume == 0 + // Also after resuming from a lower checkpoint: the chunk moves up to this one. + if resume < i && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let scissor = match p.clip { Some(clip) => { @@ -1467,11 +1473,11 @@ impl Compositor { pass.set_bind_group(1, bg1[i].as_ref(), &[]); pass.draw(0..3, 0..1); } - if resume == 0 + if resume < plan.passes.len() && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == plan.passes.len()) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let (t, v) = &self.pool[plan.root as usize]; sink(encoder, ChunkOut { rect: *c, texture: t, view: v }); diff --git a/crates/gpu/src/prefix.rs b/crates/gpu/src/prefix.rs index 0312a25fa2..34c70916e0 100644 --- a/crates/gpu/src/prefix.rs +++ b/crates/gpu/src/prefix.rs @@ -6,6 +6,13 @@ //! every frame, and caching those would fill the budget with entries that never hit again. //! A partial rectangle is served as a sub-rect copy of the whole chunk it falls into, and a //! capture merges into the chunk it overlaps, so every hit lands on the same stable entry. +//! +//! Each chunk remembers the checkpoint it holds. A render captures at the highest checkpoint +//! below its edit, also where it resumed from a lower cached one, so the cache follows the edit +//! up the stack chunk by chunk: repeating an edit high in the stack (a duplicate or paste and its +//! undo) then skips everything below it, and chunks a refresh doesn't reach keep serving their +//! own, lower prefix meanwhile (#5286). A chunk moved by a partial capture holds only that +//! rectangle afterwards: the rest of it misses once, like any fresh entry. use std::collections::HashMap; use photocraft_doc::{Document, Layer}; @@ -21,11 +28,17 @@ pub(crate) const MAX_BYTES: u64 = 512 << 20; pub(crate) struct Chunk { tex: Tex, covered: Vec, + /// The checkpoint the covered pixels hold. + checkpoint: Checkpoint, + bytes: u64, } pub(crate) struct Prefix { context: Option, + /// The root layers of the last prepared render, all of them: a chunk stays valid while every + /// render since its capture left the layers below its checkpoint as they were. layers: Vec, + /// Where renders capture: the highest useful checkpoint below the latest edit. checkpoint: Option, chunks: HashMap, bytes: u64, @@ -90,6 +103,7 @@ impl Prefix { self.context.as_ref().map(|d| d.id) } + /// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at. pub(crate) fn prepare(&mut self, doc: &Document, plan: &Plan<'_>) -> Option { if self.budget == 0 { return None; @@ -97,39 +111,51 @@ impl Prefix { // Keeping every non-layer field makes new document inputs invalidate automatically. // Tile data is Arc-shared; shared Tile equality does not scan its pixel bytes. let mut context = doc.clone(); - let mut layers = std::mem::take(&mut context.layers); + let layers = std::mem::take(&mut context.layers); let same_context = self.context.as_ref() == Some(&context); - let changed = self.layers.iter().zip(&layers).position(|(a, b)| a != b).or_else(|| (layers.len() < self.layers.len()).then_some(layers.len())); - let old = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); - let checkpoint = old.or_else(|| { - if same_context && let Some(changed) = changed { - plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied() - } else { + // The lowest root layer this render changes since the last one (a layer added or removed + // at the top changes from there on). + let changed = self + .layers + .iter() + .zip(&layers) + .position(|(a, b)| a != b) + .or_else(|| (layers.len() != self.layers.len()).then_some(layers.len().min(self.layers.len()))); + if same_context { + let mut freed = 0u64; + self.chunks.retain(|_, c| { + let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && plan.checkpoints.contains(&c.checkpoint); + if !keep { + freed = freed.saturating_add(c.bytes); + } + keep + }); + self.bytes = self.bytes.saturating_sub(freed); + } else { + self.clear(); + } + let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); + let checkpoint = kept.or_else(|| match changed { + // Just below the edit; nothing when it is at the bottom. + Some(changed) if same_context => plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(), + _ => { let mut useful = plan.checkpoints.iter().rev().filter(|c| c.pass_end > 1); let last = useful.next().copied(); useful.next().copied().or(last) } }); - if old.is_none() { - self.clear(); - } - let Some(checkpoint) = checkpoint else { - // Remember bottom-layer edits even when no useful prefix survives: otherwise - // every other edit would populate and immediately discard the default checkpoint. - self.context = Some(context); - self.layers = layers; - return None; - }; - layers.truncate(checkpoint.layer_end); + // Remember bottom-layer edits even when no useful prefix survives: otherwise every other + // edit would populate and immediately discard the default checkpoint. self.context = Some(context); self.layers = layers; - self.checkpoint = Some(checkpoint); - Some(checkpoint) + self.checkpoint = checkpoint; + checkpoint } - /// Whether every pixel of `rect` is already cached in its whole grid chunk. - pub(crate) fn contains(&self, rect: Rect) -> bool { - self.covered_by(rect).is_some() + /// The checkpoint the cached pixels of `rect` hold, when every one of them is cached in its + /// whole grid chunk. + pub(crate) fn restore_point(&self, rect: Rect) -> Option { + self.covered_by(rect).map(|(chunk, _)| chunk.checkpoint) } /// The whole chunk and the source offset that serve `rect`, when it is fully covered. @@ -160,7 +186,8 @@ impl Prefix { /// Merges the rendered `rect` (document coordinates) into its whole grid chunk, admitting /// the chunk while space remains. Replacing entries on every miss would make a full /// refresh larger than the cache evict its own next hits. - pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture) { + /// A chunk holding another (lower) checkpoint starts over with this capture. + pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture, checkpoint: Checkpoint) { if rect.is_empty() { return; } @@ -174,9 +201,15 @@ impl Prefix { let tex = Tex::new(device, "pc_compose_prefix", cell.width(), cell.height(), src.format(), wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST); self.bytes = self.bytes.saturating_add(cell_bytes); - self.chunks.insert(cell, Chunk { tex, covered: Vec::new() }); + self.chunks.insert(cell, Chunk { tex, covered: Vec::new(), checkpoint, bytes: cell_bytes }); } let Some(chunk) = self.chunks.get_mut(&cell) else { return }; + if chunk.checkpoint != checkpoint { + // The render restored this chunk's old pixels earlier in the same encoder, so + // overwriting them in place is ordered after that copy. + chunk.covered.clear(); + chunk.checkpoint = checkpoint; + } // A whole-chunk render replaces the coverage; a partial rectangle lands at its offset // inside the cell, beside whatever earlier strokes put there. if rect == cell { diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 44836c233f..de2a972008 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -259,6 +259,93 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() { } } +/// Duplicating a layer high in the stack, undoing and redoing it (#5286): the render resumes +/// from the lower cached prefix and moves the cache up to just below the copy, so the undo and +/// the redo skip more passes. Every render matches an uncached compositor bit for bit, at every +/// depth. +#[test] +fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::new(&g.device); + uncached.set_composite_cache_budget(0); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("follow", Size::new(300, 70), ColorMode::Rgb, depth); + for seed in 1..=6 { + doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2)); + } + let mut exact = |g: &mut Gpu, doc: &Document, what: &str| -> photocraft_gpu::Stats { + let stats = diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); + let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + assert_eq!(cached, cold, "{depth:?} {what}: reuse preserves the GPU accumulator exactly"); + stats + }; + exact(&mut g, &doc, "populate"); + // An edit low in the stack puts the cache there. + doc.layers[1].opacity = 0.6; + assert_eq!(exact(&mut g, &doc, "low edit").prefix_hits, 0); + let low = doc.clone(); + let copy = doc.layers[4].duplicate(); + doc.layers.insert(5, copy); + let first = exact(&mut g, &doc, "duplicate"); + assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix"); + let undo = exact(&mut g, &low, "undo"); + assert!(undo.prefix_hits > 0, "{depth:?}"); + assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}"); + let redo = exact(&mut g, &doc, "redo"); + assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?}"); + // An edit below the moved cache still invalidates it. + doc.layers[2].opacity = 0.4; + assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?}"); + } +} + +/// A small refresh above the cache moves only the chunk it renders, which then holds just that +/// rectangle; the other chunks keep serving their lower checkpoint, and a full render afterwards +/// resumes each chunk from its own (#5286). Every render matches an uncached compositor exactly. +#[test] +fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + uncached.set_texture_limit(PAGED_LIMIT); + uncached.set_composite_cache_budget(0); + let mut doc = base_doc(600, 300); + for seed in 2..=5 { + doc.layers.push(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2)); + } + let mut render = |g: &mut Gpu, doc: &Document, rect: Rect, what: &str| -> photocraft_gpu::Stats { + let (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, doc, rect).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, rect).unwrap(); + assert_eq!(cached, cold, "{what}: reuse preserves the GPU accumulator exactly"); + let cpu = photocraft_compose::render(doc, rect); + assert!(worst_diff(&cpu.px, &cached).0 <= TOL, "{what}: GPU and CPU differ"); + stats + }; + render(&mut g, &doc, doc.bounds(), "populate"); + doc.layers[1].opacity = 0.5; + let low = render(&mut g, &doc, doc.bounds(), "low edit"); + assert_eq!(low.prefix_hits, 0); + // A copy near the top, refreshed in one chunk only. + let copy = doc.layers[3].duplicate(); + doc.layers.insert(4, copy); + let small = render(&mut g, &doc, Rect::new(10, 10, 60, 60), "partial duplicate refresh"); + assert_eq!(small.prefix_hits, 1, "{small:?}"); + let full = render(&mut g, &doc, doc.bounds(), "full refresh after the partial one"); + assert_eq!(full.prefix_hits, full.chunks - 1, "only the partly moved chunk misses: {full:?}"); + let again = render(&mut g, &doc, doc.bounds(), "full refresh, all moved"); + assert_eq!(again.prefix_hits, again.chunks); + assert!(again.prefix_passes_skipped > full.prefix_passes_skipped, "{full:?} {again:?}"); + // Mixed checkpoints: a bottom-half refresh moves the chunks it covers whole further up + // (an edit at the top), the top half keeps the previous checkpoint and still hits. + doc.layers.last_mut().unwrap().opacity = 0.7; + let bottom = render(&mut g, &doc, Rect::new(0, 256, 600, 300), "bottom strip"); + assert_eq!(bottom.prefix_hits, bottom.chunks); + let moved = render(&mut g, &doc, Rect::new(0, 0, 512, 256), "whole top chunks"); + assert_eq!(moved.prefix_hits, moved.chunks); + let mixed = render(&mut g, &doc, doc.bounds(), "full refresh, mixed checkpoints"); + assert_eq!(mixed.prefix_hits, mixed.chunks, "{mixed:?}"); +} + #[test] fn root_checkpoints_keep_clipping_groups_and_adjustments_whole() { let Some(mut g) = gpu() else { return }; From 8d2806f66c4f7b86be3a7b8ec57519f5c8f41a99 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 00:46:04 +0800 Subject: [PATCH 016/199] Expose shared Smart Object instances in inspection and replacement results (#4832) * Smart Objects: inspect and Replace Contents show shared instances (#4075) Placed layers that share a source have imported as one set of contents since #2222, and Replace Contents changes every instance, but nothing reported it: `linkGroup` is Link Layers, and `smartSource` can't tell copies of one contents from independent copies of the same file. document.inspect now gives each smart object `smartContents`: the contents `id` its copies share and how many `instances` there are. Replace Contents, Relink to File and Convert to Embedded/Linked list the `instances` they changed. Co-Authored-By: Claude Opus 5.5 * Test rendered caches after shared Smart Object replacement --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/inspect.rs | 35 ++++++-- crates/engine/src/smart_cmds.rs | 11 ++- crates/engine/tests/smart_duplicates.rs | 103 +++++++++++++++++++++++- docs/development.md | 4 +- 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 22c3d3a96f..6068503aec 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -1,6 +1,8 @@ //! JSON views of engine state for automation, tests and debugging. -use photocraft_doc::{Layer, LayerContent, SmartSource}; +use std::collections::HashMap; + +use photocraft_doc::{Layer, LayerContent, SmartContentsId, SmartSource}; use serde_json::{Value, json}; use crate::{DocState, Session}; @@ -25,6 +27,7 @@ pub fn session(s: &Session) -> Value { pub fn document(d: &DocState) -> Value { let doc = &d.doc; let selected = d.selected_layers(); + let instances = smart_instances(&doc.layers); json!({ "name": doc.name, "path": &d.path, @@ -38,7 +41,7 @@ pub fn document(d: &DocState) -> Value { "isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::>(), "hasSelection": doc.selection.is_some(), "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), - "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::>(), + "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata))).collect::>(), "history": d.history.entries(), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), @@ -61,14 +64,27 @@ pub fn document(d: &DocState) -> Value { }) } -/// Layer tree top-to-bottom (display order). +/// Layer tree top-to-bottom (display order). Smart object instances are counted within `l`. pub fn layer(l: &Layer) -> Value { - layer_sel(l, &[], None) + layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None) } -/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection); with the -/// document's `meta`, a smart object whose file is embedded in the PSD says so. -fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&photocraft_doc::Metadata>) -> Value { +/// How many smart objects in `layers`, groups included, share each contents identity. +fn smart_instances(layers: &[Layer]) -> HashMap { + let mut counts = HashMap::new(); + let mut pending: Vec<&Layer> = layers.iter().collect(); + while let Some(l) = pending.pop() { + match &l.content { + LayerContent::Smart(sm) => *counts.entry(sm.contents_id).or_default() += 1, + LayerContent::Group(g) => pending.extend(&g.children), + _ => {} + } + } + counts +} + +/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection). +fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMap, meta: Option<&photocraft_doc::Metadata>) -> Value { let bounds = l.surface().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] @@ -92,7 +108,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot }); match &l.content { LayerContent::Group(g) => { - v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, meta)).collect()); + v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, instances, meta)).collect()); v["expanded"] = json!(g.expanded); if let Some(a) = &g.artboard { v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset}); @@ -122,6 +138,9 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot None => json!({"kind": "linked", "path": path}), }, }; + // Copies of the same contents change together (Edit and Replace Contents), and + // `smartSource` can't tell them from independent copies of one file (#4075). + v["smartContents"] = json!({"id": sm.contents_id.0, "instances": instances.get(&sm.contents_id).copied().unwrap_or(1)}); } LayerContent::Text(t) => { v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt}); diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 295b845df0..649a154419 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -827,21 +827,24 @@ fn set_source(s: &mut Session, p: &Value, label: &str, keep_psd: bool, make: imp // Keep the shared-instance transaction out of the per-command source-builder instantiations. fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, new: SmartSource) -> Result { - let (parent, contents_id) = s.edit(label, |doc, _| { + let (parent, contents_id, changed) = s.edit(label, |doc, _| { let contents_id = smart(doc, id)?.contents_id; - for instance in instances(doc, contents_id) { + let changed = instances(doc, contents_id); + for &instance in &changed { smart_mut(doc, instance)?.source = new.clone(); if !keep_psd { detach_psd(doc.layer_mut(instance).ok_or(EngineError::NoLayer(instance))?); } refresh_or_fail(doc, instance)?; } - Ok((doc.id, contents_id)) + Ok((doc.id, contents_id, changed)) })?; // An old editor must not overwrite newly replaced/relinked contents on close. Keep its // document open, but the next Edit Contents opens the replacement source. s.smart_links.retain(|l| l.parent != parent || l.contents_id != contents_id); - Ok(json!({"layer": id.0})) + // Every instance changed, not only `layer`: list them, so a caller doesn't repeat the + // command once per copy (#4075). + Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>()})) } fn replace_contents(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/tests/smart_duplicates.rs b/crates/engine/tests/smart_duplicates.rs index 12f5ec5486..554fd23f0f 100644 --- a/crates/engine/tests/smart_duplicates.rs +++ b/crates/engine/tests/smart_duplicates.rs @@ -4,7 +4,7 @@ use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject}; use photocraft_engine::Session; use photocraft_engine::smart_cmds::{decode_source, source_bytes}; use photocraft_geom::Rect; -use serde_json::json; +use serde_json::{Value, json}; fn session(depth: u64) -> (Session, u64, u64) { let mut s = Session::new(); @@ -195,3 +195,104 @@ fn replacing_shared_contents_does_not_reuse_or_commit_a_stale_editor() { s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap(); assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [0.0, 1.0, 0.0, 1.0]); } + +/// A `.pcraft` file whose contents are one solid colour, for Replace Contents. +fn contents_file(rgba: [f32; 4], tag: &str) -> std::path::PathBuf { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, rgba); + let path = std::env::temp_dir().join(format!("photocraft-{tag}-{}.pcraft", std::process::id())); + std::fs::write(&path, photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()).unwrap(); + path +} + +/// The node named `name` in `document.inspect`'s layer tree, searched through groups. +fn inspected<'a>(layers: &'a Value, name: &str) -> Option<&'a Value> { + layers.as_array()?.iter().find_map(|l| if l["name"] == name { Some(l) } else { inspected(&l["children"], name) }) +} + +/// Placed layers sharing a source (a PSD's copies of one smart object) share their contents, so +/// one Replace Contents updates every copy. Nothing showed it: `linkGroup` is Link Layers, which +/// stays unset (#4075). Inspect gives each smart object its contents identity and instance count, +/// and Replace Contents lists every layer it changed. +#[test] +fn inspect_and_replace_contents_report_shared_instances() { + let (mut s, original, duplicate) = session(8); + let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap(); + s.edit("name and group", |doc, _| { + for (id, name) in [(original, "original"), (duplicate, "duplicate"), (independent, "independent")] { + doc.layer_mut(LayerId(id)).unwrap().name = name.into(); + } + // An instance inside a group counts too. + let copy = doc.remove(LayerId(duplicate)).unwrap(); + doc.layers.push(Layer::group("Copies", vec![copy])); + Ok(()) + }) + .unwrap(); + let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap(); + let mut s = Session::new(); + s.add_document(photocraft_io::import("copies.psd", &out.bytes).unwrap().document, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let [o, d, i] = ["original", "duplicate", "independent"].map(|name| inspected(&tree["layers"], name).unwrap().clone()); + assert_eq!(o["smartContents"]["id"], d["smartContents"]["id"]); + assert_ne!(o["smartContents"]["id"], i["smartContents"]["id"]); + assert_eq!([&o, &d, &i].map(|l| l["smartContents"]["instances"].as_u64()), [Some(2), Some(2), Some(1)]); + assert!([&o, &d, &i].iter().all(|l| l["linkGroup"].is_null()), "shared contents don't link the layers"); + + let [o, d, i] = [&o, &d, &i].map(|l| l["id"].as_u64().unwrap()); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "replace-instances"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": d, "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + assert_eq!(changed, if o < d { [o, d] } else { [d, o] }); + assert_eq!([o, d, i].map(|id| contents_pixel(&s, id)), [[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]); + for (id, expected) in [(o, [0.0, 1.0, 0.0, 1.0]), (d, [0.0, 1.0, 0.0, 1.0]), (i, [0.0, 0.0, 1.0, 1.0])] { + assert_eq!(smart(&s, id).cache.as_ref().unwrap().rgba(4, 3), expected, "instance {id} must display its current contents"); + } +} + +/// The same with the placed layer Photoshop wrote, twice: Photoshop's copies of a smart object +/// each carry a `SoLd` naming the one embedded file (`Idnt`), so they import as one contents. +#[cfg(feature = "corpus")] +#[test] +fn photoshop_placed_copies_import_as_shared_instances() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-none-plain.psd"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display())); + let mut file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let placed = file.layers().iter().find(|r| r.block(b"SoLd").is_some()).expect("a placed layer").clone(); + file.layers_mut().push(placed); + let mut s = Session::new(); + s.add_document(photocraft_io::psd_to_document(&file).0, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let copies: Vec<&Value> = tree["layers"].as_array().unwrap().iter().filter(|l| l["kind"] == "Smart Object").collect(); + assert_eq!(copies.len(), 2); + assert_eq!(copies[0]["smartContents"], copies[1]["smartContents"]); + assert_eq!(copies[0]["smartContents"]["instances"], 2); + for copy in &copies { + assert_eq!(copy["smartSource"]["kind"], "embedded"); + assert!(copy["smartSource"]["uuid"].as_str().is_some()); + } + let mut ids: Vec = copies.iter().map(|l| l["id"].as_u64().unwrap()).collect(); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "photoshop-copies"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": ids[0], "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + ids.sort_unstable(); + assert_eq!(changed, ids); + for id in ids { + assert_eq!(contents_pixel(&s, id), [0.0, 1.0, 0.0, 1.0]); + let cache = smart(&s, id).cache.as_ref().unwrap(); + let bounds = cache.content_bounds(); + assert!(!bounds.is_empty(), "instance {id} must have rendered pixels"); + assert_eq!( + cache.rgba(bounds.x0 + bounds.width() as i32 / 2, bounds.y0 + bounds.height() as i32 / 2), + [0.0, 1.0, 0.0, 1.0], + "instance {id} must refresh its imported cache" + ); + } +} diff --git a/docs/development.md b/docs/development.md index f685f653cc..8bfc53df81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -243,7 +243,9 @@ claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp \ `doc_inspect` (and the engine command `document.inspect`) reports the layer tree with kinds, bounds, masks, selection, effects (`effects.items[].kind`), smart filters (`smartFilters[]`), smart object sources (`smartSource`: `embedded` with its file name, or `linked` with a file path or, for a -PSD placed layer, the `Idnt` uuid its duplicates share), type +PSD placed layer, the `Idnt` uuid its duplicates share), shared contents (`smartContents`: an `id` +every copy of the same contents has, and the number of `instances`; Replace Contents changes them +all and lists them in its result), type text, adjustment settings, channels and history, so agents can verify what they did without a screenshot. `crates/automation/tests/agent_tasks.rs` is the reference: ten realistic edit tasks (title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align, From 45f8921ae84e0583b69460d4c62b7c1f530a05fd Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Wed, 14 Oct 2026 18:46:11 +0200 Subject: [PATCH 017/199] Free Transform: solid pixel stretch (edge-repeat sampling, selected-pixel bounds) (#3867) Stretching a 1 px row or column faded out along the stretch: bicubic and bilinear sampling read transparent pixels past the source edge, and with a selection the edge was the whole layer's bounds (lifted pixels kept their colour at zero alpha). Samples past the source rect now repeat the edge and the outline is antialiased by coverage, and the lifted pixels' bounds are the selected pixels'. Fixes #3773 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 40 +++++++++- crates/engine/src/transform_cmds.rs | 110 ++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 7d1dd0fe6e..3b157b56a9 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -392,6 +392,15 @@ fn warp( px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64 } }; + // Samples past the source rect repeat its edge, so a stretched edge row or column keeps its + // colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage + // instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels. + let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c); + let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0); + let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0)); + let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 }; + let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs()); + let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs()); let w = t.width() as usize; let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; let bytes = len.checked_mul(std::mem::size_of::()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; @@ -424,7 +433,7 @@ fn warp( for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix + dx, iy + dy, c) * wx * wy; + *s += clamped(ix + dx, iy + dy, c) * wx * wy; } } } @@ -436,12 +445,21 @@ fn warp( for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; + *s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; } } } } } + if interp != Interp::Nearest { + // How much of this pixel the warped source rect covers, per axis. + let (uc, vc) = (u + 0.5, v + 0.5); + let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0); + let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0); + for s in acc.iter_mut().take(n) { + *s *= cu * cv; + } + } let al = acc[a].clamp(0.0, 1.0); if al <= 0.0 { continue; @@ -539,6 +557,24 @@ mod tests { Surface::new(PixelFormat::RGBA8) } + /// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in + /// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased. + #[test] + fn stretched_single_column_stays_opaque() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]); + for interp in [Interp::Bicubic, Interp::Bilinear] { + let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap(); + let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap(); + assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}"); + for x in [0, 1, 25, 50, 75, 99] { + for y in 0..4 { + assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3)); + } + } + } + } + /// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left /// half is grey 64 and right half grey 192: the row across the edge, columns 26..36. #[test] diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index bcf97126cc..dfc3375d00 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -282,7 +282,12 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R } _ => { if let Some(surf) = l.surface() { - check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?; + // With a selection only the lifted pixels are warped (`split_selected`). + let src = match sel { + Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()), + None => surf.content_bounds(), + }; + check_budget(src, h, &format!("layer \"{}\"", l.name))?; } } } @@ -383,17 +388,20 @@ fn apply_transform( } _ => { if let Some(surf) = l.surface_mut() { - let src = surf.content_bounds(); *surf = match doc_sel { - // Only the selected pixels move: lift them, clear them, warp and paste back. + // Only the selected pixels move: lift them, clear them, warp and paste back. The + // lifted pixels' own bounds are the edge a stretch repeats (#3773). Some(sel) => { let (lifted, mut rest) = split_selected(surf, sel)?; - let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?; + let moved = warp_surface_with(&lifted, lifted.content_bounds(), h, interp, ctl)?.ok_or(EngineError::Cancelled)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?, + None => { + let src = surf.content_bounds(); + warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)? + } }; } } @@ -445,6 +453,11 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface let k = sel.sample_channel(x, y, 0); l[n - 1] *= k; r[n - 1] *= 1.0 - k; + // Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the + // selected pixels' (what a stretch repeats at the edge, #3773). + if l[n - 1] <= 0.0 { + l.fill(0.0); + } if r[n - 1] <= 0.0 { r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c); } @@ -1249,6 +1262,93 @@ mod tests { assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform"); } + /// A 1 x 8 px column on a 256 x 256 canvas (#3773). + fn column_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().fill_rect(Rect::new(100, 100, 101, 108), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + fn opaque_green(s: &Session, x: i32, y: i32) -> bool { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (surf.sample_channel(x, y, 1) - 1.0).abs() < 1e-3 && (surf.sample_channel(x, y, 3) - 1.0).abs() < 1e-3 + } + + /// The "pixel stretch" (#3773): scales far past 1000% are taken, and the stretched column stays + /// solid along its whole length. + #[test] + fn a_one_pixel_column_stretches_5000_percent() { + let mut s = column_session(); + s.execute("edit.transform", json!({"quad": [[100, 100], [150, 100], [150, 108], [100, 108]]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(100, 100, 150, 108)); + for x in [100, 101, 125, 149] { + for y in 100..108 { + assert!(opaque_green(&s, x, y), "({x}, {y})"); + } + } + } + + /// The technique as done in Photoshop: select a 1 px column of an image and stretch it across. + #[test] + fn a_selected_column_stretches_solid_over_the_image() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 256, 256), &[1.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(10, 0, 11, 256), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 256})).unwrap(); + s.execute("edit.transform", json!({"quad": [[10, 0], [250, 0], [250, 256], [10, 256]]})).unwrap(); + for x in [10, 11, 100, 200, 249] { + assert!(opaque_green(&s, x, 128), "x = {x}"); + } + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(surf.sample_channel(250, 128, 0) > 0.999 && surf.sample_channel(9, 128, 0) > 0.999, "the image either side is untouched"); + } + + /// Free Transform keeps pixels moved off the canvas, as Photoshop does: an 8 x 8 layer moved + /// three canvas widths right and back comes back whole. + #[test] + fn a_layer_moved_far_off_the_canvas_and_back_keeps_its_pixels() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..8 { + for x in 0..8 { + surf.fill_rect(Rect::new(20 + x, 20 + y, 21 + x, 21 + y), &[x as f32 / 8.0, y as f32 / 8.0, 0.5, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + let pixels = |s: &Session| { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (0..8 * 8 * 4).map(|i| surf.sample_channel(20 + i / 4 % 8, 20 + i / 32, (i % 4) as usize)).collect::>() + }; + let before = pixels(&s); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(212, 20, 220, 28)); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(20, 20, 28, 28)); + assert_eq!(pixels(&s), before); + } + #[test] fn relative_params_scale_and_rotate_about_the_frame_centre() { let mut s = Session::new(); From 7b08b56531b2af71c593ce6e97b7e5ba9599381a Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 18:46:18 +0200 Subject: [PATCH 018/199] feat(engine): layer.list, a read-only listing of the layer tree (#5059) (#5148) The layers were only in document.inspect, together with history, channels and much more, so agents looked for a layer.* command that didn't exist. layer.list returns the layers top-down with id, name, kind, visibility, bounds, selected and active; groups list their children, or with "flat": true every layer is one row with its depth and parent group. It takes the same optional "document" index as document.inspect, is not journaled and bad params are errors. --- crates/engine/src/commands.rs | 1 + crates/engine/src/layer_list_cmds.rs | 163 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + docs/control-protocol.md | 1 + 4 files changed, 166 insertions(+) create mode 100644 crates/engine/src/layer_list_cmds.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 48ce4a9332..923f049643 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1283,6 +1283,7 @@ fn build() -> Vec { }); } v.extend(crate::layer_style::specs()); + v.extend(crate::layer_list_cmds::specs()); v.extend(crate::filters::specs()); v.extend(crate::filters_ext::specs()); v.extend(crate::color_to_alpha_cmds::specs()); diff --git a/crates/engine/src/layer_list_cmds.rs b/crates/engine/src/layer_list_cmds.rs new file mode 100644 index 0000000000..6ced6ae0d9 --- /dev/null +++ b/crates/engine/src/layer_list_cmds.rs @@ -0,0 +1,163 @@ +//! `layer.list`: the layer tree alone, for agents and scripts (#5059). `document.inspect` also has +//! it, buried among history, channels and much more; an agent looking for the layers' ids looks +//! for a `layer.*` command first. + +use photocraft_doc::{Layer, LayerContent, LayerId}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{DocState, EngineError, Result, Session}; + +const ID: &str = "layer.list"; + +/// Groups nest at most this deep in the listing; deeper layers are left out (documents can be +/// deep, and the tree is built recursively). +const MAX_DEPTH: usize = 256; + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +/// One layer's row: what an agent needs to pick a layer to work on. +fn row(l: &Layer, selected: &[LayerId], active: Option) -> Value { + let bounds = crate::layer_multi_cmds::layer_bounds(l).map(|r| [r.x0, r.y0, r.width() as i32, r.height() as i32]); + json!({ + "id": l.id.0, + "name": l.name, + "kind": l.content.kind_name(), + "visible": l.visible, + "bounds": bounds, + "selected": selected.contains(&l.id), + "active": active == Some(l.id), + }) +} + +/// The layers top-down (the Layers panel's order), groups with their `children`. +fn tree(layers: &[Layer], d: &DocState, selected: &[LayerId], depth: usize) -> Vec { + if depth >= MAX_DEPTH { + return Vec::new(); + } + layers + .iter() + .rev() + .map(|l| { + let mut v = row(l, selected, d.active_layer); + if let LayerContent::Group(g) = &l.content { + v["children"] = Value::Array(tree(&g.children, d, selected, depth + 1)); + } + v + }) + .collect() +} + +/// The layers top-down as one list, each with its `depth` (0 = top level) and `parent` group id. +fn flat(layers: &[Layer], d: &DocState, selected: &[LayerId], parent: Option, depth: usize, out: &mut Vec) { + if depth >= MAX_DEPTH { + return; + } + for l in layers.iter().rev() { + let mut v = row(l, selected, d.active_layer); + v["depth"] = json!(depth); + v["parent"] = json!(parent.map(|p| p.0)); + out.push(v); + if let LayerContent::Group(g) = &l.content { + flat(&g.children, d, selected, Some(l.id), depth + 1, out); + } + } +} + +fn run(s: &mut Session, p: &Value) -> Result { + let bad = |msg: &str| EngineError::BadParams { cmd: ID.into(), msg: msg.into() }; + let d = match p.get("document") { + None | Some(Value::Null) => s.active().ok_or(EngineError::NoDocument)?, + Some(v) => { + let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("`document` must be an index"))?; + s.documents().get(i).ok_or_else(|| EngineError::Other(format!("no document at index {i}")))? + } + }; + let as_flat = match p.get("flat") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("`flat` must be true or false"))?, + }; + let selected = d.selected_layers(); + let layers = if as_flat { + let mut out = Vec::new(); + flat(&d.doc.layers, d, &selected, None, 0, &mut out); + out + } else { + tree(&d.doc.layers, d, &selected, 0) + }; + Ok(json!({"layers": layers, "activeLayer": d.active_layer.map(|l| l.0)})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: ID, + label: "List Layers", + menu: &[], + shortcut: None, + params: r##"{"document":index?,"flat":bool=false} → {"layers":[{"id","name","kind","visible","bounds":[x,y,w,h]|null,"selected","active","children"?}],"activeLayer":id?} (top-down; groups list their "children", or with "flat" every layer is one row with its "depth" and "parent" group id)"##, + enabled: has_doc, + run, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s + } + + #[test] + fn lists_ids_names_kinds_and_nesting_top_down() { + let mut s = session_with_doc(); + let bg = s.active().unwrap().active_layer.unwrap().0; + let inner = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().doc.layers.last().unwrap().id.0; + s.execute("layer.select", json!({"layer": inner})).unwrap(); + s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap(); + let r = s.execute(ID, json!({})).unwrap(); + let layers = r["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 2, "{r}"); + assert_eq!((layers[0]["id"].as_u64(), layers[0]["kind"].as_str()), (Some(group), Some("Group")), "{r}"); + assert_eq!(layers[0]["children"][0]["id"], inner, "{r}"); + assert_eq!(layers[0]["children"][0]["active"], true, "{r}"); + assert!(layers[0]["children"][0]["bounds"].is_array(), "{r}"); + assert_eq!(layers[0]["bounds"], layers[0]["children"][0]["bounds"], "a group spans its children"); + assert_eq!((layers[1]["id"].as_u64(), layers[1]["name"].as_str(), layers[1]["visible"].as_bool()), (Some(bg), Some("Background"), Some(false))); + assert!(layers[1].get("children").is_none()); + assert_eq!(r["activeLayer"], inner); + // Flat: one row per layer, with its depth and parent group. + let r = s.execute(ID, json!({"flat": true})).unwrap(); + let rows: Vec<(u64, u64, Option)> = + r["layers"].as_array().unwrap().iter().map(|l| (l["id"].as_u64().unwrap(), l["depth"].as_u64().unwrap(), l["parent"].as_u64())).collect(); + assert_eq!(rows, [(group, 0, None), (inner, 1, Some(group)), (bg, 0, None)]); + } + + #[test] + fn is_a_query_and_leaves_the_document_alone() { + let mut s = session_with_doc(); + let revision = s.active().unwrap().revision; + let journal = s.journal.len(); + s.execute(ID, json!({})).unwrap(); + assert_eq!((s.active().unwrap().revision, s.journal.len()), (revision, journal)); + } + + #[test] + fn bad_params_are_errors_not_panics() { + let mut s = Session::new(); + assert!(s.execute(ID, json!({})).is_err(), "no document"); + let mut s = session_with_doc(); + for p in [json!({"flat": "yes"}), json!({"document": -1}), json!({"document": 99}), json!({"document": u64::MAX}), json!({"document": "a"})] { + assert!(s.execute(ID, p.clone()).is_err(), "{p}"); + } + assert!(s.execute(ID, json!({"document": 0, "flat": false})).is_ok()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff3cde6e4c..f3e683be4f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,6 +62,7 @@ pub mod jobs; pub mod kys; pub mod layer_copy_cmds; pub mod layer_label_cmds; +pub mod layer_list_cmds; pub mod layer_mask_props_cmds; pub mod layer_menu_cmds; pub mod layer_multi_cmds; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4fa1c60c6e..1cf47c17a6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -111,6 +111,7 @@ store expose and persist the same setting; scripts keep using canonical command | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | +| `layer.list` | `{"document":index?,"flat":bool?}`: just the layers, top-down: `id`, `name`, `kind`, `visible`, `bounds` (`[x,y,w,h]` or null), `selected`, `active`; groups list their `children`, or with `flat` every layer is one row with its `depth` and `parent` group id | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | From 3b45e8baee7117e53847523b5bc3ae2b337966d4 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:46:25 +0200 Subject: [PATCH 019/199] PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202) (#5206) * PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202) The flattened Bitmap/Indexed/Duotone path retried every composite_rgba8 error, including LimitExceeded, by allocating the refused RGBA buffer (OOM abort on e.g. a 50000x50000 1-bit PSB). LimitExceeded now propagates; the Multichannel fallback checks its output against the cap, reserves fallibly, borrows the merged image, uses get for planes and converts in TILE_SIZE bands. A failed composite now warns. Checked usize arithmetic for alpha channel names, merged plane sizes and the gradient map name length (overflowed on 32-bit targets). Also: EXR part import drops a cache that never hit but cloned each image; raw KRA tiles are borrowed; flat import detects the format once. Co-Authored-By: Claude Opus 5.5 * PSD import: flat Background opacity check reads a band at a time (#5200) flat_background read the whole surface into floats to see whether it is opaque, undoing the banded conversion. Read TILE_SIZE row bands, stop at the first transparent pixel, and drop the RGBA8 buffer before. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/adjust_map.rs | 3 +- crates/io/src/exr_parts.rs | 13 +-- crates/io/src/flat.rs | 21 ++-- crates/io/src/kra/tiles.rs | 6 +- crates/io/src/psd_import.rs | 193 +++++++++++++++++++++++++++++------- 5 files changed, 178 insertions(+), 58 deletions(-) diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 6e82478389..d4bc312690 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -280,7 +280,8 @@ fn parse_gradient_map(d: &[u8]) -> Option { 3 => (Some(d.get(4..8)?), 8), _ => return None, }; - at += 4 + be32(d, at)? as usize * 2; + // The gradient's name (UTF-16 units); checked, as `len * 2` overflows a 32-bit `usize`. + at = (be32(d, at)? as usize).checked_mul(2)?.checked_add(at + 4)?; let n = usize::from(be16(d, at)?); at += 2; let mut stops = Vec::with_capacity(n); diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 5474d00310..7e25feb984 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -225,8 +225,8 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result> = vec![None; info.len()]; + // Each chosen part (deduplicated above) decodes once; groups come from their part's named + // channels. let mut layers: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); let mut gray_only = true; @@ -234,14 +234,7 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result img.clone(), - None => { - let img = codecs::decode_exr_part(bytes, part.index, &opts)?; - part_pixels[part.index] = Some(img.clone()); - img - } - }; + let img = codecs::decode_exr_part(bytes, part.index, &opts)?; if img.sample_type() == SampleType::F16 { f16_seen = true; } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 5512a55c01..54fe96090d 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -16,13 +16,15 @@ const BAND_BYTES: usize = 32 << 20; /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - if codecs::detect(bytes) == Some(Format::Tiff) { + // Detected once; every step below branches on it. + let format = codecs::detect(bytes); + if format == Some(Format::Tiff) { return import_tiff_page(name, bytes, None); } - let img = codecs::decode(bytes)?; + let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; // A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop // (#3802, #4804). - let palette = match codecs::detect(bytes) { + let palette = match format { Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)), Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)), _ => None, @@ -34,15 +36,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { r.document.mode = ColorMode::Indexed; r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } - r.document.file_encoding = file_encoding(bytes); - if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { + r.document.file_encoding = file_encoding_as(format, bytes); + if format == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { tga_alpha_to_channel(&mut r.document)?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear // gray so they display and convert correctly. let d = &mut r.document; - if d.icc_profile.is_none() && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) { + if d.icc_profile.is_none() && matches!(format, Some(Format::OpenExr | Format::Hdr)) { d.icc_profile = match d.mode { ColorMode::Rgb => Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()), ColorMode::Grayscale => Some(photocraft_cms::builtin::linear_gray().to_bytes()), @@ -68,8 +70,13 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document /// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its /// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`]. pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { + file_encoding_as(codecs::detect(bytes), bytes) +} + +/// [`file_encoding`] for `bytes` already detected as `format`. +fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::FileEncoding { let mut e = photocraft_doc::FileEncoding::default(); - match codecs::detect(bytes) { + match format { Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes), Some(Format::WebP) => match codecs::webp_encoding(bytes) { Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true), diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index b369341b99..8af6638aa8 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -231,9 +231,9 @@ impl<'a> TileFile<'a> { for &(x, y, data) in &self.tiles { ctl.check().map_err(|_| IoError::Cancelled)?; let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?; - let planar = match flag { - 1 => lzf_decompress(payload, expected)?, - 0 if payload.len() == expected => payload.to_vec(), + let planar: std::borrow::Cow<'_, [u8]> = match flag { + 1 => lzf_decompress(payload, expected)?.into(), + 0 if payload.len() == expected => payload.into(), 0 => return Err(invalid("raw tile has the wrong size")), f => return Err(invalid(format!("unknown tile compression flag {f}"))), }; diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 211bba4da9..94c4ea9932 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -595,14 +595,83 @@ fn unicode_names(data: &[u8]) -> Vec { while at + 4 <= data.len() { let n = u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as usize; at += 4; - let Some(b) = data.get(at..at + n * 2) else { break }; + // Checked: `n * 2` overflows a 32-bit `usize` for a hostile length. + let Some(end) = n.checked_mul(2).and_then(|len| at.checked_add(len)) else { break }; + let Some(b) = data.get(at..end) else { break }; let units: Vec = b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); out.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); - at += n * 2; + at = end; } out } +/// Plane `i` of a decoded merged image whose planes are `plane` bytes each, or `None` when it is +/// short or the offsets overflow. +fn merged_plane(all: &[u8], i: usize, plane: usize) -> Option<&[u8]> { + all.get(i.checked_mul(plane)?..i.checked_add(1)?.checked_mul(plane)?) +} + +/// The merged image of a file the layered path does not open (Bitmap, Indexed, Duotone; 1-bit +/// files) as RGBA8. What `composite_rgba8` can't read (Multichannel) shows its first channels as +/// RGB. An output over `cap` bytes is refused, never allocated (#5200). +fn flat_composite( + file: &PsdFile, + merged: Option<&Result, photocraft_psd::PsdError>>, + cap: u64, +) -> Result { + use photocraft_psd::PsdError; + match file.composite_rgba8() { + Ok(img) => return Ok(img), + // Over the output budget: the fallback below would make the same allocation. + Err(e @ PsdError::LimitExceeded(_)) => return Err(e), + Err(_) => {} + } + let all = match merged { + Some(Ok(all)) => all, + Some(Err(e)) => return Err(e.clone()), + None => return Err(PsdError::Invalid("no merged image".into())), + }; + let h = &file.header; + let (w, hh) = (h.width as usize, h.height as usize); + let len = w + .checked_mul(hh) + .and_then(|n| n.checked_mul(4)) + .filter(|&len| len as u64 <= cap) + .ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?; + let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?; + let mut data = Vec::new(); + data.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for RGBA output"))?; + data.resize(len, 255u8); + for c in 0..3.min(usize::from(h.channels)) { + let bytes = merged_plane(all, c, plane).ok_or_else(|| PsdError::Invalid("channel size mismatch".into()))?; + let p = photocraft_psd::pixels::plane_to_u8(bytes, h.depth, w, hh)?; + for (px, v) in data.as_chunks_mut::<4>().0.iter_mut().zip(&p) { + px[c] = *v; + } + } + Ok(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) +} + +/// Writes RGBA8 `data` covering `canvas` into `s`, a band of tile rows at a time (no full-size +/// float copy of the image). +fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) { + let fmt = s.format(); + let row = (canvas.width() as usize).saturating_mul(4); + let mut vals = Vec::new(); + let mut y = canvas.y0; + while y < canvas.y1 { + let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); + let start = ((y - canvas.y0) as usize).saturating_mul(row); + let Some(rows) = data.get(start..start.saturating_add((band.height() as usize).saturating_mul(row))) else { break }; + vals.clear(); + for p in rows.as_chunks::<4>().0 { + vals.extend(photocraft_raster::from_rgba(&fmt, p.map(|x| f32::from(x) / 255.0))); + } + s.write_region(band, &vals); + y = band.y1; + } +} + fn parse_guides(data: &[u8], doc: &mut Document) { let rd = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let Some(count) = rd(12) else { return }; @@ -623,10 +692,21 @@ fn parse_guides(data: &[u8], doc: &mut Document) { /// Background (Photoshop 27.11 opens psd-tools' flat Bitmap and Indexed files that way), so it is /// written back as one. fn flat_background(s: Surface) -> Layer { + // A band of tile rows at a time (no full-size float copy), stopping at the first + // transparent pixel. let opaque = { let r = s.content_bounds(); let n = s.channels(); - !r.is_empty() && s.read_region(r).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)) + let mut vals = Vec::new(); + let mut y = r.y0; + let mut opaque = !r.is_empty(); + while opaque && y < r.y1 { + let band = Rect::new(r.x0, y, r.x1, y.saturating_add(TILE_SIZE).min(r.y1)); + s.read_region_into(band, &mut vals); + opaque = vals.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); + y = band.y1; + } + opaque }; let mut l = Layer::new("Background", LayerContent::Raster(s)); l.locks.transparency = opaque; @@ -771,7 +851,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } let (w, hh) = (h.width as usize, h.height as usize); - let n = w * hh; + // Pixels per plane and bytes per merged plane, checked: a 1-bit plane can decode within the + // budget while `w * hh` does not fit a 32-bit `usize` (wasm32). + let sizes = w.checked_mul(hh).zip(h.row_bytes().checked_mul(hh)); let canvas = Rect::new(0, 0, h.width as i32, h.height as i32); // The merged composite is decoded only when something consumes it: a flattened or // multichannel file (it *is* the image), or extra alpha/spot channels behind the colour @@ -821,12 +903,13 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } if doc.layers.is_empty() && let Some(Ok(all)) = &merged + // Always `Some` here: a decoded 8/16/32-bit plane holds at least `w * hh` bytes. + && let Some((n, plane)) = sizes { // Flattened file: the merged image becomes the background layer. - let plane = h.row_bytes() * hh; let alpha_idx = if file.merged_has_alpha() { Some(cc) } else { None }; - let mut planes: Vec> = (0..cc).map(|c| all.get(c * plane..(c + 1) * plane)).collect(); - planes.push(alpha_idx.and_then(|a| all.get(a * plane..(a + 1) * plane))); + let mut planes: Vec> = (0..cc).map(|c| merged_plane(all, c, plane)).collect(); + planes.push(alpha_idx.and_then(|a| merged_plane(all, a, plane))); let mut fill = vec![zero_sample(depth); cc]; fill.push(max_sample(depth)); let mut inv = vec![cx.cmyk; cc]; @@ -882,47 +965,32 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); } - let rgba = file.composite_rgba8().or_else(|_| { - // Multichannel: show the first channels as RGB. - let all = merged.clone().ok_or_else(|| photocraft_psd::PsdError::Invalid("no merged image".into()))??; - let plane = h.row_bytes() * hh; - let mut data = vec![255u8; n * 4]; - for c in 0..3.min(usize::from(h.channels)) { - let p = photocraft_psd::pixels::plane_to_u8(&all[c * plane..(c + 1) * plane], h.depth, w, hh)?; - for i in 0..n { - data[i * 4 + c] = p[i]; - } + match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { + Ok(img) => { + let mut s = Surface::new(fmt); + write_rgba8(&mut s, canvas, &img.data); + drop(img); + s.prune(); + doc.layers.push(flat_background(s)); } - Ok::<_, photocraft_psd::PsdError>(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) - }); - if let Ok(img) = rgba { - let mut s = Surface::new(fmt); - let vals: Vec = img - .data - .as_chunks::<4>() - .0 - .iter() - .flat_map(|p| { - let v = photocraft_raster::from_rgba(&fmt, [p[0], p[1], p[2], p[3]].map(|x| f32::from(x) / 255.0)); - v.into_iter() - }) - .collect(); - s.write_region(canvas, &vals); - s.prune(); - doc.layers.push(flat_background(s)); + // A merged image that failed to decode was reported above. + Err(_) if matches!(merged, Some(Err(_))) => {} + Err(e) => cx.warn(format!("the image could not be read: {e}")), } } // Extra (alpha / spot) channels of the merged image. - if layered && let Some(Ok(all)) = &merged { + if layered + && let Some(Ok(all)) = &merged + && let Some((n, plane)) = sizes + { let first = cc + usize::from(file.merged_has_alpha()); - let plane = h.row_bytes() * hh; let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); let names = names.get(skip..).unwrap_or_default(); for (k, idx) in (first..usize::from(h.channels)).enumerate() { - let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break }; + let Some(p) = merged_plane(all, idx, plane) else { break }; let mut s = Surface::new(cx.mask_fmt); s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); s.prune(); @@ -962,3 +1030,54 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) ctl.progress(1.0); Some((doc, cx.warnings)) } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_psd::{Compression, PsdError, Version, testgen}; + + /// #5200: the Multichannel fallback checks its RGBA output against the budget before + /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). + #[test] + fn flat_fallback_refuses_an_output_over_the_cap() { + let file = testgen::merged_only(Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 2, 2); + assert!(file.composite_rgba8().is_err(), "Multichannel goes through the fallback"); + let merged = file.decode_merged(); + assert!(matches!(flat_composite(&file, Some(&merged), 15), Err(PsdError::LimitExceeded(_)))); + let img = flat_composite(&file, Some(&merged), 16).unwrap(); + assert_eq!(img.data.len(), 16); + } + + /// #5200: a composite over the budget is not retried by the fallback (which allocated the + /// refused buffer: 14.4 GB for this 60000x60000 Bitmap header). + #[test] + fn flat_fallback_does_not_retry_an_over_budget_composite() { + let mut file = testgen::small(Version::Psb, Compression::Raw); + file.header.color_mode = PsdMode::Bitmap; + file.header.depth = 1; + file.header.channels = 1; + file.header.width = 60_000; + file.header.height = 60_000; + let merged = Ok(vec![0u8; 16]); + let cap = photocraft_psd::compression::MAX_DECODED_BYTES; + assert!(matches!(flat_composite(&file, Some(&merged), cap), Err(PsdError::LimitExceeded(_)))); + } + + /// #5202: a hostile name length ends the list instead of overflowing. + #[test] + fn unicode_names_stop_at_a_hostile_length() { + let mut data = 1u32.to_be_bytes().to_vec(); + data.extend([0, b'A']); + data.extend(u32::MAX.to_be_bytes()); + data.extend([0, b'B']); + assert_eq!(unicode_names(&data), ["A"]); + } + + #[test] + fn merged_plane_checks_offsets() { + let all = [1u8, 2, 3, 4]; + assert_eq!(merged_plane(&all, 1, 2), Some(&[3u8, 4][..])); + assert_eq!(merged_plane(&all, 2, 2), None); + assert_eq!(merged_plane(&all, usize::MAX, 2), None); + } +} From 0eb008ce5ddf2d40e52262f045fe815a6a15e60a Mon Sep 17 00:00:00 2001 From: Wil <77077912+GamerWil26@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:46:31 +0800 Subject: [PATCH 020/199] Actions: record layer selections by name and display the target (#2441) * Record action layer selections by name and show their targets * Use document pixel format in grouped-layer regression * Document the named layer action UI with a synthetic capture --- crates/engine/src/actions_cmds.rs | 103 ++++++++++++++++++++++++++ crates/engine/src/commands.rs | 2 +- crates/engine/src/jobs.rs | 2 + crates/engine/src/layer_multi_cmds.rs | 44 ++++++++++- crates/ui-egui/src/actions.rs | 28 +++++++ docs/control-protocol.md | 13 +++- 6 files changed, 189 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 3b3ffd1f01..7ae557c48a 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -957,6 +957,109 @@ mod tests { } } + #[test] + fn recorded_layer_selection_replays_by_name_on_another_document() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + let original = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + s.execute("actions.record", json!({"name": "Duplicate photo"})).unwrap(); + s.execute("layer.select", json!({"layer": original.0, "mode": "replace"})).unwrap(); + // The target name must be captured now, not when recording stops. + s.execute("layer.setProps", json!({"name": "Retouched"})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo", "mode":"replace"})); + // Persisted actions must work in an entirely different session too. + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + replay.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + replay.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + let target = replay.active().unwrap().active_layer.unwrap(); + assert_ne!(original, target); + replay.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(result["ran"], 3); + assert_eq!(replay.active().unwrap().doc.layer(target).unwrap().name, "Retouched"); + let active = replay.active().unwrap().active_layer.unwrap(); + assert_eq!(replay.active().unwrap().doc.layer(active).unwrap().name, "Retouched copy"); + } + } + + #[test] + fn named_selection_preserves_modes_and_duplicate_occurrences() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.setProps", json!({"name":"Photo"})).unwrap(); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.group", json!({"name":"Group"})).unwrap(); + // A duplicate nested in a group must be found even when that group is collapsed. + let second = s + .edit("child", |doc, active| { + let child = photocraft_doc::Layer::raster("Photo", doc.pixel_format()); + let id = child.id; + if let photocraft_doc::LayerContent::Group(group) = &mut doc.layer_mut(active.unwrap()).unwrap().content { + group.children.push(child); + group.expanded = false; + } + Ok(id) + }) + .unwrap(); + s.execute("actions.record", json!({"name":"Select duplicates"})).unwrap(); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + s.execute("layer.select", json!({"layer":second.0,"mode":"add"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo","occurrence":0})); + assert_eq!(s.actions.list[0].steps[1].1, json!({"name":"Photo","occurrence":1,"mode":"add"})); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + let r = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(s.active().unwrap().selected_layers(), vec![first, second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"toggle"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"range"})).unwrap(); + assert!(s.active().unwrap().selected_layers().contains(&second)); + // Main's imported .atn steps use a string-valued `layer` and choose the topmost match. + s.execute("layer.select", json!({"layer":"Photo"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"add"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + // Removing an unselected layer does not add it; removing the last target keeps it. + s.execute("layer.select", json!({"layer":"Photo","mode":"remove"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + for p in [ + json!({"name":"absent"}), + json!({"name":"Photo"}), + json!({"name":1}), + json!({"name":"Photo","occurrence":-1}), + json!({"name":"Photo","occurrence":1.5}), + json!({"name":"Photo","occurrence":99}), + json!({"name":"Photo","layer":first.0}), + json!({"layer":first.0,"occurrence":0}), + ] { + let before = s.active().unwrap().selected_layers(); + assert!(s.execute("layer.select", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().selected_layers(), before); + } + s.actions.list.push(Action { + name: "Missing".into(), + steps: vec![("layer.select".into(), json!({"name":"absent"})), ("layer.duplicate".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let before = s.active().unwrap().doc.walk().len(); + let result = s.execute("actions.play", json!({"action":"Missing"})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result["failed"]["error"].as_str().unwrap().contains("absent")); + assert_eq!(s.active().unwrap().doc.walk().len(), before); + } + #[test] fn play_stops_at_the_failing_step_and_reports_it() { let mut s = Session::new(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 923f049643..3969af0576 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -690,7 +690,7 @@ fn build() -> Vec { "Select Layer", [], None, - r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, + r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, has_doc, crate::layer_multi_cmds::select ), diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index dc43f4d794..2ad21c4367 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -724,6 +724,8 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { + let params = + if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; self.journal.push((id.to_string(), params)); } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2accc4ea86..9a79a72050 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -100,6 +100,48 @@ pub(crate) fn reselect(s: &mut Session, ids: Vec, active: Option Value { + let Some(id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; + let Some(st) = s.active() else { return p }; + let Some(layer) = st.doc.layer(id) else { return p }; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).map(|(_, _, l)| l.id).collect(); + let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p }; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + fields.insert("name".into(), json!(layer.name)); + if matches.len() > 1 { + fields.insert("occurrence".into(), json!(occurrence)); + } + } + p +} + +fn selection_target(s: &Session, p: &Value) -> Result { + let Some(name) = p.get("name") else { + if p.get("occurrence").is_some() { + return Err(bad("layer.select", "`occurrence` requires `name`")); + } + return crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "needs `layer` or `name`")); + }; + if p.get("layer").is_some() { + return Err(bad("layer.select", "use either `layer` or `name`, not both")); + } + let name = name.as_str().ok_or_else(|| bad("layer.select", "`name` must be a string"))?; + let occurrence = p + .get("occurrence") + .map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("layer.select", "`occurrence` must be a non-negative integer"))) + .transpose()?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).collect(); + if matches.len() > 1 && occurrence.is_none() { + return Err(bad("layer.select", format!("multiple layers named `{name}`; specify `occurrence`"))); + } + let index = occurrence.unwrap_or(0); + matches.get(index).copied().ok_or_else(|| bad("layer.select", format!("no layer named `{name}` at occurrence {index}"))) +} + /// Layer rows follow the Layers panel's visual order, not Document::walk's storage order. /// Closed groups hide their children unless both endpoints were selected programmatically. fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec) { @@ -115,7 +157,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`. pub fn select(s: &mut Session, p: &Value) -> Result { - let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?; + let id = selection_target(s, p)?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); let st = s.active().ok_or(EngineError::NoDocument)?; if st.doc.layer(id).is_none() { diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index e0f3713f2e..17d324c606 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -250,6 +250,14 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == "layer.select" { + let target = params + .get("name") + .and_then(Value::as_str) + .or_else(|| params.get("layer").and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| params.get("layer").and_then(Value::as_u64).map(|id| format!("ID {id}"))); + target.map_or_else(|| label_of(id), |target| format!("{} ({target})", label_of(id))) } else if id == "actions.conditional" { // "If hasLayers: FS8-Layers / FS8-No-Layers" let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); @@ -911,6 +919,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn selected_layer_name_is_visible_during_recording_and_after_stop() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4})).unwrap(); + app.run("layer.setProps", json!({"name":"Background"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("actions.record", json!({"name":"Select photo"})).unwrap(); + app.ui.actions.expanded = vec![true]; + app.run("layer.select", json!({"layer":layer.0})).unwrap(); + let mut h = Harness::builder().with_size(vec2(400.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + assert_eq!(step_label("layer.select", &json!({"layer":26})), "Select Layer (ID 26)"); + assert_eq!(step_label("layer.select", &json!({"layer":"Background"})), "Select Layer (Background)"); + } + #[test] fn actions_list_grows_with_panel_and_keeps_footer_visible() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1cf47c17a6..9b49cd9b36 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -100,7 +100,7 @@ store expose and persist the same setting; scripts keep using canonical command |---|---| | `file.new` | `{"width":1920,"height":1080,"mode":"rgb","depth":8,"background":"white"}` | | `layer.new.layer` | `{"name":"Ink"}` | -| `layer.select` | `{"layer":id,"mode":"replace\|toggle\|range\|add"}`: ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | +| `layer.select` | `{"layer":id\|str,"mode":"replace\|toggle\|range\|add\|remove"}` or `{"name":"Background","occurrence":0?}` (occurrence is zero-based document walk order; required for duplicate names): ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | | `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel | | `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | | `layer.fill.solidColor.set` | `{"layer":id?,"document":id?,"color":"#rrggbb"}`: edit a Solid Color Fill in one undo step; RGB without alpha preserves the fill's alpha. Float RGB arrays and a stored `Color` are also accepted. `document` rejects stale dialog targets. | @@ -547,6 +547,17 @@ visibility reset when the dialog opens. HDR scopes are not implemented. See ### Editing and composing actions +Newly recorded Select Layer steps store the layer name instead of a document-specific ID, +and the Actions panel shows `Select Layer (Background)`. Duplicate names include a zero-based +`occurrence` in document walk order (including layers inside groups); keep that order consistent +between source and target documents. A missing name or occurrence stops playback; a name without +an occurrence is rejected when ambiguous. Names are captured at selection time, so a later +rename in the action does not change the earlier target. Numeric `layer` IDs remain supported +for direct commands and older recordings, which the panel labels `Select Layer (ID 26)`. +String-valued `layer` targets from imported Photoshop actions keep main's topmost-name matching +and show the same named label; `remove` selection mode is supported for either target form. +Re-record old Select Layer steps: their original names cannot be recovered from an ID alone. + Creating an action scrolls its row into view; newly recorded steps are revealed immediately. Drag a step above or below another step in the same action to reorder it. Drag an action header to reorder the action list. The insertion line marks the drop position; dragging near the rows' From 8fb32981611a1bdb34ff68ca7e52169f5975b732 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 18:46:38 +0200 Subject: [PATCH 021/199] Color Balance: fold the tones into one Levels per channel (#3423) (#5339) Photoshop doesn't apply the shadows, midtones and highlights curves one after another: per channel it builds a single Levels (black point from shadows, white point from highlights, one midtone gamma 2^((midtones + (shadows + highlights) / 2) / 100)), and a gamma > 1 starts with slope 2^gamma at black instead of a pure power curve. Applying the tones in sequence let a lowered shadows and a raised highlights slider compound their gammas (up to 85/255 off on mixed settings), and the pure power curve over-brightened the shadows of the issue's red +100 / magenta -100 repro by up to 16/255. Measured against Photoshop on generated 16-bit ramps and pure colours: worst error 0.8/255 (was 85/255), the issue's repro 0.02/255 (was 16.5). Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/adjust.rs | 170 +++++++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 48 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 726b375e36..3c40d7c595 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -594,61 +594,63 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f } } -/// How far one Color Balance slider at ±100 moves the black or white point (Photoshop 27.11: -/// shadows −100 maps 99.6 of 255 to black, highlights +100 maps 155.5 to white). -const BALANCE_LEVELS: f32 = 0.39; - /// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the /// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue, /// −100..100). /// -/// Reverse-engineered from Photoshop 27.11 (a gray ramp and pure colours through each slider at -/// ±25, ±50 and ±100, with Preserve Luminosity on and off): every slider is a levels-and-gamma -/// curve per channel, exact within 1 level for a single slider. With `a` = slider / 100: -/// - Preserve Luminosity off: a slider only moves its own channel. Midtones is gamma 2^−a. -/// Shadows is gamma 2^(−a/2) after a black point at 0.39·|a| when lowered; highlights is -/// gamma 2^(−a/2) after a white point at 1 − 0.39·a when raised. -/// - Preserve Luminosity on: midtones uses gamma 2^((max(a) + min(a))/2 − a_ch), -/// preserving the one-slider behaviour without summing offsets when multiple sliders move. -/// Shadows and highlights go by the differences between a tone's three sliders: -/// each channel gets a plain black point at 0.39·(max − its own) for shadows and a plain white -/// point at 1 − 0.39·(its own − min) for highlights (so one raised shadows slider darkens the -/// other two channels, and two raised together darken only the third). +/// Reverse-engineered from Photoshop (a 16-bit gray ramp through every tone at ±10..±100, pairs of +/// tones, and random settings of all nine sliders, Preserve Luminosity on and off): the three +/// tones fold into one Levels per channel (input black point, input white point, midtone gamma), +/// within 0.8 of 255 everywhere. Folding matters: a lowered shadows and a raised highlights +/// slider cancel each other's gamma, which applying the tones one after another doesn't (#3423). +/// - Preserve Luminosity off: a slider only moves its own channel. Lowered shadows set the black +/// point to |shadows| levels, raised highlights set the white point to 255 − highlights levels, +/// and the gamma is 2^(m / 100) with m = midtones + (shadows + highlights) / 2. +/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white +/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones. /// -/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one -/// channel: within 1 level). +/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter +/// near black without it). When the black point reaches the white point, input +/// at or above the white point is white and the rest black. pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 { - let [shadows, midtones, highlights] = tones; - let black = |v: f32, b: f32| ((v - b) / (1.0 - b)).max(0.0); - let white = |v: f32, w: f32| (v / w).min(1.0); - let mut v = v.clamp(0.0, 1.0); - for (tone, sliders) in [(2, highlights), (0, shadows), (1, midtones)] { - let a: [f32; 3] = sliders.map(|s| (s / 100.0).clamp(-1.0, 1.0)); - if preserve_luminosity { - let own = a.get(ch).copied().unwrap_or(0.0); - let (lo, hi) = a.iter().fold((f32::MAX, f32::MIN), |m, x| (m.0.min(*x), m.1.max(*x))); - v = match tone { - 0 => black(v, BALANCE_LEVELS * (hi - own)), - 1 => v.powf(2f32.powf((hi + lo) / 2.0 - own)), - _ => white(v, 1.0 - BALANCE_LEVELS * (own - lo)), - }; - continue; - } - for (src, slider) in sliders.iter().enumerate() { - let a = (slider / 100.0).clamp(-1.0, 1.0); - if a == 0.0 || src != ch { - continue; - } - v = match tone { - 1 => v.powf(2f32.powf(-a)), - 0 if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)), - 0 => v.powf(2f32.powf(-a / 2.0)), - 2 if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)), - _ => v.powf(2f32.powf(-a / 2.0)), - }; - } + let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 })); + let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0); + let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]); + let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]); + let (black, white, m) = if preserve_luminosity { + (max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0) + } else { + ((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0) + }; + let (b, w) = (black / 255.0, 1.0 - white / 255.0); + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if v >= w { + return 1.0; } - v + if v <= b { + return 0.0; + } + balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0)) +} + +/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except +/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the +/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop, +/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255). +fn balance_gamma(u: f32, gamma: f32) -> f32 { + let e = 1.0 / gamma.max(0.01); + let power = u.powf(e); + if gamma <= 1.0 { + return power; + } + let s0 = 2f32.powf(gamma); + let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0); + if u0.is_nan() || u0 <= 0.0 || u >= u0 { + return power; + } + let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0); + let (t2, t3) = (t * t, t * t * t); + ((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0) } /// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans, @@ -1257,6 +1259,78 @@ mod tone_tests { } } + /// Photoshop, measured (8-bit gray ramp, every channel): whole settings, where the tones fold + /// into one Levels per channel (#3423). The issue's midtones red +100 / magenta −100 (the + /// shadow toe), its sequoia14 layer with Preserve Luminosity off and on, all nine sliders with + /// Preserve Luminosity on, a black point past the white point, and lowered shadows with raised + /// highlights (their gammas cancel: a straight line between the two points). + #[test] + fn color_balance_whole_settings_match_photoshop() { + const X: [u8; 12] = [0, 4, 8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + type Case = ([[f32; 3]; 3], bool, [[u8; 12]; 3]); + let cases: [Case; 6] = [ + ( + [[0.0; 3], [100.0, -100.0, 0.0], [0.0; 3]], + false, + [[0, 16, 30, 55, 90, 128, 156, 181, 202, 221, 239, 255], [0, 0, 0, 1, 4, 16, 36, 64, 100, 145, 197, 255], X], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + false, + [ + [0, 0, 2, 9, 25, 56, 89, 122, 156, 188, 222, 255], + [0, 5, 9, 18, 35, 67, 100, 131, 163, 194, 225, 255], + [0, 3, 7, 14, 29, 60, 91, 123, 156, 189, 222, 255], + ], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + true, + [ + [0, 0, 0, 0, 5, 42, 80, 117, 154, 189, 226, 255], + [0, 4, 8, 16, 33, 66, 98, 130, 162, 195, 226, 255], + [0, 0, 0, 0, 15, 48, 82, 116, 151, 186, 221, 255], + ], + ), + ( + [[40.0, -30.0, 20.0], [-50.0, 25.0, 60.0], [30.0, -70.0, 10.0]], + true, + [ + [0, 1, 3, 9, 25, 69, 126, 193, 255, 255, 255, 255], + [0, 0, 0, 0, 0, 0, 46, 93, 136, 177, 217, 255], + [0, 0, 0, 0, 44, 107, 156, 199, 238, 255, 255, 255], + ], + ), + ( + [[100.0, -100.0, 0.0], [0.0; 3], [-100.0, 100.0, 0.0]], + true, + [X, [0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 0, 0, 0, 130, 255, 255, 255, 255]], + ), + ( + [[-76.0, -2.0, 60.0], [0.0; 3], [99.0, 0.0, -30.0]], + false, + [ + [0, 0, 0, 0, 0, 0, 71, 171, 255, 255, 255, 255], + [0, 2, 6, 14, 29, 61, 94, 126, 158, 191, 224, 255], + [0, 6, 11, 21, 39, 73, 106, 137, 168, 197, 227, 255], + ], + ), + ]; + for (tones, preserve, want) in cases { + for (ch, row) in want.iter().enumerate() { + for (x, w) in X.iter().zip(row) { + let got = (color_balance_channel(tones, preserve, ch, f32::from(*x) / 255.0) * 255.0).round() as i32; + assert!((got - i32::from(*w)).abs() <= 1, "{tones:?} pl {preserve} ch {ch} at {x}: {got} vs Photoshop {w}"); + } + } + } + // Hostile input stays in range. + for v in [f32::NAN, -1.0, 2.0, f32::INFINITY] { + let out = color_balance_channel([[f32::NAN, 1e9, -1e9]; 3], true, 7, v); + assert!((0.0..=1.0).contains(&out), "{v}: {out}"); + } + } + #[test] fn color_balance_midtones_with_multiple_sliders_preserve_luminosity() { // A shared offset depends on the two extremes, not the sum of moved sliders. From 88b2a21b335fd01802031be056bef8f220fb7aeb Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:46:44 +0200 Subject: [PATCH 022/199] Linked smart objects: read embedded WebP/GIF/BMP/EXR sizes from their headers (#5216) source_size_probe decoded every embedded file other than PSD/TIFF/PNG/JPEG in full just to learn its size. photocraft_codecs::probe_size reads the size from the same decoders' headers (WebP chunks, the image crate's GIF/BMP/TGA/ICO/QOI/HDR headers, flat EXR part headers), with the same limit checks and the EXIF quarter-turn swap, so it agrees with decode wherever decode succeeds. Deep EXR, AVIF and anything else still decode. Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/exr.rs | 15 +++++++++++++++ crates/codecs/src/codecs/via_image.rs | 22 ++++++++++++++++++++-- crates/codecs/src/codecs/webp.rs | 17 ++++++++++++++++- crates/codecs/src/lib.rs | 21 +++++++++++++++++++++ crates/codecs/tests/orientation.rs | 22 ++++++++++++++++++++++ crates/io/src/linked.rs | 17 +++++++++-------- 6 files changed, 103 insertions(+), 11 deletions(-) diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 2506dd062b..a55810b4b8 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -187,6 +187,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their +/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> { + let meta = read_meta(bytes, limits)?; + if meta.requirements.has_deep_data { + return Err(CodecError::unsupported(F, "no header-only size probe for deep data")); + } + let pick = pick_part(&meta); + let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?; + if info.color_rank() == 0 { + return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); + } + Ok((info.width, info.height)) +} + /// Decode one part of a multi-part file by its index into [`info`]'s list. A file with /// deep parts is not mixed: the deep decoder composites those instead. pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result { diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index c91be5baf7..7e7eb76b2f 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -37,7 +37,8 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result>, CodecErr Ok(decoder.get_palette().map(|palette| palette.to_vec())) } -pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { +/// The `image` decoder for `f` with our limits, its header read and its size checked. +fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result { let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt); let mut il = image::Limits::default(); @@ -45,9 +46,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result { + let mut dec = open(f, bytes, limits)?; let icc = dec.icc_profile().ok().flatten(); let exif = dec.exif_metadata().ok().flatten(); let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?; @@ -60,6 +66,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result<(u32, u32, Option>), CodecError> { + if f == Format::Avif { + return Err(CodecError::unsupported(f, "no header-only size probe")); + } + let mut dec = open(f, bytes, limits)?; + let (w, h) = dec.dimensions(); + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + /// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any. /// The total is unknown when the blocks end before the trailer or break off. fn gif_more_frames(b: &[u8]) -> Option { diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index 36473041dd..b7b6e2a668 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } -pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { +type Decoder<'a> = image_webp::WebPDecoder>; + +/// The decoder with its chunks read, the stored size and the output layout, the size checked. +fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> { let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?; dec.set_memory_limit(limits.alloc_usize()); let (w, h) = dec.dimensions(); @@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the // ratio is generous; it still stops a header-only file from asking for a gigabyte. Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?; + Ok((dec, w, h, layout)) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let (mut dec, w, h, layout) = open(bytes, limits)?; let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; let mut buf = alloc_zeroed::(size)?; dec.read_image(&mut buf).map_err(err)?; @@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the +/// same header reads and size checks, without decoding the bitstream. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option>), CodecError> { + let (mut dec, w, h, _) = open(bytes, limits)?; + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); if !opts.webp_lossless { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 438e1857e4..3831cf654f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -102,6 +102,27 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res finish_decode(img, o, opts) } +/// The upright pixel size [`decode`] would return, read from the file's header without decoding +/// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same +/// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an +/// EXIF orientation of 5–8, so wherever [`decode`] succeeds the two agree. Other formats (and +/// deep EXR or AVIF) return [`CodecError::Unsupported`]: decode those to learn their size. +pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let format = detect(bytes).ok_or(CodecError::UnknownFormat)?; + if !caps(format).read { + return Err(CodecError::unsupported(format, "decoding is not available for this format")); + } + let l = &Limits::default(); + let (w, h, exif) = match format { + Format::WebP => webp::probe(bytes, l)?, + Format::OpenExr => exr::probe(bytes, l).map(|(w, h)| (w, h, None))?, + Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::probe(format, bytes, l)?, + _ => return Err(CodecError::unsupported(format, "no header-only size probe")), + }; + let o = exif.as_deref().map_or(1, exif_orientation); + Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) +} + /// Returns the embedded colour table for an indexed BMP, if it has one. pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { via_image::bmp_palette(bytes) diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/orientation.rs index 9808330790..c1f176b87c 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/orientation.rs @@ -236,6 +236,7 @@ fn webp_opens_upright() { // WebP has no gray mode: it comes back as RGB. let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8); assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}"); + assert_eq!(probe_size(&out).unwrap(), (w as u32, h as u32), "the header probe turns upright too, o={o}"); } } @@ -554,3 +555,24 @@ fn long_orientation_reads_and_rewrites_in_both_byte_orders() { assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]); } } + +/// `probe_size` reads the size `decode` returns from the header alone, for every format it +/// covers, and a file cut short inside its header is refused rather than given a size. +#[test] +fn probe_size_matches_decode() { + let img = synth(7, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.0); + let probed = [Format::WebP, Format::Gif, Format::Bmp, Format::Tga, Format::Qoi, Format::Hdr, Format::Ico, Format::OpenExr]; + assert!(probed.iter().all(|f| rw_formats().contains(f))); + for format in rw_formats() { + let bytes = encode(&img, format, &EncodeOptions::default()).unwrap(); + let decoded = decode(&bytes).unwrap().dimensions(); + match probe_size(&bytes) { + Ok(size) => assert_eq!(size, decoded, "{format:?}"), + Err(_) => assert!(!probed.contains(&format), "{format:?} should be probed"), + } + if probed.contains(&format) { + assert!(probe_size(&bytes[..10]).is_err(), "{format:?} cut inside its header"); + } + } + assert!(probe_size(&[]).is_err()); +} diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index 6634735ef0..e200b875ea 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -136,9 +136,11 @@ pub(crate) fn source_size_in_blocks<'a>( } /// Pixel size of an embedded source file from its header: PSD/PSB, TIFF (the default page's -/// directory), PNG (`IHDR`) and JPEG (the frame header); any other image is decoded flat by -/// `photocraft-codecs`. It never imports a document: a layered TIFF can carry PSD layer data -/// whose `lnkD` holds another file, so a full import here would nest without bound (#2465). +/// directory), PNG (`IHDR`) and JPEG (the frame header); WebP, GIF, BMP, EXR and the rest of +/// `photocraft_codecs::probe_size`'s formats from their decoder's header; anything else (or a +/// header that probe cannot read) is decoded flat by `photocraft-codecs`. It never imports a +/// document: a layered TIFF can carry PSD layer data whose `lnkD` holds another file, so a full +/// import here would nest without bound (#2465). pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { let be32 = |at: usize| bytes.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let size = |w: u32, h: u32| (f64::from(w), f64::from(h)); @@ -151,10 +153,7 @@ pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { } [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => be32(16).zip(be32(20)).map(|(w, h)| size(w, h)), [0xFF, 0xD8, ..] => jpeg_size(bytes), - _ => photocraft_codecs::decode(bytes).ok().map(|img| { - let (w, h) = img.dimensions(); - size(w, h) - }), + _ => photocraft_codecs::probe_size(bytes).or_else(|_| photocraft_codecs::decode(bytes).map(|img| img.dimensions())).ok().map(|(w, h)| size(w, h)), } } @@ -408,9 +407,11 @@ mod tests { fn source_size_probe_reads_headers() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; let img = Image::new(7, 5, ChannelLayout::Rgb, SampleType::U8).unwrap(); - for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp] { + for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp, Format::WebP, Format::Gif, Format::OpenExr, Format::Tga] { let bytes = photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap(); assert_eq!(source_size_probe(&bytes), Some((7.0, 5.0)), "{format:?}"); + // A header cut short neither panics nor invents a size (the decode fallback fails too). + assert_eq!(source_size_probe(&bytes[..10]), None, "{format:?} truncated"); } let mut psd = b"8BPS\0\x01".to_vec(); psd.extend([0; 6]); From 2d5e60ad51f5edfc33201d1f50d89f02e0a58cd8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 18:46:51 +0200 Subject: [PATCH 023/199] Compose: build a layer's effect-map identity once per render, not once per tile (#4437) (#4438) effect_maps hashed the whole layer (Debug of effects and vector mask, a walk over every pixel and mask tile, every child of a group) on every call, i.e. once per render tile per effect layer. The identity depends on the layer only; memoise it in the render's Ctx for layers that are part of the document being rendered (their addresses cannot be reused during the render). Co-authored-by: Claude Opus 5.5 --- crates/compose/src/lib.rs | 58 ++++++++++++++++++++++++++++++- crates/compose/src/tests.rs | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8813b122d9..c53a224d63 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -326,6 +326,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { fx_maps: Default::default(), fx_clip: Default::default(), doc: None, + layer_keys: Default::default(), }, advanced::Scope::ROOT, ); @@ -523,6 +524,60 @@ struct Ctx<'a> { fx_clip: std::sync::Mutex>, /// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer. doc: Option<&'a Document>, + /// Effect-map identities of this render's document layers (see [`LayerKeys`]). + layer_keys: LayerKeys, +} + +/// Memo of [`layer_identity`] for the layers of the document being rendered. The identity depends +/// on the layer alone, not on the tile, so it is computed once per layer per render instead of +/// once per tile. Only layers that sit in the document tree are memoised by address: the document +/// is borrowed for the whole render, so no other object can live at such an address. Temporary +/// copies (`advanced::effects_source`) are never in the tree and always take the full path. +#[derive(Default)] +struct LayerKeys { + /// Addresses of every layer in the document tree, collected on first use. + in_doc: std::sync::OnceLock>, + ids: std::sync::Mutex>, + /// Identities computed (tests count them). + #[cfg(test)] + built: std::sync::atomic::AtomicUsize, +} + +impl LayerKeys { + fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 { + let addr = std::ptr::from_ref(layer) as usize; + let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr)); + if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) { + return *id; + } + let id = self.compute(layer); + if in_doc { + self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id); + } + id + } + + fn compute(&self, layer: &Layer) -> u64 { + use std::hash::Hasher; + #[cfg(test)] + self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut h = std::collections::hash_map::DefaultHasher::new(); + layer_identity(layer, &mut h); + h.finish() + } +} + +/// Addresses of all layers of `doc` (groups' children included). +fn doc_layer_addresses(doc: &Document) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + let mut todo: Vec<&Layer> = doc.layers.iter().collect(); + while let Some(l) = todo.pop() { + out.insert(std::ptr::from_ref(l) as usize); + if let LayerContent::Group(g) = &l.content { + todo.extend(g.children.iter()); + } + } + out } impl<'a> Ctx<'a> { @@ -538,6 +593,7 @@ impl<'a> Ctx<'a> { vector_masks: RenderVectorMasks::default(), fx_maps: Default::default(), fx_clip: Default::default(), + layer_keys: Default::default(), } } @@ -1603,7 +1659,7 @@ fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } +// ---------- Effect-map identity is computed once per layer per render (#4437) ---------- + +/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled +/// child, and a layer whose mask hides its effects (rendered from a temporary copy). +fn identity_doc(depth: SampleType) -> Document { + let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE); + let fmt = d.pixel_format(); + let mut sq = Layer::raster("sq", fmt); + sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]); + sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))]; + d.layers.push(sq); + let mut child = Layer::raster("child", fmt); + child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]); + child.effects.items = vec![stroke(2.0, StrokePosition::Outside)]; + let mut group = Layer::group("g", vec![child]); + group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))]; + d.layers.push(group); + let mut hidden = Layer::raster("hidden", fmt); + hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]); + hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)]; + hidden.mask = Some(LayerMask::reveal_all()); + hidden.advanced.layer_mask_hides_effects = true; + d.layers.push(hidden); + d +} + +#[test] +fn memoised_effect_identity_leaves_every_render_unchanged() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = identity_doc(depth); + let b = d.bounds(); + // One tile for the whole canvas never goes through `prepare_effects`; small tiles do. + purge_effect_cache(); + let whole = render_tiled(&d, b, 4096); + purge_effect_cache(); + let tiled = render_tiled(&d, b, 16); + let warm = render_tiled(&d, b, 24); + assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile"); + assert!(whole.px == warm.px, "{depth:?}: warm differs from cold"); + assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw"); + // Edits still invalidate: a changed layer must not keep its old maps. + let mut e = d.clone(); + e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))]; + let edited = render_tiled(&e, b, 16); + purge_effect_cache(); + assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit"); + assert!(edited.px != whole.px, "{depth:?}: the edit should show"); + } +} + +#[test] +fn effect_identity_is_built_once_per_layer_not_per_tile() { + let mut d = identity_doc(SampleType::U8); + // Leave out the temporary-copy layer: it is rebuilt per call by design. + d.layers.pop(); + let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&d, &patterns); + let b = d.bounds(); + let tiles = 6 * 6; + let first = render_tiled_with(&d, b, 16, &cx); + let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed); + // sq, the group and its child: three effect layers, however many tiles. + assert_eq!(built, 3, "identity built {built} times over {tiles} tiles"); + // A second render with the same context finds all of them memoised. + let second = render_tiled_with(&d, b, 16, &cx); + assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3); + assert!(first.px == second.px); +} + #[test] fn render_refuses_rects_beyond_the_pixel_cap() { let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); From b8ae1cd92fccacfc5b30566e64c22bdebe0a75eb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:47:23 +0700 Subject: [PATCH 024/199] fix(brush): preserve Dual Brush tip spacing and scale its size with the primary brush (#4856) * fix(abr): prefer dual brush tip spacing over wrapper spacing * test(abr): cover conflicting dual brush spacing descriptors * fix(paint): scale secondary brush tip when changing primary diameter * fix(engine): preserve dual tip proportions through brush commands * test(brush): cover secondary tip scaling across settings, strokes and presets * style: apply rustfmt to Dual Brush ABR code * style: apply rustfmt to Dual Brush ABR code --- crates/engine/src/brush_cmds.rs | 7 +++++- crates/engine/src/brush_cmds/tests.rs | 32 +++++++++++++++++++++++++++ crates/io/src/abr_map/tests.rs | 23 +++++++++++++++++++ crates/paint/src/brush.rs | 11 ++++++++- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 370b6fcc78..5c1942441a 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -143,6 +143,11 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result Result // A library preset's tips stay on disk until a stroke uses them (#1843). s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; if let Some(v) = num(p, "size") { - b.size = v.max(0.5); + b.resize_with_dual_brush(v.max(0.5)); } if let Some(v) = num(p, "hardness") { b.hardness = v.clamp(0.0, 1.0); diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index d91e9ec4d7..f1c8188246 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -349,6 +349,38 @@ fn set_brush_merges_fields() { assert_eq!(s.tools.brush.texture.pattern, chalk_pattern); } +#[test] +fn resizing_a_brush_resizes_its_enabled_secondary_tip() { + let mut s = session(64, 64); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 12}})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 24.0)); + + s.execute("tools.setBrush", json!({"brush": {"size": 20}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (20.0, 6.0)); + + // Editing both diameters in the same patch is an explicit secondary-tip size. + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"size": 18}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 18.0)); + s.execute("tools.setBrush", json!({"dualBrush": {"size": 9}})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 9.0); + + // The stroke-level size override must use the same proportional dimensions. + let for_stroke = resolve_brush(&s, &json!({"size": 80, "points": [[10, 10]]}), "paint.stroke").unwrap(); + assert_eq!((for_stroke.size, for_stroke.dual_brush.size), (80.0, 18.0)); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0)); +} + +#[test] +fn an_uncaptured_preset_size_keeps_dual_brush_proportions() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual", "captureSize": false})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + s.execute("tools.setBrush", json!({"preset": "Dual"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 20.0)); +} + #[test] fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() { let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0); diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 8bea763861..501e331af9 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -177,6 +177,29 @@ fn v6_settings_map_onto_brush_sections() { } } +#[test] +fn dual_brush_tip_spacing_takes_precedence_over_wrapper_spacing() { + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("Close-spaced dual brush")) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 40.0)))) + .with( + "dualBrush", + Value::Descriptor( + Descriptor::new("dualBrush") + .with("useDualBrush", Value::Boolean(true)) + .with("Spcn", prc(100.0)) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 12.0)).with("Spcn", prc(5.0)))), + ), + ); + let bytes = write_v6(2, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Dual").unwrap(); + assert_eq!(imported.presets.len(), 1); + let brush = &imported.presets[0].brush; + assert!(brush.dual_brush.enabled); + assert!((brush.dual_brush.spacing - 0.05).abs() < 1e-6, "the tip's 5% spacing must not be replaced with the wrapper's 100%"); + assert!((brush.spacing - 0.25).abs() < 1e-6, "main brush spacing is independent"); +} + #[test] fn missing_pattern_and_tip_are_reported() { let orphan = Descriptor::new("brushPreset").with("Nm ", t("Orphan")).with("Brsh", Value::Descriptor(sampled_tip("$nowhere"))); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 0dd4d21d65..354dd61d53 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -582,6 +582,15 @@ impl Default for BrushSettings { } impl BrushSettings { + /// Resize the primary tip and keep an enabled Dual Brush tip at the same relative size. + /// An explicit Dual Brush size edit should bypass this and retain the requested diameter. + pub fn resize_with_dual_brush(&mut self, new_size: f32) { + if self.dual_brush.enabled && self.size.is_finite() && self.size > 0.0 && new_size.is_finite() && new_size > 0.0 { + self.dual_brush.size = (self.dual_brush.size * (new_size / self.size)).clamp(0.5, MAX_BRUSH_SIZE); + } + self.size = new_size; + } + /// Copy the settings with the primary and dual diameters and Scatter amounts constrained for /// rasterization. /// @@ -714,7 +723,7 @@ impl BrushPreset { pub fn picked_over(&self, current: &BrushSettings) -> BrushSettings { let mut b = self.brush.clone().picked_over(current); if !self.captures.size { - b.size = current.size; + b.resize_with_dual_brush(current.size); } if !self.captures.tool_settings { b.mode = current.mode; From 72d43599998cc1017db891bd21b2ba4994ad653b Mon Sep 17 00:00:00 2001 From: Zach Schneider Date: Wed, 14 Oct 2026 12:05:26 -0500 Subject: [PATCH 025/199] Font preview test: skip installed fonts whose Arabic glyphs are blank (#5259) (#5261) a_system_arabic_face_renders_both_samples took the first family whose character map claims Latin and Arabic and expected its Arabic sample to render. A font can map those characters to empty glyphs (for example Black Mountain), so cargo test failed on machines that have one installed. The test now renders every candidate and checks the first one that actually draws an Arabic sample, and skips only if none does. --- crates/ui-egui/src/font_preview.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 74c334d682..678da4191c 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -244,14 +244,20 @@ mod tests { fn a_system_arabic_face_renders_both_samples() { let mut engine = photocraft_text::TextEngine::new(); engine.fonts.load_system_fonts(); - let both = engine.fonts.families().into_iter().find(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()); - let Some(family) = both else { - eprintln!("skipped: no installed family covers both Latin and Arabic"); + let candidates: Vec = engine.fonts.families().into_iter().filter(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()).collect(); + // A face can map the Arabic characters to blank glyphs (#5259): `render` then rightly gives + // no sample, so every candidate is rendered and the first one with an Arabic sample is checked. + let mut rendered = None; + for family in &candidates { + if let Some(arabic) = images(&mut engine, family, 1, MEDIUM).and_then(|i| i.arabic) { + rendered.get_or_insert((family.clone(), arabic)); + } + } + let Some((family, arabic)) = rendered else { + eprintln!("skipped: no installed family renders both Latin and Arabic ({} claim both)", candidates.len()); return; }; - let images = images(&mut engine, &family, 1, MEDIUM).expect(&family); - let arabic = images.arabic.expect("Arabic sample"); - assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize]); + assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize], "{family}"); assert!(arabic.pixels.iter().any(|p| p.a() > 0), "{family}"); } } From b37a148759d52542f448f43f2175e2f059c7df59 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:05:47 +0200 Subject: [PATCH 026/199] ui: remove the title-bar Discord button (#1737) Co-authored-by: highperfocused --- crates/ui-egui/src/panels.rs | 27 +++------------------------ crates/ui-egui/src/titlebar.rs | 17 +++++++++++++---- docs/ui-design.md | 2 +- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 733f923c8a..6d0b9b0037 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -646,9 +646,9 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("Pixel Art".to_string(), tl!("Pixel Art")), ("Motion".to_string(), tl!("Motion")), ]; - // A narrow bar drops what is also in a menu, Discord first (below), then - // the theme toggle (Preferences), then search (Edit › Search), and narrows - // the switcher (Window › Workspace) before anything runs over. + // A narrow bar drops what is also in a menu, the theme toggle first + // (Preferences), then search (Edit › Search), and narrows the switcher + // (Window › Workspace) before anything runs over. let room = ui.available_width(); let icons_shown: u8 = if room >= WORKSPACE_MIN + 2.0 * ICON_SLOT { 2 @@ -680,25 +680,6 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } button.context_menu(|ui| crate::prefs_ui::appearance_context_menu(app, ui)); } - // The community Discord, one click away while the bar has room for it - // (narrow windows drop it first; it is also Help › Discord). - if ui.available_width() >= DISCORD_ROOM { - let discord = egui::Button::image_and_text( - icons::image("message-square", 14.0, t.text_dim), - egui::RichText::new(tl!("Discord")).color(t.text_dim).size(12.0), - ) - .frame(false); - if ui - .add(discord) - .on_hover_text({ - let template = "Join the ArtCraft Discord ({url})"; - crate::i18n::fmt(tl!(template), &[("url", crate::links::DISCORD)]) - }) - .clicked() - { - crate::links::open(app, ui.ctx(), crate::links::DISCORD); - } - } if rtl { ui.min_rect().right() } else { ui.min_rect().left() } }; free = if rtl { (controls_end, menus_end) } else { (menus_end, controls_end) }; @@ -755,8 +736,6 @@ pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) { /// Minimum space kept between the window title and the menus or controls beside it. const TITLE_GAP: f32 = 16.0; -/// Free room the title bar needs after its other controls to show the Discord button. -const DISCORD_ROOM: f32 = 120.0; /// The workspace switcher's narrowest width, and one 28 pt title-bar icon with its spacing. const WORKSPACE_MIN: f32 = 90.0; const ICON_SLOT: f32 = 34.0; diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index c45baa2f0d..de10ca30f3 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -646,7 +646,7 @@ mod tests { for width in [760.0, 1100.0, 1440.0] { let mut w = Win::new(width); w.frame(vec![]); - let out = w.frame(vec![]); + w.frame(vec![]); let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}"); // The bar's bottom point is the panel's separator line, which the buttons leave visible. @@ -657,9 +657,18 @@ mod tests { for a in &widgets { assert!(a.right() <= min.left(), "{a:?} runs into the caption buttons at {width}"); } - // Wide windows show the whole group; the narrowest drops Discord (also Help › Discord). - let has_discord = texts(&out).iter().any(|(t, _)| t == "Discord"); - assert_eq!(has_discord, width > 1000.0, "Discord at {width}"); + } + } + + #[test] + fn the_title_bar_promotes_no_community_chat() { + // Issue #1726: only the always-visible title-bar button is removed. The start-screen + // and About buttons and Help › Discord remain available. + for width in [760.0, 1100.0, 1440.0] { + let mut w = Win::new(width); + w.frame(vec![]); + let out = w.frame(vec![]); + assert!(!texts(&out).iter().any(|(t, _)| t.contains("Discord")), "no Discord in the bar at {width}"); } } diff --git a/docs/ui-design.md b/docs/ui-design.md index 96a389d7f7..c6135ff744 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -97,7 +97,7 @@ The app's top bar (`panels::title_bar`) starts with the brand mark (the app icon - **Windows and Linux:** the window has no OS decorations, so there is one bar, not two stacked. The top bar is the title bar (`titlebar.rs`): Minimize, Maximize/Restore and Close (red on hover, `caption_close` tokens) sit flush in the window's top-right corner, the free gap between the menus and the controls drags the window and a double-click there maximizes it, and invisible 5 pt edges (12 pt corners) resize it. Close runs File › Exit, so unsaved documents are asked about first. - **macOS:** the traffic lights sit over the integrated title strip (`integrated_titlebar`). -- **Narrow windows** drop controls that are also in a menu before anything overlaps: Discord (Help › Discord), then the theme toggle, then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). +- **Narrow windows** drop controls that are also in a menu before anything overlaps: the theme toggle (Preferences), then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). ## Compact tool controls From be64d2dcbc338507a9fa0fcab3349273173e83cf Mon Sep 17 00:00:00 2001 From: mjohnson7cd <61306576+mjohnson7cd@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:05:53 -0400 Subject: [PATCH 027/199] fix(windows): handle GPU initialization failures gracefully (#5191) (#5309) On Windows with the automatic backend, GL adapters now rank after every other adapter, WARP included. VMware SVGA3D over GL and GL-only old GPUs drew a black window (#1788, #1647); WARP renders. --- apps/photocraft/src/gpu_startup.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 8632fd2a55..66acb9a42f 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -233,9 +233,10 @@ pub struct Candidate { /// Rank of an adapter (lower is better): high-performance device types first (egui's power /// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for /// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback -/// for presenting the window (see [`backends`]). +/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter, +/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788). fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { - let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan); + let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl)); let ty = match a.device_type { wgpu::DeviceType::DiscreteGpu => 0, wgpu::DeviceType::IntegratedGpu => 1, @@ -635,6 +636,20 @@ mod tests { assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1)); } + /// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12). + /// GL drew a black window; WARP works. GL stays the last resort. + #[test] + fn windows_auto_prefers_warp_over_gl() { + use wgpu::{Backend as B, DeviceType as T}; + let svga_gl = cand(0x15ad, T::Other, B::Gl); + let warp = cand(0x1414, T::Cpu, B::Dx12); + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0)); + // Elsewhere GL keeps its rank. + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0)); + } + #[test] fn adapters_must_present_even_when_none_support_the_surface() { let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12); From 08def9648ddfd4a27c6f05d0c66ec07b87373a51 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:05:59 +0700 Subject: [PATCH 028/199] fix(ui): make Preferences modal resizable on desktop (#5170) --- crates/ui-egui/src/dialogs.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0530031f93..b71443ec17 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -104,6 +104,10 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l))) } +fn resizable_dialog(fields: &serde_json::Map) -> bool { + fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields) +} + fn show_modal( modal: egui::Modal, ctx: &egui::Context, @@ -182,7 +186,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. let web = fields.contains_key("__web"); let web_popup_open = web && egui::Popup::is_any_open(ctx); - let modal = show_modal(modal, ctx, id, web, interactive, |ui| { + let modal = show_modal(modal, ctx, id, resizable_dialog(&fields), interactive, |ui| { sizing = ui.is_sizing_pass(); let available = ui.available_size(); // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title @@ -998,3 +1002,21 @@ mod tests { } } } + +#[cfg(test)] +mod preference_resize_tests { + use super::*; + + #[test] + fn preferences_can_resize_while_standard_native_dialogs_keep_their_layout() { + let prefs = json!({"__prefsui": "prefs"}); + let shortcuts = json!({"__prefsui": "shortcuts"}); + let ordinary = json!({}); + let web = json!({"__web": true}); + + assert!(resizable_dialog(prefs.as_object().unwrap())); + assert!(!resizable_dialog(shortcuts.as_object().unwrap())); + assert!(!resizable_dialog(ordinary.as_object().unwrap())); + assert!(resizable_dialog(web.as_object().unwrap())); + } +} From fe8fa3b9bc1627ab818284971b0c0348e9b72466 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Wed, 14 Oct 2026 19:06:05 +0200 Subject: [PATCH 029/199] Color Range: sample colours outside the active selection (#5244) (#5296) The eyedroppers (the points/subtractPoints params, and clicks on the canvas while the dialog is open) refused points outside the active selection. Photoshop samples anywhere and only limits the result to the selection, which Color Range already does; the refusal is gone. --- crates/engine/src/selection_cmds.rs | 46 ++++++++++++---------------- crates/ui-egui/src/color_range_ui.rs | 14 +++------ 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index eda55c7d73..d6fc50aeea 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -30,10 +30,6 @@ fn mode(p: &Value) -> SelectionMode { /// A Color Range replacement is scoped to the selection that was active when it opened. /// Explicit add/subtract/intersect operations retain their normal selection-combine semantics. -fn point_in_selection(doc: &Document, x: i32, y: i32) -> bool { - doc.selection.as_ref().is_none_or(|selection| selection.sample_channel(x, y, 0) > 0.0) -} - fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionMode) { if m != SelectionMode::Replace { return; @@ -153,14 +149,7 @@ fn parse_color(p: &Value) -> [f32; 3] { /// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point /// and where it was picked (area-local, for Localized Color Clusters). -fn eyedropper_samples( - p: &Value, - key: &str, - area: Rect, - px: &[[f32; 4]], - allowed: &impl Fn(i32, i32) -> bool, - bad: &impl Fn(String) -> EngineError, -) -> Result> { +fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; let w = area.width() as usize; @@ -178,9 +167,6 @@ fn eyedropper_samples( let Some(&[r, g, b, _]) = q else { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); }; - if !allowed(xi, yi) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); } Ok(out) @@ -251,9 +237,6 @@ impl ColorRangeSamples { if !area.contains(x, y) { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); } - if mode(p) == SelectionMode::Replace && !point_in_selection(doc, x, y) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } let r = Rect::from_xywh(x, y, 1, 1); let pixel = if !b(p, "sampleAllLayers", true) { s.active().and_then(|d| d.active_layer.and_then(|id| d.doc.layer(id))).and_then(|l| l.surface()).map(|surf| { @@ -377,10 +360,10 @@ fn color_range(s: &mut Session, p: &Value) -> Result { let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - let allowed = |x, y| selection_mode != SelectionMode::Replace || point_in_selection(doc, x, y); - let plus = eyedropper_samples(p, "points", area, &px, &allowed, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &allowed, &bad)?; + // Like Photoshop's, they may sample anywhere on the canvas: an active selection only + // limits the result (scope_color_range), not where colours are picked (#5244). + let plus = eyedropper_samples(p, "points", area, &px, &bad)?; + let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); (area, mask) @@ -868,15 +851,24 @@ mod tests { assert_eq!(coverage(&s, 30, 10), 0.0); } + #[test] + fn color_range_eyedropper_samples_anywhere_but_the_result_stays_in_the_selection() { + // #5244: as in Photoshop, a colour picked outside the active selection is accepted; only + // the result is limited to the selection. + let mut s = session(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + let outside = json!({"points": [[30, 10]], "fuzziness": 0}); + assert!(ColorRangeSamples::new(&s, &outside).is_ok()); + s.execute("select.colorRange", outside).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0, "the red inside the selection is selected"); + assert_eq!(coverage(&s, 30, 10), 0.0, "the sampled red outside it stays out"); + assert_eq!(coverage(&s, 2, 2), 0.0, "white is not the sampled colour"); + } + #[test] fn color_range_eyedropper_respects_selection_but_add_can_extend_it() { let mut s = session(); s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); - let before = current_mask(&s.active().unwrap().doc).1; - let outside = json!({"points": [[30, 10]], "fuzziness": 0}); - assert!(s.execute("select.colorRange", outside.clone()).unwrap_err().to_string().contains("outside the active selection")); - assert!(ColorRangeSamples::new(&s, &outside).err().unwrap().to_string().contains("outside the active selection")); - assert_eq!(current_mask(&s.active().unwrap().doc).1, before); s.execute("select.colorRange", json!({"points": [[10, 10]], "fuzziness": 0})).unwrap(); assert_eq!(coverage(&s, 10, 10), 1.0); assert_eq!(coverage(&s, 30, 10), 0.0); diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index 80417eeec8..59b3bfeb2f 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -649,9 +649,6 @@ pub fn pick_top(app: &mut PhotocraftApp, at: [f64; 2], mods: egui::Modifiers) -> if !at[0].is_finite() || !at[1].is_finite() || at[0] < 0.0 || at[1] < 0.0 || at[0] >= f64::from(doc.size.width) || at[1] >= f64::from(doc.size.height) { return false; } - if doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0].floor() as i32, at[1].floor() as i32, 0) <= 0.0) { - return false; - } pick(app, &mut f, at, mods); match app.ui.dialogs.last_mut() { Some(d) => { @@ -708,9 +705,6 @@ pub fn pick(app: &PhotocraftApp, f: &mut Map, at: [f64; 2], mods: let (w, h) = (f64::from(st.doc.size.width.max(1)), f64::from(st.doc.size.height.max(1))); let [x, y] = at; let at = [x.floor().clamp(0.0, w - 1.0), y.floor().clamp(0.0, h - 1.0)]; - if st.doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0] as i32, at[1] as i32, 0) <= 0.0) { - return; - } let tool = if mods.shift { "add" } else if mods.alt { @@ -831,10 +825,12 @@ mod tests { assert_eq!(mask[5 * 40 + 25], 0.0, "even inverted previews stay within the selection"); } + // As in Photoshop, the eyedropper samples anywhere; only the result is scoped (#5244). open(&mut app); - assert!(!pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); - let fields = &app.ui.dialogs.last().unwrap().fields; - assert!(points(fields, "points").is_empty(), "a click outside the selection is ignored"); + assert!(pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); + assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[25.0, 5.0]], "a colour outside the selection is sampled"); + let mask = proxy_mask(&app, &doc, 1, &json!({"select": "sampledColors", "points": [[25, 5]], "fuzziness": 0})).unwrap(); + assert_eq!(mask[5 * 40 + 25], 0.0, "but the preview stays within the selection"); assert!(pick_top(&mut app, [5.0, 5.0], egui::Modifiers::NONE)); assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[5.0, 5.0]]); } From 08d9c1d67dbb49283b94ffe81c2a4dbb824bdce1 Mon Sep 17 00:00:00 2001 From: mjohnson7cd <61306576+mjohnson7cd@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:06:12 -0400 Subject: [PATCH 030/199] fix(transform): keep masked objects visible during transform (#5239) (#5319) --- crates/ui-egui/src/transform_tool.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 81fc0fadcf..1cd81f3a3f 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -776,6 +776,9 @@ fn preview_image( for l in solo.layers.iter_mut() { if l.id != id && doc.layer(id).is_some_and(|_| !contains(l, id)) { l.visible = false; + } else { + // Its clipping base is hidden here, which would clip it away entirely (#5239). + l.clipped = false; } } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { @@ -4794,6 +4797,27 @@ mod tests { } } + /// #5239: a smart object clipped to the layer under it previewed as nothing while the box + /// was open (its flattened render hid the clipping base too). + #[test] + fn preview_of_a_clipped_smart_object_is_not_empty() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.session.execute("layer.createClippingMask", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + assert!(st.doc.layer(id).unwrap().clipped); + let (img, _) = preview_image(&st.doc, id, None, photocraft_geom::Rect::new(0, 0, 64, 64), 4096, false); + assert_eq!(texel_alpha(&img, 32, 32), 255, "the moving object shows"); + } + #[test] fn targeted_unlinked_mask_transforms_alone_through_the_box() { let mut app = masked_app(false); From 42c5cc64be03784ea47bd53ec3adb7eeba6d8f6f Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:06:18 -0400 Subject: [PATCH 031/199] Canvas: themes without a dot pattern draw no dots texture (#4542) (#5093) * Canvas: themes without a dot pattern draw no dots texture (#4542) Switching the dark theme to Pro filled the empty workspace with red and black blocks on the reporter's machine. The blocks repeat every 22 px, the tile of the canvas-surround dots texture, so it is that texture showing wrong contents. Pro and ten other themes set canvas_dot to their canvas colour: they have no dot pattern, yet still uploaded a texture for it and tiled it over the whole pasteboard. dots() now returns None for them, so nothing is uploaded or drawn. Studio, Studio Light and Solarized Dark, whose dots differ from the canvas, are unchanged. Co-Authored-By: Claude Opus 5.5 * Contributors: credit Higgs32584 by GitHub profile name Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 676d068070..11adb2c79a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2575,9 +2575,10 @@ pub fn fmt_zoom(pct: f32) -> String { if (pct - pct.round()).abs() < 0.05 { format!("{}", pct.round() as i64) } else { format!("{pct:.1}") } } -/// Repeating dot-grid texture for the canvas surround. +/// Repeating dot-grid texture for the canvas surround. A theme whose dots are its canvas colour +/// has no pattern to show, so it gets no texture at all rather than an invisible one (#4542). fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option { - if t.bevel { + if t.bevel || t.canvas_dot == t.canvas { return None; } let key = egui::Id::new(("canvas-dots", format!("{:?}", t.kind))); @@ -6003,6 +6004,32 @@ mod tests { assert_ne!(ensure_texture(&mut app, &ctx, 1, Some(1)).unwrap().0, a, "reopening a preserved ID creates a fresh texture"); } + #[test] + fn only_themes_with_a_dot_pattern_tile_the_canvas_surround() { + // #4542: switching to Pro filled the empty workspace with red blocks on one GPU. Pro's + // dots are its canvas colour, so the tiled texture had nothing to show in the first place. + use crate::theme::ThemeKind; + let tiles = |ctx: &egui::Context, kind: ThemeKind| { + crate::theme::apply(ctx, kind); + let mut output = ctx.run_ui(Default::default(), |ui| paint_dots(ui, ui.max_rect(), true)); + output.textures_delta.clear(); + output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))) + }; + let uploaded = |ctx: &egui::Context| ctx.tex_manager().read().allocated().filter(|(_, meta)| meta.name == "canvas-dots").count(); + for kind in ThemeKind::ALL { + let ctx = egui::Context::default(); + let dotted = matches!(kind, ThemeKind::Studio | ThemeKind::StudioLight | ThemeKind::SolarizedDark); + assert_eq!(tiles(&ctx, kind), dotted, "{kind:?}"); + assert_eq!(uploaded(&ctx), usize::from(dotted), "{kind:?}"); + } + // The reported switch: a dotted theme, then Pro. + let ctx = egui::Context::default(); + assert!(tiles(&ctx, ThemeKind::Studio)); + assert!(!tiles(&ctx, ThemeKind::Pro)); + assert!(!tiles(&ctx, ThemeKind::ProMedium)); + assert_eq!(uploaded(&ctx), 1, "only Studio's dots were uploaded"); + } + #[test] fn closed_documents_release_cpu_canvas_textures() { let mut app = app_on_two_displays(); From df10129f6c218e0ca6d1e3205271a1a0092ca57e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=C2=B7=20VanTan?= <77483789+VanTan666@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:06:25 +0200 Subject: [PATCH 032/199] fix(ui): fit Curves dialog width to its graph (#5160) --- crates/ui-egui/src/adjust_dialog.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 6 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 5f43348507..eb8f89a685 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -451,6 +451,30 @@ mod tests { } } + /// #5113: the dialog should fit its curve graph instead of reserving a blank right column. + #[test] + fn curves_dialog_fits_its_graph_and_controls() { + for language in ["en", "ru"] { + let _language = crate::i18n::language_scope(crate::i18n::Lang::from_code(language).unwrap()); + for theme in crate::theme::ThemeKind::ALL { + for size in [egui::vec2(1280.0, 900.0), egui::vec2(900.0, 720.0)] { + let mut h = Harness::builder().with_size(size).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); + PhotocraftApp::setup_context(&h.ctx, theme); + let id = open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap(); + let graph = h.ctx.data(|d| d.get_temp::(dialog_mem("curves").with("curves-graph"))).unwrap(); + assert!(window.right() - graph.right() < 40.0, "blank right column: window {window:?}, graph {graph:?}"); + assert!(graph.width() > 270.0, "keep the graph usable: {graph:?}"); + assert!(window.contains_rect(graph)); + for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] { + assert!(window.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window"); + } + } + } + } + } + #[test] fn cancel_leaves_the_document_untouched() { let mut harness = diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b71443ec17..2930889bab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -192,8 +192,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title // rule and the button row fill the width they are given, so it needs its own maximum too. let compact = d.fields.contains_key("__compact"); + // The curve editor caps its graph allocation at 300 points (#5113). + let curves = fields.get("__adjust").and_then(Value::as_str) == Some("curves"); if !compact { - ui.set_min_width(380.0); + ui.set_min_width(if curves { 300.0 } else { 380.0 }); } let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); if let Some(w) = wide { @@ -215,6 +217,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { 600.0 } else if compact { 260.0 + } else if curves { + 300.0 } else { 440.0 })); From a904bd7a390fa50eaa8c9aac3a4eca4f18952e5d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:06:32 +0200 Subject: [PATCH 033/199] Frame tool: take type, shape and smart object layers into the frame (#4435) (#4441) --- crates/engine/src/frame_cmds.rs | 35 ++++++++++++++++++++++++++------ crates/ui-egui/src/frame_tool.rs | 9 ++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index c97006e746..7a401594fd 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -3,7 +3,7 @@ //! primitives: group the selected layers, then clip the group with a rectangular layer mask the //! size of their bounds — the same visual result Photoshop's Frame tool produces for this command. //! `layer.new.frame` is the Frame tool's (K) command: a rectangular or elliptical frame drawn on -//! the canvas, empty or holding the pixel layer it was drawn over. Headless and scriptable like +//! the canvas, empty or holding the layer it was drawn over. Headless and scriptable like //! every other command. use photocraft_doc::{Layer, LayerContent, LayerId, LayerMask, PixelFormat}; @@ -107,8 +107,9 @@ fn ellipse_mask(rect: Rect, area: Rect) -> LayerMask { } /// Frame tool (K): a new frame — a group clipped to the drawn rectangle or ellipse — as one -/// history step. With `layer` (a pixel layer, not the Background), that layer becomes the frame's -/// content, as when a frame is drawn over a selected pixel layer in Photoshop; else it is empty. +/// history step. With `layer` (a pixel, type, shape or smart object layer, not the Background), +/// that layer becomes the frame's content, as when a frame is drawn over a selected layer in +/// Photoshop; else it is empty. fn new_frame(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.new.frame"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; @@ -137,8 +138,9 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { let gid = match content { Some(id) => { let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) { - return Err(bad("only a pixel layer (not the Background) can go in a frame".into())); + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || doc.is_background(id) { + return Err(bad("only a pixel, type, shape or smart object layer (not the Background) can go in a frame".into())); } let gid = doc.insert_above(Some(id), Layer::group(name, vec![])); let child = doc.remove(id).ok_or(EngineError::NoLayer(id))?; @@ -163,7 +165,7 @@ pub fn specs() -> Vec { label: "New Frame", menu: &[], shortcut: None, - params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel layer `layer` if given"#, + params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel, type, shape or smart object layer `layer` if given"#, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), journal: true, run: new_frame, @@ -274,6 +276,27 @@ mod tests { } } + /// #4435: a type layer (and the other rendered layer kinds) goes into a frame like a pixel layer. + #[test] + fn new_frame_takes_a_type_layer() { + let (mut s, a, _) = session(); + let tid = s + .edit("setup", |doc, _| { + let cache = doc.layer(a).and_then(|l| l.surface()).cloned(); + let t = Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() })); + let id = t.id; + doc.layers.push(t); + Ok(id) + }) + .unwrap(); + let r = s.execute("layer.new.frame", json!({"x": 0, "y": 0, "width": 40, "height": 30, "layer": tid.0})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + let g = s.active().unwrap().doc.layer(gid).unwrap(); + assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([tid])), "the frame holds the type layer"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(tid).is_some(), "undo gives the type layer back"); + } + #[test] fn needs_a_selected_layer() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/frame_tool.rs b/crates/ui-egui/src/frame_tool.rs index 28eb556282..4a1210b874 100644 --- a/crates/ui-egui/src/frame_tool.rs +++ b/crates/ui-egui/src/frame_tool.rs @@ -1,6 +1,6 @@ //! Frame tool (K): drag on the canvas to draw a rectangular or elliptical frame (the options bar //! picks the shape). ⇧ makes it a square or circle, ⌥ draws it from the centre. Drawn over the -//! selected pixel layer's content, the frame takes that layer in, as in Photoshop; else it is an +//! selected layer's content, the frame takes that layer in, as in Photoshop; else it is an //! empty placeholder. Each frame is one engine command (`layer.new.frame`), so one undo step; this //! module only turns the drag into it. @@ -48,12 +48,13 @@ fn drawn(d: &FrameDrag) -> [i64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| v.round() as i64) } -/// The selected pixel layer the frame `r` takes in: one whose content it overlaps and that can -/// move (not the Background or a position-locked layer). +/// The selected pixel, type, shape or smart object layer the frame `r` takes in: one whose content +/// it overlaps and that can move (not the Background or a position-locked layer). fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { let st = app.session.active()?; let l = st.doc.layer(st.active_layer?)?; - if !matches!(l.content, LayerContent::Raster(_)) || l.locks.position || l.locks.all { + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || l.locks.position || l.locks.all { return None; } let b = l.surface()?.content_bounds(); From 8428645311b176c89b95c727de3b6cd3d45597d2 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Wed, 14 Oct 2026 18:06:39 +0100 Subject: [PATCH 034/199] perf(gpu): keep the DX12 mip scratch textures for Intel adapters only (#5354) The separate mip render targets work around reduced-preview corruption on Intel DX12 drivers (#892, verified on Intel UHD 620), but every DX12 adapter took them. Creating a scratch texture per level on every refresh costs about 1 ms of CPU time per brush dab on an NVIDIA DX12 adapter. Gate them to Intel under DX12; PHOTOCRAFT_SEPARATE_MIPS=1/0 forces either way. Part of #4515. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/gpu_canvas.rs | 41 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 3862aec534..ce45364c4c 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -112,6 +112,21 @@ pub struct GpuCanvas { health: photocraft_gpu::DeviceHealth, } +/// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on +/// Intel adapters under DX12 only, where some drivers corrupt reduced previews when a pass +/// samples one mip level and renders into another of the same texture (#892, Intel UHD 620). +/// Everywhere else it renders straight into the level: on an NVIDIA DX12 adapter the scratch +/// textures, created per level on every refresh, cost about 1 ms of CPU time per brush dab +/// (#4515). `PHOTOCRAFT_SEPARATE_MIPS` (`setting`) forces it on (`1`) or off (`0`). +pub fn separate_mip_targets(backend: wgpu::Backend, vendor: u32, setting: Option<&str>) -> bool { + const INTEL: u32 = 0x8086; + match setting.map(str::trim) { + Some("1") => true, + Some("0") => false, + _ => backend == wgpu::Backend::Dx12 && vendor == INTEL, + } +} + /// Can `adapter` use `Rgba16Float` as a sampled, filtered, render-target and copy texture? /// (Core WebGPU guarantees it; WebGL2 needs `EXT_color_buffer_float`, and some drivers lack it.) pub fn supports_f16_canvas(adapter: &wgpu::Adapter) -> bool { @@ -141,7 +156,8 @@ impl GpuCanvas { let health = photocraft_gpu::DeviceHealth::watch(&rs.device); let mut res = Resources::new(&rs.device, &rs.queue, rs.target_format, high != HighPolicy::Off); res.health = health.clone(); - res.separate_mip_targets = rs.adapter.get_info().backend == wgpu::Backend::Dx12; + let info = rs.adapter.get_info(); + res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref()); rs.renderer.write().callback_resources.insert(res); log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format); Self { rs: rs.clone(), tile, high, health } @@ -1283,7 +1299,7 @@ struct Resources { views: HashMap, out_linear: bool, /// Build each mip level in a scratch texture and copy it back instead of rendering into - /// the level directly (DX12 only: works around reduced-preview corruption on Intel drivers). + /// the level directly (see [`separate_mip_targets`]). separate_mip_targets: bool, /// The wgpu layer compositor (created on first use). compositor: Option, @@ -1692,8 +1708,8 @@ impl DocTextures { wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.mip_sampler) }, ], }); - // Keep the sampled source and render attachment in separate resources on DX12 - // (`Resources::separate_mip_targets`): some Intel DX12 drivers corrupt reduced + // Keep the sampled source and render attachment in separate resources on Intel + // DX12 adapters ([`separate_mip_targets`]): some of their drivers corrupt reduced // previews when both are mip levels of the same texture, even though the // subresources do not overlap. Elsewhere render straight into the level, which // spares a scratch texture and a copy per level on every edit. @@ -2199,6 +2215,23 @@ mod mip_tests; mod tests { use super::*; + /// The scratch-texture workaround is for Intel's DX12 drivers (#892); other DX12 adapters + /// render straight into the level like every other backend (#4515). + #[test] + fn separate_mip_targets_only_for_intel_on_dx12() { + use wgpu::Backend::{Dx12, Metal, Vulkan}; + let (intel, nvidia, amd) = (0x8086, 0x10de, 0x1002); + assert!(separate_mip_targets(Dx12, intel, None)); + assert!(!separate_mip_targets(Dx12, nvidia, None)); + assert!(!separate_mip_targets(Dx12, amd, None)); + assert!(!separate_mip_targets(Vulkan, intel, None)); + assert!(!separate_mip_targets(Metal, 0, None)); + // PHOTOCRAFT_SEPARATE_MIPS forces either way; anything else leaves the default. + assert!(separate_mip_targets(Dx12, nvidia, Some("1"))); + assert!(!separate_mip_targets(Dx12, intel, Some(" 0 "))); + assert!(separate_mip_targets(Dx12, intel, Some("yes"))); + } + #[test] fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; From 89a763a368979aaf2b0894748b5178ae6680d030 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:06:47 -0400 Subject: [PATCH 035/199] Display profile notice: one card for all displays without a usable profile (#5312) Two monitors without ICC profiles (a common Linux desk, e.g. DP-4 and DP-5) showed two identical "Display profile not used" cards at every launch. A reading now posts one notice: one reason line per display, then the advice line naming them all. Displays added by a later reading still get a card of their own, and a reading with nothing new stays quiet, as before. Claude-Session: https://claude.ai/code/session_01SvxNXnS4tcg6BHT9VG67t7 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/monitor_status.rs | 52 +++++++++++++++++++++------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/monitor_status.rs b/crates/ui-egui/src/monitor_status.rs index 8e29b8c2f2..2350e6dc30 100644 --- a/crates/ui-egui/src/monitor_status.rs +++ b/crates/ui-egui/src/monitor_status.rs @@ -218,19 +218,24 @@ pub fn apply(app: &mut PhotocraftApp, r: Detection) { }) .collect() }; - for (id, name, reason) in fallbacks { - if app.monitors.reported.iter().any(|(i, r)| *i == id && *r == reason) { - continue; - } - app.monitors.reported.push((id, reason.clone())); - let template = if name.is_empty() { - "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - } else { - "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - }; - let lines = vec![reason, crate::i18n::fmt(tl!(template), &[("display", &name)])]; - crate::notices::post(app, FALLBACK_TITLE, lines, true, None); + let fresh: Vec<_> = fallbacks.into_iter().filter(|(id, _, reason)| !app.monitors.reported.iter().any(|(i, r)| i == id && r == reason)).collect(); + if fresh.is_empty() { + return; } + app.monitors.reported.extend(fresh.iter().map(|(id, _, reason)| (*id, reason.clone()))); + // One notice per reading, not one per display: two profile-less monitors (common on Linux + // desks) gave two cards saying the same thing at every launch. Each display keeps its own + // reason line; the names share the existing template, so no catalog change is needed. + let names: Vec<&str> = fresh.iter().map(|(_, name, _)| name.as_str()).filter(|n| !n.is_empty()).collect(); + let template = if names.is_empty() { + "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + } else { + "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + }; + let advice = crate::i18n::fmt(tl!(template), &[("display", &names.join(", "))]); + let mut lines: Vec = fresh.into_iter().map(|(_, _, reason)| reason).collect(); + lines.push(advice); + crate::notices::post(app, FALLBACK_TITLE, lines, true, None); } /// The frame of the window `ctx` draws, in OS points (as `Display::frame`). egui reports it in @@ -427,6 +432,29 @@ mod tests { assert!(lines[0].starts_with("Monitor profile on Built-in Retina Display: sRGB"), "{lines:?}"); } + #[test] + fn displays_without_profiles_share_one_notice() { + // Two profile-less monitors (a Linux desk: DP-4 and DP-5) gave two identical cards. + let mut app = app(); + let desk = |id, name: &str, x| Display { id, name: name.into(), frame: [x, 0.0, 1920.0, 1080.0], profile_name: None, icc: None }; + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + let lines = &app.ui.notices[0].lines; + assert_eq!(lines[0], "DP-4 has no ICC profile"); + assert_eq!(lines[1], "DP-5 has no ICC profile"); + assert!(lines[2].starts_with("The canvas on DP-4, DP-5 is shown as sRGB."), "{lines:?}"); + // The same reading again: nothing new to say. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + // A third display plugged in later: a card for it alone. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0), desk(3, "HDMI-A-1", 3840.0)])); + assert_eq!(app.ui.notices.len(), 2); + let lines = &app.ui.notices[1].lines; + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "HDMI-A-1 has no ICC profile"); + assert!(lines[1].starts_with("The canvas on HDMI-A-1 is shown"), "{lines:?}"); + } + #[test] fn displays_are_read_again_when_they_may_have_changed() { let mut app = app(); From 0f3d2f133e4fe5ae9c53b7705242cd9058612548 Mon Sep 17 00:00:00 2001 From: prekabreki <133610573+prekabreki@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:07:08 +0200 Subject: [PATCH 036/199] Type: hovering a font previews it with the Character section shown too (#5152) (#5222) The options bar and the Properties panel's Character section both draw the "type-font" family picker, and the Character one is drawn later. With its menu closed it removed the shared hover key every frame, so the options bar's hover never reached the canvas. Only the picker whose menu is open now clears the key; follow_hover already drops it each frame once nothing writes it. Test: options_bar_hover_previews_with_the_character_picker_also_shown runs the whole app (Pro theme, both pickers on screen), hovers a font in the options bar and checks the canvas shows it, then that leaving the row restores it. It fails without the fix. Co-authored-by: prekabreki Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/type_font_picker_tests.rs | 40 ++++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 4 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index f17032523d..176e683a9d 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -490,3 +490,43 @@ fn wheel_family_index_clamps_to_the_ends() { assert_eq!(super::wheel_family_index(1, 3, 0.0), None); assert_eq!(super::wheel_family_index(0, 0, -1.0), None); } + +/// #5152: in the full app the Properties panel's Character section draws a second font picker +/// after the options bar; hovering a font in the options bar still previews it on the canvas. +#[test] +fn options_bar_hover_previews_with_the_character_picker_also_shown() { + use crate::{PhotocraftApp, Services}; + use photocraft_doc::LayerContent; + use serde_json::json; + + let theme = crate::theme::ThemeKind::Pro; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.ui.theme = theme; + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app.ui.tool = crate::state::Tool::Type; + app.sync_views(); + app + }); + h.run_steps(8); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let font = |h: &mut Harness<'static, PhotocraftApp>| { + let (shown, _) = crate::canvas::display_doc(h.state_mut(), 0); + let LayerContent::Text(t) = &shown.layer(layer).unwrap().content else { panic!("type layer") }; + t.char_runs()[0].style.font_family.clone() + }; + let pickers: Vec<_> = h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Inter")).collect(); + assert_eq!(pickers.len(), 2, "options bar and Character section both show the font picker"); + pickers[0].click(); + h.run(); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").type_text("JetBrains Mono"); + h.run(); + h.get_by_label("JetBrains Mono").hover(); + h.run(); + assert_eq!(font(&mut h), "JetBrains Mono"); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").hover(); + h.run(); + assert_eq!(font(&mut h), "Inter", "leaving the row restores the committed font"); +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f00fb57157..bee49293b5 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1553,7 +1553,9 @@ pub(crate) fn family_picker_in( let hover_id = egui::Id::new("type-font-hover"); if let Some(f) = hovered_font { ui.ctx().data_mut(|d| d.insert_temp(hover_id, f)); - } else { + } else if egui::ComboBox::is_open(ui.ctx(), response.response.id) { + // Only the open menu clears it: the options bar and the Character section both draw + // this picker, and the closed one must not wipe the other's hover (#5152). ui.ctx().data_mut(|d| d.remove::(hover_id)); } } From 44bb43e94dc12bfaa42d2e50ba41674668f73fbb Mon Sep 17 00:00:00 2001 From: prekabreki <133610573+prekabreki@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:07:15 +0200 Subject: [PATCH 037/199] Number fields: centre the number in its box (#5150) (#5226) #1322 lifted the number's slot 1 pt above the box's centre because digits have no descenders. Measured on today's mono font at 4x, the digits' ink already sits within 0.2 pt of centre without the lift; with it the ink is 0.9 pt high, the focus outline has 1 pt of gap above and 3 pt below, and the digits start 1 pt above the unit. The slot is centred again. Tests: value_field_digits_ink_is_centred_and_level_with_the_unit renders the field (Pro and Studio) and checks the digits' ink is centred and starts level with the "%"; value_field_number_slot_is_centred_in_its_box replaces the 1 pt-lift test, and the #2823 slim-box test now expects centring. Both new assertions fail with the lift restored. Co-authored-by: prekabreki Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 52 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..1b19e1ccc7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -519,12 +519,13 @@ fn value_field_box( // 6 pt from the right edge), so a one-character unit like `%` leaves the number room (#3040). let suffix_font = theme::mono(11.0); let suffix_w = if suffix.is_empty() { 0.0 } else { ui.painter().layout_no_wrap(suffix.to_owned(), suffix_font.clone(), t.text_faint).size().x + 3.0 }; - // 1 pt higher than centred: digits have no descenders, so centred text looks low. The slot - // is at least one text row tall, so a slim box's number isn't pushed out of it (#2823). + // Centred: the digits' ink then sits within 0.2 pt of the box's centre, where a 1 pt lift + // (#1322) put it 0.9 pt high and the focus outline off-centre (#5150). The slot is at least + // one text row tall, so a slim box's number isn't pushed out of it (#2823). let font = theme::mono(12.0); let row_h = ui.painter().layout_no_wrap("0".into(), font.clone(), t.text).size().y; let slot_h = (rect.height() - 4.0).max(row_h).min(rect.height() - 2.0); - let cy = rect.center().y - 1.0; + let cy = rect.center().y; let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. let fine = range.end() - range.start() <= 10.0; @@ -1842,9 +1843,9 @@ mod tests { h } - /// Digits have no descenders, so the number sits 1 pt above the box's centre to look centred. + /// The number's slot (and its focus outline) is centred in the box (#5150). #[test] - fn value_field_digits_sit_one_point_above_centre() { + fn value_field_number_slot_is_centred_in_its_box() { let mut h = Harness::new_ui_state( |ui, s: &mut (f32, egui::Rect)| { s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0, super::FieldOpts::default()).1; @@ -1854,11 +1855,46 @@ mod tests { h.run(); let number = h.get_by_role(egui::accesskit::Role::SpinButton).rect(); let field = h.state().1; - assert!((field.center().y - number.center().y - 1.0).abs() < 0.01, "field {field:?}, number {number:?}"); + assert!((field.center().y - number.center().y).abs() < 0.01, "field {field:?}, number {number:?}"); + } + + /// The digits' ink, not just their slot, is centred in the box, and starts level with the + /// `%`'s, so the number lines up with the box and its unit (#5150). + #[test] + fn value_field_digits_ink_is_centred_and_level_with_the_unit() { + let scale = 4.0; + for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut h = Harness::builder().with_size(egui::vec2(100.0, 40.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, s: &mut (f32, egui::Rect)| { + s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "%", 80.0, 0.0, super::FieldOpts::default()).1; + }, + (100.0, egui::Rect::NOTHING), + ); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let image = h.render().expect("render value field"); + let (field, suffix_left) = (h.state().1, h.state().1.right() - 14.0); + let px = |v: f32| (v * scale) as u32; + let bg = image.get_pixel(px(field.left() + 2.0), px(field.center().y)).0; + let lum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + // Top and bottom of the ink (pt) between x0 and x1. + let ink = |x0: f32, x1: f32| { + let rows: Vec = (px(field.top() + 2.0)..px(field.bottom() - 2.0)) + .filter(|&y| (px(x0)..px(x1)).any(|x| lum(image.get_pixel(x, y).0) > lum(bg) + 150)) + .collect(); + let (top, bottom) = (rows.first().copied().unwrap_or(0), rows.last().copied().unwrap_or(0) + 1); + (top as f32 / scale, bottom as f32 / scale) + }; + let digits = ink(field.left() + 4.0, suffix_left - 2.0); + let unit = ink(suffix_left, field.right() - 2.0); + let what = format!("{kind:?}: field {field:?}, digits {digits:?}, unit {unit:?}"); + assert!(((digits.0 + digits.1) / 2.0 - field.center().y).abs() <= 0.25, "{what}"); + assert!((digits.0 - unit.0).abs() <= 0.25, "{what}"); + } } /// With the app's theme (its 24 pt interact height and 4 pt button padding), in a full-size - /// box and a slim status-bar box alike, the number stays inside the box, 1 pt above its centre, + /// box and a slim status-bar box alike, the number stays inside the box, centred in it, /// and a long number doesn't spill out of the box's left edge (#2823). #[test] fn themed_value_field_number_sits_inside_its_box_in_every_size() { @@ -1878,7 +1914,7 @@ mod tests { let field = harness.state().1; let what = format!("{kind:?} {w}x{h}: field {field:?}, number {number:?}"); assert!(field.contains_rect(number), "{what}"); - assert!((field.center().y - number.center().y - 1.0).abs() < 0.51, "{what}"); + assert!((field.center().y - number.center().y).abs() < 0.51, "{what}"); } } } From f053a924a668ec1b43d9bafcb473930cf0c65824 Mon Sep 17 00:00:00 2001 From: Pharbie <22204443+Pharbi@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:07:22 -0700 Subject: [PATCH 038/199] fix(ui): Color Picker hex field accepts repeated pastes (#5398) The hex field's six-character limit dropped any paste into a full field (every paste after the first) and any paste with a leading #. A pasted hex colour now replaces the whole code. Co-authored-by: kwesi <22204443+yankskwesi@users.noreply.github.com> --- crates/ui-egui/src/color_picker_ui.rs | 46 +++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 0d51f5d2f5..09a3ce8d4c 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -685,9 +685,17 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo crate::field_tab::select_all(ui.ctx(), id, current_hex.trim_start_matches('#')); ui.memory_mut(|m| m.request_focus(id)); } - let typing = if ui.memory(|m| m.has_focus(id)) { ui.data(|d| d.get_temp::(id)) } else { None }; - let mut h = typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()); + let focused = ui.memory(|m| m.has_focus(id)); + let pasted = if focused { take_hex_paste(ui) } else { None }; + let typing = if focused { ui.data(|d| d.get_temp::(id)) } else { None }; + let mut h = match pasted { + Some(c) => hex(c).trim_start_matches('#').to_ascii_uppercase(), + None => typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()), + }; let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6)); + if let Some(c) = pasted { + edit = Some((c, Keep::Nothing)); + } if r.gained_focus() { crate::field_tab::select_all(ui.ctx(), id, &h); } @@ -743,6 +751,23 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo } } +fn take_hex_paste(ui: &egui::Ui) -> Option<[f32; 3]> { + ui.input_mut(|i| { + let mut found = None; + i.events.retain(|e| match e { + egui::Event::Paste(s) => match parse_hex(s) { + Some(c) => { + found = Some(c); + false + } + None => true, + }, + _ => true, + }); + found + }) +} + /// Add to Swatches was clicked: add the picked colour to the Swatches panel. pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map) { if f.remove("__addSwatch").and_then(|v| v.as_bool()) == Some(true) { @@ -884,6 +909,23 @@ mod tests { assert_eq!(color(&h), "#ff8800"); } + #[test] + fn hex_field_takes_every_paste_with_or_without_hash() { + let mut h = picker("#336699"); + for (paste, want) in [("ff8800", "#ff8800"), ("#00ff00", "#00ff00"), (" 1234AB\n", "#1234ab")] { + h.event(egui::Event::Paste(paste.into())); + h.run_steps(2); + assert_eq!(color(&h), want, "paste {paste:?}"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some(&want[1..].to_ascii_uppercase()[..])); + } + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.event(egui::Event::Paste("ff".into())); + h.run_steps(2); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("ff")); + assert_eq!(color(&h), "#1234ab"); + } + #[test] fn color_picker_opens_with_hex_focused_and_selected() { let mut h = picker("#336699"); From a7f7f1fd1e327fcddb2697f0f03236fc3db2c2cd Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 19:07:28 +0200 Subject: [PATCH 039/199] =?UTF-8?q?Edit=20=E2=80=BA=20Menus:=20Menus,=20Ke?= =?UTF-8?q?yboard=20Shortcuts,=20Preferences,=20Save=20and=20Exit=20can't?= =?UTF-8?q?=20be=20hidden=20(#5096)=20(#5183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding Edit › Menus (or Keyboard Shortcuts) left no way back into the dialog, and Save, Save As, Exit and Preferences could disappear too. edit.menus now refuses to hide them, the dialog greys their Visible box, and the menus keep them even when a preferences file lists them. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/prefs.rs | 17 +++++++++++++++++ crates/engine/src/prefs/tests.rs | 13 +++++++++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 16 ++++++++++++++-- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b163d40d88..295870b3ca 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -954,6 +954,20 @@ pub struct MenuCustomization { pub colors: BTreeMap, } +impl MenuCustomization { + /// False for the items Edit › Menus can't hide (#5096): the way back into this dialog + /// (Edit › Menus, Edit › Keyboard Shortcuts), Preferences, Save, Save As and Exit. + pub fn hideable(id: &str) -> bool { + !matches!(id, "edit.menus" | "edit.keyboardShortcuts" | "file.save" | "file.saveAs" | "file.exit") && !id.starts_with("edit.preferences") + } + + /// Whether the menus leave `id` out. Items that can't be hidden stay shown even when an + /// older or hand-edited preferences file lists them. + pub fn is_hidden(&self, id: &str) -> bool { + Self::hideable(id) && self.hidden.iter().any(|h| h == id) + } +} + /// Edit › Toolbar: hidden tools and a custom order (tool names as in `ui.set {tool}`). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -1898,6 +1912,9 @@ fn menus(s: &mut Session, p: &Value) -> Result { } } let (hide, show) = (ids("hide"), ids("show")); + if let Some(id) = hide.iter().find(|id| !MenuCustomization::hideable(id)) { + return Err(bad("edit.menus", format!("`{id}` can't be hidden"))); + } s.edit_prefs(|pr| { if p.get("reset").and_then(Value::as_bool) == Some(true) { pr.menus = MenuCustomization::default(); diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 6b56cbc7ba..7f5c863e7f 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -397,6 +397,19 @@ fn menus_and_toolbar_customisation_persist() { assert_eq!(back.toolbar.hidden, vec!["Sponge".to_string()]); } +/// #5096: Edit › Menus can't hide the way back into itself, Preferences, Save or Exit. +#[test] +fn menus_refuse_to_hide_essential_items() { + let mut s = session(); + for id in ["edit.menus", "edit.keyboardShortcuts", "edit.preferences.general", "file.save", "file.saveAs", "file.exit"] { + assert!(s.execute("edit.menus", json!({"hide": ["edit.fade", id]})).is_err(), "{id}"); + assert!(s.prefs().menus.hidden.is_empty(), "{id}: a refused call changes nothing"); + } + // A preferences file that lists them anyway still shows them. + let menus = MenuCustomization { hidden: vec!["file.exit".into(), "edit.fade".into()], ..Default::default() }; + assert!(!menus.is_hidden("file.exit") && menus.is_hidden("edit.fade")); +} + #[test] fn preference_sections_are_commands() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 403994ac10..d25d6a41de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1111,7 +1111,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { } } if !prefs.menus.hidden.is_empty() { - items.retain(|i| !prefs.menus.hidden.contains(&i.id)); + items.retain(|i| !prefs.menus.is_hidden(&i.id)); } if prefs.interface.show_menu_colors { for it in &mut items { diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index db908ae23f..497a7dba62 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1753,8 +1753,10 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map