From 60c87b9884fdae0e464614d482a3bc435cbbc578 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:11:30 -0400 Subject: [PATCH 01/40] Release 0.15.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 62 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce466ec9d3..c925c8f29f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4638,7 +4638,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arboard", "async-io", @@ -4684,7 +4684,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.15.0" +version = "0.15.1" dependencies = [ "crc32fast", "flate2", @@ -4694,7 +4694,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4708,7 +4708,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.15.0" +version = "0.15.1" dependencies = [ "base64 0.22.1", "cap-std", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4746,7 +4746,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.15.0" +version = "0.15.1" dependencies = [ "moxcms", "rayon", @@ -4754,7 +4754,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.15.0" +version = "0.15.1" dependencies = [ "exr", "flate2", @@ -4775,7 +4775,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-cms", "serde", @@ -4783,7 +4783,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.15.0" +version = "0.15.1" dependencies = [ "criterion", "photocraft-color", @@ -4798,7 +4798,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4809,7 +4809,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.15.0" +version = "0.15.1" dependencies = [ "blake3", "chrono", @@ -4849,7 +4849,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.15.0" +version = "0.15.1" dependencies = [ "blake3", "crc32fast", @@ -4872,14 +4872,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.15.0" +version = "0.15.1" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.15.0" +version = "0.15.1" dependencies = [ "half", "log", @@ -4897,14 +4897,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.15.0" +version = "0.15.1" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.15.0" +version = "0.15.1" dependencies = [ "blake3", "craft-docpath", @@ -4935,18 +4935,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.15.0" +version = "0.15.1" [[package]] name = "photocraft-ops" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4957,7 +4957,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.15.0" +version = "0.15.1" dependencies = [ "flate2", "photocraft-psd", @@ -4984,7 +4984,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4995,7 +4995,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.15.0" +version = "0.15.1" dependencies = [ "flate2", "image", @@ -5010,7 +5010,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.15.0" +version = "0.15.1" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -5022,7 +5022,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.15.0" +version = "0.15.1" dependencies = [ "memory-stats", "serde_json", @@ -5030,7 +5030,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.15.0" +version = "0.15.1" dependencies = [ "flate2", "parley", @@ -5045,7 +5045,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.15.0" +version = "0.15.1" dependencies = [ "base64 0.22.1", "craft-print", @@ -5088,7 +5088,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.15.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5099,7 +5099,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.15.0" +version = "0.15.1" dependencies = [ "eframe", "egui", @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.15.0" +version = "0.15.1" dependencies = [ "flate2", "js-sys", @@ -8717,7 +8717,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 154ca201f6..8a3e05181e 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.15.0" +version = "0.15.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 8d1926433f2d234566d0c14f58143f23ad389a4f Mon Sep 17 00:00:00 2001 From: kotulsky Date: Fri, 23 Oct 2026 23:37:56 +0200 Subject: [PATCH 02/40] Reuse filled-shape effect maps while moving styled groups (#8993) * perf(gpu): reuse filled-shape effect maps during translation Hash filled-shape outlines in local coordinates so whole-pixel movement can use the existing effect-map translation reuse path. Keep its region-size and exact coverage checks, and invalidate the key when the outline deforms. Add a shape-key regression and a synchronized styled-shape group benchmark with fresh-render parity verification. On RTX 4070 Laptop, the 1600x1200 fixture improves from 61.336 to 33.120 ms median; a 24 MP full-canvas fixture improves from 223.018 to 184.289 ms. Warm frames rebuild zero effect programs, and the quick fixture matches fresh rendering exactly. * fix(gpu): stabilize fractional shape effect fingerprints Quantize only origin-normalized fingerprint coordinates to a 1/65536 pixel grid and canonicalize signed zero. Preserve the render path and exact coverage guard. Cover fractional anchors and handles, positive and negative integer translations and handle deformation. Exercise fractional origins in the benchmark and document its entry points. All 40 GPU tests, Clippy and WASM checks pass; reused output matches a fresh compositor exactly. --------- Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com> --- crates/gpu/examples/bench_shape_drag.rs | 82 +++++++++++++++++++++++++ crates/gpu/src/fx.rs | 54 +++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 crates/gpu/examples/bench_shape_drag.rs diff --git a/crates/gpu/examples/bench_shape_drag.rs b/crates/gpu/examples/bench_shape_drag.rs new file mode 100644 index 0000000000..820c1e187f --- /dev/null +++ b/crates/gpu/examples/bench_shape_drag.rs @@ -0,0 +1,82 @@ +//! Styled shape group translation: synchronized frame time and effect-map rebuild counters. +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Fill, Layer, LayerContent, Path, ShapeLayer, Subpath}; +use photocraft_geom::{Affine, Size}; +use photocraft_gpu::Compositor; +use std::time::Instant; + +/// Translate the fixture's paths and raster caches together, as a group drag preview does. +fn moved(base: &Document, offset: i32) -> Document { + let mut doc = base.clone(); + if let Some(group) = doc.layers.last_mut().and_then(Layer::children_mut) { + for layer in group { + if let LayerContent::Shape(shape) = &mut layer.content { + shape.path = shape.path.transform(&Affine::translate(f64::from(offset), f64::from(offset))); + if let Some(cache) = &mut shape.cache { + *cache = cache.translated(offset, offset, photocraft_compose::bounds::content_bounds(cache)); + } + } + } + } + doc +} +/// Measure synchronized drag frames; quick mode also verifies fresh-render parity. +fn main() -> Result<(), Box> { + let quick = std::env::args().any(|a| a == "--quick"); + let size = if quick { Size::new(1600, 1200) } else { Size::new(6000, 4000) }; + let mut doc = Document::with_background("styled shapes", size, ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut children = Vec::new(); + for i in 0..12 { + let x = 100.1 + f64::from(i % 4) * f64::from(size.width) / 5.0; + let y = 100.2 + f64::from(i / 4) * f64::from(size.height) / 4.0; + let w = f64::from(size.width) / 7.0; + let h = f64::from(size.height) / 6.0; + let mut shape = ShapeLayer { + path: Path::new(vec![Subpath::polygon(&[(x, y), (x + w, y), (x + w, y + h), (x, y + h)])]), + fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.8))), + ..Default::default() + }; + shape.cache = Some(photocraft_vector::render_shape(&shape, doc.pixel_format(), doc.bounds())); + let mut layer = Layer::new(format!("shape {i}"), LayerContent::Shape(shape)); + layer.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + children.push(layer); + } + doc.layers.push(Layer::group("group", children)); + let adapter = pollster::block_on( + wgpu::Instance::default() + .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), + )?; + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; + println!("adapter={:?} document={}x{}", adapter.get_info().name, size.width, size.height); + let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter))?; + let mut samples = Vec::new(); + for frame in 0..8 { + let start = Instant::now(); + let current = moved(&doc, frame * 3); + let preview_ms = start.elapsed().as_secs_f64() * 1000.0; + let stats = comp.render(&device, &queue, ¤t, current.bounds(), |_, _| {})?; + device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None })?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + println!( + "frame={frame} ms={ms:.3} preview_ms={preview_ms:.3} fx_shapes={} fx_programs={} fx_pixels={}", + stats.fx_shapes, stats.fx_programs, stats.fx_pixels + ); + if frame > 1 { + samples.push(ms); + } + } + samples.sort_by(f64::total_cmp); + println!("warm_median_ms={:.3}", samples.get(samples.len() / 2).copied().unwrap_or_default()); + if quick { + let current = moved(&doc, 21); + let warm = photocraft_gpu::render_to_vec(&mut comp, &device, &queue, ¤t, current.bounds())?; + let mut fresh = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter))?; + let expected = photocraft_gpu::render_to_vec(&mut fresh, &device, &queue, ¤t, current.bounds())?; + let difference = warm.iter().zip(&expected).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + println!("fresh_render_max_difference={difference}"); + if difference >= 1.0 / 255.0 { + return Err("reused effect maps differ from fresh rendering".into()); + } + } + Ok(()) +} diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index db204a3bf4..898eb2b87a 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -664,7 +664,23 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 { LayerContent::Shape(sh) => { std::mem::discriminant(&layer.content).hash(&mut h); if photocraft_compose::effect_outline(layer).is_some() { - format!("{:?}", sh.path).hash(&mut h); + // The cached maps live in region-local coordinates. Preserve their identity + // under translation so sync_fx can verify coverage and reuse them after a move. + let mut outline = + sh.path.control_bounds().map_or_else(|| sh.path.clone(), |(x, y, _, _)| sh.path.transform(&photocraft_geom::Affine::translate(-x, -y))); + // Integer translation can perturb fractional f64 coordinates in the low bits. + // Quantize only the fingerprint (1/65536 px); rendering keeps the original path, + // and sync_fx still requires exact coverage equality before reusing moved maps. + for subpath in &mut outline.subpaths { + for knot in &mut subpath.knots { + for point in [&mut knot.anchor, &mut knot.in_ctrl, &mut knot.out_ctrl] { + // Adding zero also canonicalizes negative zero in Debug output. + point.x = (point.x * 65536.0).round() / 65536.0 + 0.0; + point.y = (point.y * 65536.0).round() / 65536.0 + 0.0; + } + } + } + format!("{outline:?}").hash(&mut h); } } other => std::mem::discriminant(other).hash(&mut h), @@ -746,6 +762,42 @@ mod tests { use photocraft_color::BlendMode; use photocraft_doc::FxCommon; + /// Filled-shape identity survives integer moves, including fractional anchors and handles. + #[test] + fn filled_shape_key_preserves_translation_but_detects_deformation() { + use photocraft_doc::{Fill, Path, ShapeLayer, Subpath}; + let mut layer = Layer::new( + "shape", + LayerContent::Shape(ShapeLayer { + path: Path::new(vec![Subpath::polygon(&[(20.0, 20.0), (70.0, 20.0), (70.0, 60.0), (20.0, 60.0)])]), + fill: Some(Fill::Solid(photocraft_color::Color::WHITE)), + ..Default::default() + }), + ); + let canvas = Rect::new(0, 0, 200, 200); + let before = shape_key(&layer, canvas); + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path = shape.path.transform(&photocraft_geom::Affine::translate(13.0, 7.0)); + assert_eq!(before, shape_key(&layer, canvas)); + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path.subpaths[0].knots[1].anchor.x += 3.0; + assert_ne!(before, shape_key(&layer, canvas)); + + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path = Path::new(vec![Subpath::polygon(&[(0.1, 0.2), (0.3, 0.2), (0.3, 1.7), (0.1, 1.7)])]); + shape.path.subpaths[0].knots[1].out_ctrl = photocraft_geom::Point::new(0.45, 0.65); + let original = shape.path.clone(); + let before = shape_key(&layer, canvas); + for (dx, dy) in [(1.0, 1.0), (-13.0, 7.0), (100_000.0, -100_000.0)] { + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path = original.transform(&photocraft_geom::Affine::translate(dx, dy)); + assert_eq!(before, shape_key(&layer, canvas), "fractional path translated by {dx}, {dy}"); + } + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path.subpaths[0].knots[1].out_ctrl.x += 0.01; + assert_ne!(before, shape_key(&layer, canvas), "a handle deformation invalidates the key"); + } + #[test] fn programs_key_on_geometry_not_colour() { let light = GlobalLight::default(); From 7e3594b822e5ba61218c70bc5ae8bf930de0cb4a Mon Sep 17 00:00:00 2001 From: Kushagra Sharma <56391795+drunkeninja42@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:38:02 -0500 Subject: [PATCH 03/40] fix(ui-egui): a click on the bottom layer's Smart Filters selects them again (#8764) (#8974) * fix(ui-egui): a click on the bottom layer's Smart Filters selects them again (#8764) The empty space below the last Layers panel row deselects every layer on a click (#8100). It started under the last layer's own row, so it covered that layer's effect and Smart Filter rows and, registered after them, took their clicks: on the bottom smart object (a new document's Layer 0), clicking a filter deselected everything instead of selecting it. The click area now starts under everything drawn for the last layer. The drop zone below it (#1721) is unchanged. * Contributors: credit drunkeninja42 as Kushagra Sharma --- contributors/people.toml | 3 +++ crates/ui-egui/src/panels.rs | 9 ++++++-- crates/ui-egui/src/smart_filter_rows_tests.rs | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 30077a230f..4afc831d48 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -251,3 +251,6 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" + +[people.drunkeninja42] +real_name = "Kushagra Sharma" diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b316669258..4dbec87cdc 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2838,6 +2838,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::smart_ui::filter_rows(app, ui, &doc, l, depth, &mut actions); } } + // The bottom of everything drawn: the last layer's effect and Smart Filter rows sit + // under its own row (#8764). + let rows_bottom = ui.min_rect().bottom(); // #1721: the list takes drops everywhere, not only on a row (Photoshop): the space // between two rows lands above the lower one, the empty space below the bottom-most // row below it. Both copy with ⌥ held, move without. @@ -2848,8 +2851,10 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(&(last, last_rect)) = drawn.last() { let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom())); layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions); - // A click on that empty space deselects every layer, as in Photoshop (#8100). - if below.height() > 0.0 && ui.interact(below, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { + // A click on that empty space deselects every layer, as in Photoshop (#8100). It + // starts under the last layer's sub-rows, which take their own clicks. + let empty = Rect::from_min_max(pos2(below.left(), rows_bottom), below.max); + if empty.height() > 0.0 && ui.interact(empty, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { actions.push(("select.deselectLayers".into(), json!({}))); } } diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs index d05e02e014..7457b0c780 100644 --- a/crates/ui-egui/src/smart_filter_rows_tests.rs +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -317,3 +317,25 @@ fn with_the_layers_panel_closed_delete_leaves_the_filter() { h.run_steps(4); assert!(h.state().session.active().unwrap().doc.layer(LayerId(a)).is_none(), "Delete reached Smart A itself"); } + +/// #8764: the bottom layer's filter rows sit where the empty space below the last row begins, so +/// that space's deselect-all click (#8100) must start under them: a click on a filter of a new +/// document's only layer, converted for Smart Filters, selects the filter. +#[test] +fn a_click_selects_a_filter_of_the_bottom_layer() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let id = s.execute("filter.convertForSmartFilters", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_max_steps(64).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(4); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + let st = h.state().session.active().unwrap(); + assert_eq!(st.active_layer, Some(LayerId(id)), "the smart object stays active"); + assert_eq!(st.selected_layers(), [LayerId(id)], "the click did not deselect the layers"); + assert!(selected(&h, "Gaussian Blur")); +} From cfd894aad3bab1d257238812f944daf359934f74 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 17:38:07 -0400 Subject: [PATCH 04/40] security(paint): cap spaced steps and procedural primitives (#8957) * security(paint): cap distance-spaced steps per segment and procedural tip primitives * test(paint): count steps through a Cell so the walker closure can be shared * paint: keep the spacing carry-over when a segment ends at the step cap; lower the tip work budget --- crates/paint/src/dynamics.rs | 47 ++++++++++++++++++++++++++++++++-- crates/paint/src/procedural.rs | 28 +++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 41c1bb6b4d..5c8c88b640 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -26,6 +26,9 @@ const MAX_GRID_STEPS: f64 = 1e7; const MIN_SPEED_STEP: f64 = 0.5; /// Maximum airbrush catch-up dabs for one input segment. const MAX_AIRBRUSH_DABS_PER_SEGMENT: usize = 512; +/// Most steps one distance-spaced segment emits: two far-apart points at a quarter-pixel spacing +/// would otherwise be billions of steps, all buffered before the first is drawn. +const MAX_DISTANCE_STEPS_PER_SEGMENT: usize = 1 << 20; /// The dab centre of the pixel-grid cell holding `v` (see [`PathWalker::pixel_grid`]): the /// pixel's centre for `offset` 0.5, the nearest pixel corner for 0. A value within rounding @@ -336,12 +339,17 @@ impl PathWalker { match self.speed.filter(|_| dt > 0.0 && dt.is_finite()) { None => { if !self.grid.is_some_and(|g| self.grid_steps(&a, &p, g, out)) { - while self.next_at <= len + 1e-9 { + let mut emitted = 0usize; + while self.next_at <= len + 1e-9 && emitted < MAX_DISTANCE_STEPS_PER_SEGMENT { let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0)); self.emit(q, out); self.next_at += step_len(&q).max(0.25); + emitted += 1; } - self.next_at -= len; + // Cut short: the rest of the segment is skipped, spacing restarts at its end. + // Only when the cap held back a step that was still due; a segment that ends right at the cap keeps its carry-over. + let cut_short = emitted == MAX_DISTANCE_STEPS_PER_SEGMENT && self.next_at <= len + 1e-9; + self.next_at = if cut_short { step_len(&p).max(0.25) } else { self.next_at - len }; } } Some(iv) => { @@ -815,3 +823,38 @@ impl DabGenerator { } } } + +#[cfg(test)] +mod walker_budget_tests { + use super::*; + + #[test] + fn one_far_segment_emits_a_bounded_number_of_steps() { + let steps = std::cell::Cell::new(0usize); + let mut count = |_: StepInput| steps.set(steps.get() + 1); + let mut walker = PathWalker::new(None); + walker.push(StrokePoint::new(0.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count); + // At a quarter pixel, a billion-pixel segment is four billion steps. + walker.push(StrokePoint::new(1.0e9, 0.0, 1.0), &|_: &StrokePoint| 0.25, &mut count); + assert!(steps.get() <= MAX_DISTANCE_STEPS_PER_SEGMENT + 1, "{}", steps.get()); + // Spacing carries on from the end of the cut segment. + let before = steps.get(); + walker.push(StrokePoint::new(1.0e9 + 10.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count); + assert!(steps.get() > before && steps.get() - before <= 11, "{} more steps", steps.get() - before); + } + + #[test] + fn a_segment_ending_at_the_cap_keeps_its_carry_over() { + let steps = std::cell::Cell::new(0usize); + let mut count = |_: StepInput| steps.set(steps.get() + 1); + let mut walker = PathWalker::new(None); + let one = |_: &StrokePoint| 1.0; + walker.push(StrokePoint::new(0.0, 0.0, 1.0), &one, &mut count); + // Exactly the cap in steps, then 0.75 left before the next is due. + let end = MAX_DISTANCE_STEPS_PER_SEGMENT as f64 + 0.25; + walker.push(StrokePoint::new(end, 0.0, 1.0), &one, &mut count); + let before = steps.get(); + walker.push(StrokePoint::new(end + 0.8, 0.0, 1.0), &one, &mut count); + assert_eq!(steps.get(), before + 1); + } +} diff --git a/crates/paint/src/procedural.rs b/crates/paint/src/procedural.rs index 5a14521fba..5469dacebc 100644 --- a/crates/paint/src/procedural.rs +++ b/crates/paint/src/procedural.rs @@ -87,8 +87,20 @@ pub fn chalk_tip(n: u32, seed: u32) -> GrayTile { }) } +/// Most primitives one generated tip is drawn from, and most (pixel × primitive) visits it costs: +/// every pixel is tested against every primitive, so both counts are the caller's to multiply. +const MAX_PRIMITIVES: u32 = 4096; +const MAX_TIP_WORK: u64 = 1 << 27; + +/// `count` limited so that an `n`×`n` tip tests at most [`MAX_TIP_WORK`] pixel/primitive pairs. +fn clamp_primitives(n: u32, count: u32) -> u32 { + let per_primitive = (u64::from(n) * u64::from(n)).max(1); + count.min(MAX_PRIMITIVES).min(u32::try_from(MAX_TIP_WORK / per_primitive).unwrap_or(u32::MAX)) +} + /// A spatter tip: a cluster of round droplets of different sizes. pub fn spatter_tip(n: u32, seed: u32, drops: u32) -> GrayTile { + let drops = clamp_primitives(n, drops); let salt = u64::from(seed) + 202; let s = n as f32; let blobs: Vec<(f32, f32, f32)> = (0..drops) @@ -162,6 +174,7 @@ pub fn leaf_tip(n: u32) -> GrayTile { /// Grass: a tuft of tapered, slightly curved blades rising from the bottom edge. pub fn grass_tip(n: u32, seed: u32, blades: u32) -> GrayTile { + let blades = clamp_primitives(n, blades); let salt = u64::from(seed) + 505; let s = n as f32; let bl: Vec<(f32, f32, f32, f32)> = (0..blades) @@ -217,7 +230,7 @@ pub fn star_tip(n: u32) -> GrayTile { pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile { let salt = u64::from(seed) + 707; let s = n as f32; - let k = bristles.max(1); + let k = clamp_primitives(n, bristles).max(1); let dots: Vec<(f32, f32, f32)> = (0..k) .map(|i| { let x = s * (i as f32 + 0.5) / k as f32; @@ -239,6 +252,19 @@ pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile { mod tests { use super::*; + #[test] + fn tips_with_absurd_primitive_counts_stay_cheap() { + // 4 billion drops over a 1024² tip would be 4·10¹⁵ pixel tests. + let t = std::time::Instant::now(); + assert_eq!(spatter_tip(1024, 1, u32::MAX).width, 1024); + assert_eq!(grass_tip(1024, 1, u32::MAX).height, 1024); + assert_eq!(rake_tip(1024, 1, u32::MAX).width, 1024); + assert!(t.elapsed().as_secs() < 60, "{:?}", t.elapsed()); + assert!(clamp_primitives(1024, u32::MAX) <= MAX_PRIMITIVES); + assert!(clamp_primitives(60_000, 100) < 100, "a huge tip gets fewer primitives"); + assert_eq!(clamp_primitives(0, 7), 7); + } + #[test] fn patterns_are_seamless_and_in_range() { for st in [PatternStyle::Noise, PatternStyle::Paper, PatternStyle::Canvas, PatternStyle::Dots] { From 0c124bf38813313dbd41c6a8e500c6b9e3825843 Mon Sep 17 00:00:00 2001 From: Ignat Ai <154402655+IgnatAi401@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:38:13 +0800 Subject: [PATCH 05/40] i18n(zh-hans): translate status messages and preview labels (#8872) --- crates/ui-egui/src/i18n/zh-hans.tsv | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 16cd47edf8..c4fd417ccc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2999,7 +2999,7 @@ curves Smooth 平滑 Next Brush 下一个画笔 First Brush 第一个画笔 Last Brush 最后一个画笔 - Preview size Preview size + Preview size 预览大小 Memory Usage 内存使用 Per-document RAM limit (MB) 每个文档的 RAM 上限 (MB) Installed RAM: {ram} MB; this limit: {percent}% 已安装 RAM:{ram} MB;此上限:{percent}% @@ -3040,4 +3040,11 @@ curves Smooth 平滑 Not rendered: this adjustment ({key}) is kept as imported. 未渲染:此调整({key})按导入时的原样保留。 # Print preview image (#8236) - Print preview image Print preview image + Print preview image 打印预览图像 + +# File opening, export status and graphics driver information + Couldn't open {name}: {error} 无法打开 {name}:{error} + Exported {path} ({size}) 已导出 {path}({size}) + Exported {path} 已导出 {path} + Exported {n} files 已导出 {n} 个文件 + Driver: {driver} 驱动程序:{driver} From 08a606c7d1746039be802ffc8a5ada87ea55bec9 Mon Sep 17 00:00:00 2001 From: Yongjip Kim <13984989+yongjip@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:38:18 +0900 Subject: [PATCH 06/40] perf(liquify): reuse interpolation taps and bound render buffers (#8855) * perf(liquify): reuse cubic field taps across tile axes * perf(liquify): bound completed render tiles --- crates/algo/src/liquify.rs | 89 ++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index 9ebb3115c9..5d35f5791b 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -191,6 +191,9 @@ struct StrokeCursor { /// 125,000); smaller ones are not worth the hand-off. const PARALLEL_DAB_NODES: usize = 16_384; +/// Bound completed output buffers independently of the document's size. +const RENDER_BATCH_TILES: usize = 64; + /// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a /// drag is many small pushes and its edge stays smooth. fn dab_spacing(r: f64) -> f64 { @@ -593,14 +596,11 @@ impl LiquifyField { } /// Catmull-Rom (bicubic) displacement at grid coordinates. - fn sample_cubic(&self, gx: f64, gy: f64, wx: &[f64; 4], wy: &[f64; 4]) -> [f64; 2] { - let (ix, iy) = (gx.floor() as isize, gy.floor() as isize); + fn sample_cubic(&self, xs: &[(usize, f64); 4], ys: &[(usize, f64); 4]) -> [f64; 2] { let mut acc = [0.0f64; 2]; - for (jj, wyj) in wy.iter().enumerate() { - let y = (iy - 1 + jj as isize).clamp(0, self.h as isize - 1) as usize; + for &(y, wyj) in ys { let row = &self.d[y * self.w..(y + 1) * self.w]; - for (ii, wxi) in wx.iter().enumerate() { - let x = (ix - 1 + ii as isize).clamp(0, self.w as isize - 1) as usize; + for &(x, wxi) in xs { let k = wxi * wyj; acc[0] += f64::from(row[x][0]) * k; acc[1] += f64::from(row[x][1]) * k; @@ -660,6 +660,13 @@ fn catmull_rom(t: f64) -> [f64; 4] { [-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2] } +/// A field axis's clamped nodes and weights, reusable across a tile's rows or columns. +fn cubic_taps(g: f64, len: usize) -> [(usize, f64); 4] { + let i = g.floor() as isize; + let weights = catmull_rom(g - g.floor()); + std::array::from_fn(|k| ((i - 1 + k as isize).clamp(0, len as isize - 1) as usize, weights[k])) +} + /// Resamples `src` through the field: inside `field.bounds` each pixel shows the source at /// `p + d(p)` (bicubic field, bicubic premultiplied source, edge pixels repeat past the field /// bounds); everything outside the bounds is kept. Same format as `src`. @@ -680,19 +687,24 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { let work = |t: &Rect| -> Option<(Rect, Vec)> { let w = t.width() as usize; let h = t.height() as usize; - // Field per pixel (bicubic), rows share their y weights. + // Field per pixel (bicubic), with axis taps shared across rows and columns. let mut disp = vec![[0.0f64; 2]; w * h]; let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); let mut moved = false; + let columns: Vec<_> = (0..w) + .map(|x| { + let px = f64::from(t.x0) + x as f64 + 0.5; + let gx = (px - f64::from(b.x0) - 0.5) / field.cell; + (px, cubic_taps(gx, field.w)) + }) + .collect(); for y in 0..h { let py = f64::from(t.y0) + y as f64 + 0.5; let gy = (py - f64::from(b.y0) - 0.5) / field.cell; - let wy = catmull_rom(gy - gy.floor()); - for x in 0..w { - let px = f64::from(t.x0) + x as f64 + 0.5; - let gx = (px - f64::from(b.x0) - 0.5) / field.cell; - let wx = catmull_rom(gx - gx.floor()); - let mut d = field.sample_cubic(gx, gy, &wx, &wy); + let ys = cubic_taps(gy, field.h); + for (x, (px, xs)) in columns.iter().enumerate() { + let px = *px; + let mut d = field.sample_cubic(xs, &ys); if has_freeze { let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0); if mask > 0.0 { @@ -774,15 +786,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { } Some((*t, outp)) }; - #[cfg(not(target_arch = "wasm32"))] - let done: Vec<(Rect, Vec)> = { - use rayon::prelude::*; - tiles.par_iter().filter_map(work).collect() - }; - #[cfg(target_arch = "wasm32")] - let done: Vec<(Rect, Vec)> = tiles.iter().filter_map(work).collect(); - for (r, v) in done { - out.write_region(r, &v); + for batch in tiles.chunks(RENDER_BATCH_TILES) { + for (r, v) in par::map(batch.len(), true, |i| batch.get(i).and_then(work)).into_iter().flatten() { + out.write_region(r, &v); + } } if alpha { out.prune(); @@ -1318,6 +1325,44 @@ mod tests { } } + #[test] + fn a_wide_integer_translation_keeps_channels_and_tile_edges_at_every_format() { + let b = Rect::new(13, -1, 13 + 70 * 256, 1); + let w = b.width() as usize; + for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat::new(mode, depth, true); + let n = format.channels(); + let mut data = Vec::new(); + for i in 0..w * 2 { + let alpha = if i % 13 == 0 { 0.0 } else { 0.5 }; + for c in 0..mode.color_channels() { + data.push(if alpha == 0.0 { 0.0 } else { ((i * 19 + c * 43) % 251 + 2) as f32 / 255.0 }); + } + data.push(alpha); + } + let mut source = Surface::new(format); + source.write_region(b, &data); + source.write_pixel(b.x0 - 1, b.y0 - 1, &vec![0.75; n]); + let before = source.read_region(b); + let mut field = LiquifyField::new(b, 2.5); + field.d.fill([-1.0, 0.0]); + let output = apply_liquify(&source, &field); + let after = output.read_region(b); + let tolerance = if depth == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 }; + for (original, moved) in before.chunks_exact(w * n).zip(after.chunks_exact(w * n)) { + for (x, pixel) in moved.chunks_exact(n).enumerate() { + let offset = x.saturating_sub(1) * n; + for (actual, expected) in pixel.iter().zip(&original[offset..offset + n]) { + assert!((actual - expected).abs() <= tolerance, "{mode:?}/{depth:?} at column {x}"); + } + } + } + assert_eq!(output.pixel(b.x0 - 1, b.y0 - 1), source.pixel(b.x0 - 1, b.y0 - 1)); + } + } + } + #[test] fn no_strokes_is_identity_at_every_depth() { for st in [SampleType::U8, SampleType::U16, SampleType::F32] { From 78013e22568aefcc21c9af3da5225b81c9f0f668 Mon Sep 17 00:00:00 2001 From: Yongjip Kim <13984989+yongjip@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:38:23 +0900 Subject: [PATCH 07/40] perf(mcp): move command list rows instead of cloning (#8844) --- crates/automation/src/server.rs | 36 +++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 46905daf38..56d85509a6 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -412,6 +412,14 @@ fn strict_node(v: &mut Value, defs: &serde_json::Map, depth: usiz } } +/// Both the bare engine list and older bridge wrappers own their rows already. +fn into_command_rows(mut reply: Value) -> Vec { + match reply { + Value::Array(rows) => rows, + _ => reply.get_mut("commands").and_then(Value::as_array_mut).map(std::mem::take).unwrap_or_default(), + } +} + fn ok_json(v: &Value) -> CallToolResult { let text = match json_bytes(v).and_then(|bytes| String::from_utf8(bytes).map_err(|error| AutomationError::Other(error.to_string()))) { Ok(text) => text, @@ -827,10 +835,7 @@ impl PhotocraftMcp { }; let needle = p.filter.map(|f| f.to_lowercase()); let enabled_only = p.enabled_only.unwrap_or(false); - let items: Vec = all - .as_array() - .cloned() - .unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default()) + let items: Vec = into_command_rows(all) .into_iter() .filter(|c| { needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)) @@ -1083,6 +1088,29 @@ pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Res mod tests { use super::*; + #[test] + fn command_rows_keep_bare_and_wrapped_replies_compatible() { + let rows = json!([{"id":"first","params":{"nested":[1,2]}}, {"id":"second","enabled":false}]); + assert_eq!(into_command_rows(rows.clone()), rows.as_array().unwrap().clone()); + assert_eq!(into_command_rows(json!({"commands":rows,"other":"ignored"})), rows.as_array().unwrap().clone()); + for reply in [json!(null), json!(false), json!(3), json!("invalid"), json!({}), json!({"commands":null}), json!({"commands":{}}), json!([])] { + assert!(into_command_rows(reply).is_empty()); + } + } + + #[test] + fn command_rows_reuse_the_owned_array_and_nested_strings() { + for wrapped in [false, true] { + let rows = json!([{"id":"first","params":"documentation".repeat(64)}]); + let array = rows.as_array().unwrap().as_ptr(); + let text = rows[0]["params"].as_str().unwrap().as_ptr(); + let reply = if wrapped { Value::Object([("commands".into(), rows)].into_iter().collect()) } else { rows }; + let moved = into_command_rows(reply); + assert_eq!(moved.as_ptr(), array, "wrapped={wrapped}: move the original allocation"); + assert_eq!(moved[0]["params"].as_str().unwrap().as_ptr(), text, "wrapped={wrapped}: retain nested allocations"); + } + } + #[test] fn strict_schema_inlines_refs_and_stops_on_recursive_definitions() { // A definition that refers to itself: inlining stops at a depth instead of looping. From 612c4a3c7fec57950c48d5ba204742649045293e Mon Sep 17 00:00:00 2001 From: Przemyslaw Majdak <31745530+przemyslvw@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:38:29 +0200 Subject: [PATCH 08/40] Codecs: warn when DPX or Cineon saves drop bits (#8793) DPX at 10 or 12 bits and Cineon (always 10) are planned as U16, so DepthReduced, which compares sample types, never fired: a 16-bit document saved at 10 bits lost six bits per sample silently, and a float one was told it would be reduced to 16-bit. FidelityWarning::BitsReduced { from, bits } names the stored depth. Float samples always get it; U16 ones only when some sample is not already a code value at that depth, so a frame opened from a 10- or 12-bit file saves back without a warning. U8 widens losslessly and stays silent. --- crates/codecs/README.md | 3 +++ crates/codecs/src/codecs/dpx.rs | 20 ++++++++++++++ crates/codecs/src/fidelity.rs | 18 +++++++++++-- crates/codecs/tests/it/dpx.rs | 43 ++++++++++++++++++++++++++++++ crates/codecs/tests/it/fidelity.rs | 13 ++++++++- 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 46a33e89b3..c191cc40b2 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -106,6 +106,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. + `FidelityWarning::BitsReduced` reports a 10- or 12-bit save (and every Cineon save) that drops + precision: always for float samples, for U16 ones unless they are already code values at that + depth. Other descriptors, signed or run-length-encoded data and other bit depths return `CodecError::Unsupported` naming the field. diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index f93c79c928..aa71f94651 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -537,6 +537,26 @@ pub(crate) fn bits_for(opt: Option, sample: SampleType) -> u8 { } } +/// The bits `format` stores below its U16 plan: DPX at 10 or 12 bits, Cineon's 10. `None` when +/// the stored depth is the plan's sample type. +pub(crate) fn packed_bits(format: Format, opts: &EncodeOptions) -> Option { + match format { + Format::Dpx => opts.dpx_bits.filter(|b| matches!(b, DpxBits::Bits10 | DpxBits::Bits12)).map(DpxBits::bits), + Format::Cineon => Some(10), + _ => None, + } +} + +/// `true` when every sample of a U16 image is already a `bits`-bit code value, so writing it at +/// `bits` and reading it back gives the same samples (a frame opened from such a file). +pub(crate) fn keeps_u16_samples(img: &Image, bits: u8) -> bool { + if img.sample_type() != SampleType::U16 || !(1..16).contains(&bits) { + return false; + } + let max = (1u64 << bits) - 1; + img.data().as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).all(|v| widen(narrow(v, max), max) == v) +} + /// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16. pub(crate) fn plan_sample(opt: Option, src: SampleType) -> SampleType { match opt { diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index a6937b571c..d80bb9ef7a 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -4,6 +4,7 @@ use std::fmt; +use crate::codecs::dpx; use crate::format::{Format, caps}; use crate::image::{ChannelLayout, Image, SampleType}; use crate::options::{DdsCompression, EncodeOptions}; @@ -20,6 +21,12 @@ pub enum FidelityWarning { from: SampleType, to: SampleType, }, + /// Samples will be stored with fewer bits than their sample type holds: DPX at 10 or 12 + /// bits, Cineon at 10. + BitsReduced { + from: SampleType, + bits: u8, + }, /// Float samples outside `[0, 1]` will be clipped by integer storage. RangeClipped, /// Non-opaque alpha will be discarded. @@ -82,6 +89,7 @@ impl fmt::Display for FidelityWarning { write!(f, "{} cannot be written in this build", format.name()) } DepthReduced { from, to } => write!(f, "{} will be reduced to {}", depth_name(*from), depth_name(*to)), + BitsReduced { from, bits } => write!(f, "{} will be reduced to {bits}-bit", depth_name(*from)), RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"), AlphaDiscarded => write!(f, "alpha will be discarded"), AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"), @@ -161,7 +169,7 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan // The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16). Format::Dpx => Plan { layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) }, - sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample), + sample: dpx::plan_sample(opts.dpx_bits, sample), }, // CIELab has no float form: float Lab is written with 16 bits. Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 }, @@ -201,7 +209,13 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption (SampleType::F16, SampleType::F32) => false, _ => true, }; - if lossy_depth { + // DPX at 10 or 12 bits and Cineon keep fewer bits than their U16 plan: float samples always + // lose precision there, 16-bit ones unless they are already code values at that depth. + if let Some(bits) = dpx::packed_bits(format, opts).filter(|_| ss != SampleType::U8) { + if ss.is_float() || !dpx::keeps_u16_samples(&image.converted(p.layout, SampleType::U16), bits) { + w.push(W::BitsReduced { from: ss, bits }); + } + } else if lossy_depth { w.push(W::DepthReduced { from: ss, to: p.sample }); } // FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too. diff --git a/crates/codecs/tests/it/dpx.rs b/crates/codecs/tests/it/dpx.rs index 6fbfb2db7b..3ec8978247 100644 --- a/crates/codecs/tests/it/dpx.rs +++ b/crates/codecs/tests/it/dpx.rs @@ -323,6 +323,49 @@ fn chosen_bit_depth_sets_the_header_and_packing() { assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 })); } +/// The depth warnings of `img` written as `format` with `o`. +fn depth_warnings(img: &Image, format: Format, o: &EncodeOptions) -> Vec { + fidelity_warnings_with(img, format, o) + .into_iter() + .filter(|w| matches!(w, FidelityWarning::DepthReduced { .. } | FidelityWarning::BitsReduced { .. })) + .collect() +} + +#[test] +fn saving_sixteen_bits_at_ten_or_twelve_warns_with_the_stored_bits() { + let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::>()).unwrap(); + for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] { + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits }]); + assert_eq!(FidelityWarning::BitsReduced { from: SampleType::U16, bits }.to_string(), format!("16-bit will be reduced to {bits}-bit")); + // Float samples name the stored bits too, not the 16-bit words they are packed in. + let float = img.convert(ChannelLayout::Rgb, SampleType::F32); + assert_eq!(depth_warnings(&float, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::F32, bits }]); + // 8-bit samples widen into 10 and 12 bits without loss. + let eight = img.convert(ChannelLayout::Rgb, SampleType::U8); + assert_eq!(depth_warnings(&eight, Format::Dpx, &opts(Some(choice))), []); + } + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits16))), []); + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(None)), []); + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits8))), [FidelityWarning::DepthReduced { from: SampleType::U16, to: SampleType::U8 }]); + assert_eq!(depth_warnings(&img, Format::Cineon, &EncodeOptions::default()), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]); +} + +#[test] +fn a_frame_opened_at_ten_or_twelve_bits_saves_back_without_a_warning() { + let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::>()).unwrap(); + for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] { + let opened = decode(&encode(&img, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap(); + assert_eq!(depth_warnings(&opened, Format::Dpx, &opts(Some(choice))), [], "{bits}-bit"); + assert_eq!(decode(&encode(&opened, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap(), opened, "{bits}-bit"); + } + // A 12-bit frame saved at 10 bits does lose its two low bits. + let twelve = decode(&encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits12))).unwrap()).unwrap(); + assert_eq!(depth_warnings(&twelve, Format::Dpx, &opts(Some(DpxBits::Bits10))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]); + let cineon = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap(); + assert_eq!(depth_warnings(&cineon, Format::Cineon, &EncodeOptions::default()), []); + assert_eq!(depth_warnings(&cineon, Format::Dpx, &opts(Some(DpxBits::Bits10))), []); +} + #[test] fn gray_with_alpha_is_written_as_luma_with_a_warning() { let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap(); diff --git a/crates/codecs/tests/it/fidelity.rs b/crates/codecs/tests/it/fidelity.rs index 2c584a559a..2cdaf20c5e 100644 --- a/crates/codecs/tests/it/fidelity.rs +++ b/crates/codecs/tests/it/fidelity.rs @@ -180,6 +180,7 @@ fn every_warning_has_a_message() { let all = [ W::WriteUnsupported { format: Format::Avif }, W::DepthReduced { from: SampleType::F32, to: SampleType::F16 }, + W::BitsReduced { from: SampleType::U16, bits: 10 }, W::RangeClipped, W::AlphaDiscarded, W::AlphaBinarized, @@ -209,9 +210,19 @@ fn warnings_match_actual_roundtrip_behaviour() { let img = test_image(l, s); let w = warns(&img, f); let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); - let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. })); + let depth_reduced = w.iter().any(|w| match w { + W::DepthReduced { .. } => true, + W::BitsReduced { from, .. } => from.is_float(), + _ => false, + }); let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8; assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type()); + // Cineon stores 10 bits: 16-bit samples come back changed exactly when warned. + if f == Format::Cineon && s == SampleType::U16 && back.sample_type() == s { + let bits_reduced = w.iter().any(|w| matches!(w, W::BitsReduced { .. })); + let changed = back.to_u16_samples() != img.convert(back.layout(), s).to_u16_samples(); + assert_eq!(bits_reduced, changed, "{f:?} {l:?} {w:?}"); + } let alpha_lost = l.has_alpha() && !back.layout().has_alpha(); assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}"); let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk(); From 272554f971cb150a8e82a3c1f466a370346e4f84 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:38:36 +0100 Subject: [PATCH 09/40] fix(type): type.edit with new text keeps the styles of unchanged text (#8766) * fix(type): type.edit with new text keeps the styles of unchanged text `type.edit {"text"}` replaced the whole string, so every character took the first run's style and other styled ranges were lost. It now replaces only the span that differs (after the common prefix and suffix), so the unchanged text keeps its character and paragraph styles and an added character takes the style beside it, as the params doc promised. Fixes #8489 Co-Authored-By: Claude Opus 5.5 * fix(type): text inserted at a paragraph's start joins that paragraph Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/type_cmds.rs | 90 +++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 820338bf2d..82ac7a7915 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -540,14 +540,37 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P /// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its /// first character (as Photoshop does, so retyping a smaller second line stays small); inserting /// at a caret takes the style of the character before it (or after it at the start). +/// The part of `old` that differs from `new`: its byte range in `old` and the text that replaces +/// it, after the longest common prefix and then the longest common suffix that doesn't overlap it +/// (both on character boundaries). +fn changed_span<'a>(old: &str, new: &'a str) -> (usize, usize, &'a str) { + let prefix = old.char_indices().zip(new.chars()).find(|((_, a), b)| a != b).map_or_else(|| old.len().min(new.len()), |((i, _), _)| i); + let (old_rest, new_rest) = (&old[prefix..], &new[prefix..]); + let suffix: usize = old_rest.chars().rev().zip(new_rest.chars().rev()).take_while(|(a, b)| a == b).map(|(a, _)| a.len_utf8()).sum(); + (prefix, old.len() - suffix, &new[prefix..new.len() - suffix]) +} + pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); let mut at = 0; + // Paragraph runs only: the non-empty paragraph that starts exactly at an insertion point. + let starts_here = || { + let mut s = 0; + runs.iter().position(|r| { + let here = s == a && r.0 > 0; + s += r.0; + here + }) + }; let host = if trailing_paragraph && a == b && runs.last().is_some_and(|r| r.0 == 0) && a == runs.iter().map(|r| r.0).sum::() { // Inserted text belongs to a styled final empty paragraph, not to // the preceding paragraph whose break ends at this byte offset. runs.len() - 1 + } else if let Some(next) = (trailing_paragraph && a == b && a > 0).then(starts_here).flatten() { + // Text inserted at the start of a paragraph belongs to that paragraph, not to the one + // whose break ends there (#8489). + next } else { runs.iter() .scan(0, |acc, r| { @@ -965,8 +988,11 @@ pub fn specs() -> Vec { }); } if let Some(v) = p.get("text").and_then(Value::as_str) { - let n = t.text.len(); - replace_text(t, 0, n, v); + // Replace only what changed, so the unchanged text keeps its character and + // paragraph styles (#8489) instead of the whole text taking the first one. + let v = norm_text(v); + let (a, b, new) = changed_span(&t.text, &v); + replace_text(t, a, b, new); } if let Some(r) = p.get("replace") { let a = byte_at(&t.text, r.get("start").and_then(Value::as_u64).unwrap_or(0) as usize); @@ -1676,6 +1702,66 @@ mod tests { out } + /// #8489: `type.edit {"text"}` changes only what differs, so the rest keeps its styles: an + /// added character takes the style beside it, and a change inside one run stays in it. + #[test] + fn edit_text_keeps_the_styles_of_unchanged_text() { + // (characters, colour) per run. + fn runs(s: &Session, id: u64) -> Vec<(usize, [u8; 4])> { + let t = text_layer(s, id); + let mut at = 0; + t.char_runs() + .into_iter() + .map(|r| { + let n = t.text[at..at + r.len].chars().count(); + at += r.len; + (n, r.style.color.to_rgba8()) + }) + .collect() + } + let (dark, purple) = ([0x1b, 0x1f, 0x4b, 255], [0x5a, 0x3d, 0xf0, 255]); + let mut s = session(); + let id = s.execute("type.create", json!({"x": 20, "y": 50, "text": "Craft Suite in Workshop", "size": 40, "color": "#1b1f4b"})).unwrap()["layer"] + .as_u64() + .unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [15, 23], "color": "#5a3df0"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (8, purple)]); + s.execute("type.edit", json!({"layer": id, "text": "Craft Suite in Workshop!"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (9, purple)], "the added character joins the last run"); + s.execute("type.edit", json!({"layer": id, "text": "Craft Suite in Worksh\u{f6}p!"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (9, purple)], "a change inside a run stays in it"); + s.execute("type.edit", json!({"layer": id, "text": "Kraft Suite in Worksh\u{f6}p!"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (9, purple)]); + // Replacing everything leaves one style, as before. + s.execute("type.edit", json!({"layer": id, "text": "Hello"})).unwrap(); + assert_eq!(text_layer(&s, id).text, "Hello"); + assert_eq!(runs(&s, id).iter().map(|r| r.0).sum::(), 5); + s.execute("type.edit", json!({"layer": id, "text": ""})).unwrap(); + assert_eq!(text_layer(&s, id).text, ""); + + // Text inserted at the start of a paragraph joins that paragraph, which keeps its style. + let id = s + .execute( + "type.create", + json!({"x": 20, "y": 50, "text": "a +b", "size": 20}), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [2, 3], "align": "right"})).unwrap(); + s.execute( + "type.edit", + json!({"layer": id, "text": "a +Xb"}), + ) + .unwrap(); + let paragraphs: Vec<(usize, photocraft_doc::text::TextAlign)> = + text_layer(&s, id).paragraph_runs().into_iter().map(|r| (r.len, r.style.align)).collect(); + assert_eq!(paragraphs.first().map(|p| p.0), Some(2), "{paragraphs:?}"); + assert_eq!(paragraphs.get(1).copied(), Some((2, photocraft_doc::text::TextAlign::Right)), "{paragraphs:?}"); + } + /// #206: `type.edit {"kerning"}` over a range takes a number (manual, 1/1000 em) or a mode. #[test] fn edit_sets_kerning_over_a_range() { From b8ee65e0308954d45ae05e1d76ce4077f8803cf1 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:38:42 +0100 Subject: [PATCH 10/40] test(text): per-colour passes match full-size passes (#8716) The rasteriser made one coverage pass per distinct colour over the whole text's ink rect (nine with faux bold), so text with many coloured runs cost colours x area: 400 colours of faux-bold 48 pt text took 13.0 s. Each colour's pass now covers only that colour's glyph bounds (all bold offsets and decorations included) and composites into that part of the accumulator: 163 ms for the same text. A test compares the visible (premultiplied) pixels with full-size passes for plain, faux bold, underlined, rotated and warped text in RGB and CMYK. Part of #8602 Co-authored-by: Claude Opus 5.5 --- crates/text/src/tests.rs | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 83f699fc85..c341722703 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2666,3 +2666,52 @@ fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() { assert_eq!(l.lines.len(), 2, "{:?}", l.lines); assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines); } + +/// #8602: each colour is rasterised only over its own glyphs instead of the whole text, and the +/// pixels match a full-size pass per colour (plain, faux bold, underlined, rotated and warped +/// text; RGB and CMYK), up to float rounding in the coverage sums. +#[test] +fn per_colour_passes_match_full_size_passes() { + use photocraft_doc::text::TextWarp; + let mut e = TextEngine::new(); + let text = "Many coloured runs of text, one colour a letter, across two lines\nof a point text layer"; + let colours = [Color::rgb(0.9, 0.1, 0.1), Color::rgb(0.1, 0.7, 0.2), Color::rgb(0.1, 0.2, 0.9), Color::rgb(0.6, 0.6, 0.1)]; + let runs = |bold: bool| { + text.char_indices() + .map(|(i, ch)| { + let colour = colours[i % colours.len()]; + let style = CharStyle { size_pt: 28.0, color: colour, faux_bold: bold && i % 3 == 0, underline: i % 5 == 0, ..Default::default() }; + TextRun { len: ch.len_utf8(), style } + }) + .collect::>() + }; + let cases = [ + TextLayer { text: text.into(), runs: runs(false), transform: Affine::translate(10.0, 40.0), ..Default::default() }, + TextLayer { text: text.into(), runs: runs(true), transform: Affine::rotate(0.3).then(&Affine::translate(30.0, 90.0)), ..Default::default() }, + TextLayer { + text: text.into(), + runs: runs(true), + transform: Affine::translate(10.0, 60.0), + warp: Some(TextWarp { style: "warpArc".into(), value: 40.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }), + ..Default::default() + }, + ]; + for (k, t) in cases.iter().enumerate() { + let l = e.layout(t, 72.0); + let warp = crate::render::layout_deform(&l, t); + for format in [PixelFormat::RGBA8, PixelFormat::CMYKA8] { + let fast = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), true); + let full = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), false); + assert_eq!(fast.rect, full.rect, "case {k}"); + let (a, b) = (fast.surface.read_region(fast.rect), full.surface.read_region(full.rect)); + assert!(!a.is_empty()); + // Compare what shows: colour weighted by alpha (a fully transparent pixel's colour is moot). + let ch = format.channels(); + let shown = |px: &[f32]| { + px.chunks_exact(ch).flat_map(|p| p[..ch - 1].iter().map(|c| c * p[ch - 1]).chain([p[ch - 1]]).collect::>()).collect::>() + }; + let worst = shown(&a).iter().zip(&shown(&b)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(worst <= 1.0 / 255.0, "case {k} {format:?}: largest difference {worst}"); + } + } +} From 51e45fd394498ba064239fdbefc7649e15415efb Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:38:48 +0100 Subject: [PATCH 11/40] fix(engine): refuse partial Color Balance and Hue/Saturation ATN exports (#8701) * fix(engine): refuse partial Color Balance and Hue/Saturation ATN exports A `layer.setAdjustment` step changes only the settings it names, but the Color Balance and Hue/Saturation descriptors written to the ATN carry every setting and filled absent ones with defaults, so a partial edit replayed as one that also reset the rest. Strict export now refuses such a step (with skipUnsupported it is listed in Skipped); complete ones export and round-trip as before. Fixes #8531 Co-Authored-By: Claude Opus 5.5 * fix(engine): refuse Hue/Saturation ranges missing their own settings A colour range written without its own hue, saturation and lightness would replay with the missing ones reset to 0, so strict export refuses it like a partial master setting. Co-Authored-By: Claude Opus 5.5 * fix(engine): refuse wrong-shaped Color Balance and Hue/Saturation settings Strict export now checks the triplets, numbers and flags it needs are of the shape the descriptor writer reads, since it would write anything else as 0. Co-Authored-By: Claude Opus 5.5 * fix(engine): refuse malformed Hue/Saturation range bounds Co-Authored-By: Claude Opus 5.5 * test(engine): cover malformed and valid Hue/Saturation range bounds Co-Authored-By: Claude Opus 5.5 * fix(engine): refuse fractional Hue/Saturation range bounds The descriptor stores them as integers, so a fractional bound would be rounded on export. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/atn_cmds.rs | 32 ++++++++++++++++++- crates/engine/src/atn_cmds/tests.rs | 49 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ac1d2df8a0..a3f7b0e0da 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1661,7 +1661,37 @@ fn set_to(id: &str, p: &Value) -> Option { "layer.setAdjustment" => { // The adjustment kind isn't in the params: Photoshop needs it, so only steps that // name one (`kind`) export. - let a = adjustment_by_suffix(jstr(p, "kind")?)?; + let kind = jstr(p, "kind")?; + // `layer.setAdjustment` changes only the settings it names. Color Balance and + // Hue/Saturation descriptors carry every setting, filling absent ones with defaults, + // so a partial edit would replay as one that also resets the rest (#8531). + // Present and of the shape the writer reads: anything else it would write as 0. + let number = |p: &Value, k: &str| p.get(k).and_then(Value::as_f64).is_some_and(f64::is_finite); + let numbers = |p: &Value, keys: &[&str]| keys.iter().all(|k| number(p, k)); + let flag = |k: &str| p.get(k).is_some_and(Value::is_boolean); + let triplet = + |k: &str| p.get(k).and_then(Value::as_array).is_some_and(|a| a.len() == 3 && a.iter().all(|v| v.as_f64().is_some_and(f64::is_finite))); + let complete = match kind { + "colorBalance" => ["shadows", "midtones", "highlights"].iter().all(|k| triplet(k)) && flag("preserveLuminosity"), + // Every colour range written carries its own hue, saturation and lightness too. + "hueSaturation" => { + flag("colorize") + && numbers(p, &["hue", "saturation", "lightness"]) + && HUE_RANGES.iter().filter_map(|r| p.get(*r).filter(|v| !v.is_null())).all(|r| { + // Bounds, when given, are the four whole numbers `hue_entry` writes (as integers); + // otherwise it drops or rounds them. + let bounds = r.get("range").filter(|v| !v.is_null()).is_none_or(|v| { + v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(|b| b.as_f64().is_some_and(|b| b.is_finite() && b.fract() == 0.0))) + }); + r.is_object() && numbers(r, &["hue", "saturation", "lightness"]) && bounds + }) + } + _ => true, + }; + if !complete { + return None; + } + let a = adjustment_by_suffix(kind)?; desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor((a.to)(p)?)) } _ => return None, diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 60236b57c3..065080b02e 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -2053,6 +2053,55 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() { } } +/// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written +/// (their descriptors carry every setting, filling absent ones with defaults), so strict export +/// refuses it instead of replaying an edit that also resets the rest. Complete ones still export. +#[test] +fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() { + let one = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.setAdjustment".into(), p)], ..Default::default() }; + for p in [ + json!({"kind": "colorBalance", "shadows": [32, -24, 16]}), + json!({"kind": "colorBalance", "preserveLuminosity": true}), + json!({"kind": "hueSaturation", "hue": 20}), + json!({"kind": "hueSaturation", "reds": {"hue": 10}}), + ] { + let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err(); + assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}"); + let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}"); + } + let full = json!({"kind": "colorBalance", "shadows": [32, -24, 16], "midtones": [-17, 27, -31], "highlights": [11, -19, 23], "preserveLuminosity": false}); + let (bytes, _) = export("Audit8", &[&one(full)], &[], false).unwrap(); + let steps = import(&bytes).unwrap().actions[0].steps.clone(); + let (id, p) = steps.first().unwrap(); + assert_eq!(id, "layer.setAdjustment"); + assert_eq!(p.get("midtones"), Some(&json!([-17, 27, -31])), "{p}"); + assert_eq!(p.get("preserveLuminosity"), Some(&json!(false)), "{p}"); + let hue = json!({"kind": "hueSaturation", "colorize": false, "hue": 20, "saturation": -5, "lightness": 0}); + assert!(export("Audit8", &[&one(hue)], &[], false).is_ok()); + // A colour range written without all of its own settings would reset the rest too. + let range = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10}}); + assert!(export("Audit8", &[&one(range)], &[], false).is_err()); + let range = + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5}}); + assert!(export("Audit8", &[&one(range)], &[], false).is_ok()); + let bounded = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5, "range": [315, 345, 15, 45]}}); + assert!(export("Audit8", &[&one(bounded)], &[], false).is_ok()); + // Values of the wrong shape would be written as 0: refused too. + for bad in [ + json!({"kind": "colorBalance", "shadows": "invalid", "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}), + json!({"kind": "colorBalance", "shadows": [1, 2], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}), + json!({"kind": "colorBalance", "shadows": [0, 0, 0], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": "yes"}), + json!({"kind": "hueSaturation", "colorize": false, "hue": "20", "saturation": 0, "lightness": 0}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": 5}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [0, 20, 40]}}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": "invalid"}}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [315, 345.5, 15, 45]}}), + ] { + assert!(export("Audit8", &[&one(bad.clone())], &[], false).is_err(), "{bad}"); + } +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] From 5948a3fb2acdcd0a509778ec38d4bed60ab1f026 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 23 Oct 2026 23:38:57 +0200 Subject: [PATCH 12/40] fix(geom): reject non-finite affine inverses (#8651) NaN determinants bypassed the singularity check, while determinant and translation overflow could return Some with a bogus or non-finite inverse. Reject non-finite determinants and computed inverse coefficients so callers can take their existing failure paths. Cover all six coefficients with NaN and both infinities, determinant overflow, translation overflow with a finite determinant, and valid extreme translations. Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com> --- crates/geom/src/lib.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index acd422b32f..44a078dd36 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -189,14 +189,16 @@ impl Affine { pub fn determinant(&self) -> f64 { self.m[0] * self.m[3] - self.m[1] * self.m[2] } + /// Returns `None` for a near-singular matrix or non-finite determinant/inverse coefficients. pub fn inverse(&self) -> Option { let det = self.determinant(); - if det.abs() < 1e-12 { + if !det.is_finite() || det.abs() < 1e-12 { return None; } let [a, b, c, d, e, f] = self.m; let inv = 1.0 / det; - Some(Affine { m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv] }) + let inverse = Affine { m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv] }; + inverse.m.iter().all(|v| v.is_finite()).then_some(inverse) } } @@ -253,6 +255,34 @@ mod tests { assert!(Affine::scale(0.0).inverse().is_none()); } + #[test] + fn affine_inverse_rejects_non_finite_coefficients() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for index in 0..6 { + let mut transform = Affine::IDENTITY; + transform.m[index] = value; + assert!(transform.inverse().is_none(), "coefficient {index}: {value}"); + } + } + } + + #[test] + fn affine_inverse_rejects_determinant_overflow() { + assert!(Affine::scale(f64::MAX).inverse().is_none()); + } + + #[test] + fn affine_inverse_rejects_translation_overflow() { + let transform = Affine { m: [1.0, 0.0, 1.0, 1.0, -f64::MAX, f64::MAX] }; + assert_eq!(transform.determinant(), 1.0); + assert!(transform.inverse().is_none()); + } + + #[test] + fn affine_inverse_keeps_large_finite_translations() { + assert_eq!(Affine::translate(f64::MAX, -f64::MAX).inverse(), Some(Affine::translate(-f64::MAX, f64::MAX))); + } + #[test] fn affine_then_order() { let t = Affine::translate(10.0, 0.0).then(&Affine::scale(2.0)); From 41599c591c4fbb5f843bddace01bb0d1b739b933 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 03:09:02 +0530 Subject: [PATCH 13/40] Crop default mode: moving the image under the box snaps (#8580) (#8622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Crop default mode: moving the image under the box snaps (#8580) In the default (non-Classic) crop mode a drag inside the box moves the image under it, but snapping was switched off for that drag, so content couldn't be centred in a larger box without leaving for the Move tool. The drag now snaps like a Classic Mode frame move: the frame moves by the image's travel the other way over the document, its edges and centre snap to the document, layers, guides and grid (smart guides when those are empty), and the pointer is corrected in the press camera, as crop_ui reads the drag. A turned box still doesn't snap. Fixes #8580 Co-Authored-By: Claude Opus 5.5 * Crop image move: smart guides snap when no Snap To target is in reach (#8622 review) The document's bounds keep the regular targets non-empty, so the smart guides were never tried: with View › Snap To › Layers off, the box never snapped to a layer while the image moved under it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Khawar Zamman Wani --- crates/ui-egui/src/crop_mode.rs | 99 ++++++++++++++++++++++++++++++++- crates/ui-egui/src/snap_ui.rs | 42 +++++++++++++- docs/ui-parity.md | 4 +- 3 files changed, 140 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index ee684517bb..36bc4d659a 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -3,8 +3,9 @@ //! - **Default mode** (Photoshop's default, Use Classic Mode off): the crop box stays upright on //! screen and the image turns behind it. A drag outside the box turns the image about the box's //! centre (⇧ in 15° steps; the readout beside the pointer is the image's turn, clockwise -//! positive), a drag inside moves the image under the box (the box stays put), the arrow keys -//! nudge the image, and the handles resize the box on screen as usual. With **Auto Center +//! positive), a drag inside moves the image under the box (the box stays put; with View › Snap +//! on, the upright frame's edges and centre snap as a Classic Mode frame's do, #8580), the arrow +//! keys nudge the image, and the handles resize the box on screen as usual. With **Auto Center //! Preview** on (the default), a box being resized keeps its centre where it is on screen (also //! after a Space pan) while the handle follows the pointer, a drawn box moves to the middle of //! the canvas on release, and X re-centres it. A drag inside never re-centres the box @@ -425,6 +426,100 @@ mod tests { assert!(r.iter().zip([20.0, 30.0, 80.0, 70.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{r:?}"); } + /// Moves the image under the box from document point `from` by `d` (both in the view at the + /// press), checks the snap lines before release, and returns where the box's centre started + /// on screen. + fn image_drag(app: &mut PhotocraftApp, from: [f32; 2], d: [f32; 2], line: Option<(bool, f64)>) -> Pos2 { + let (a, b) = (xf(app).to_screen(from[0], from[1]), xf(app).to_screen(from[0] + d[0], from[1] + d[1])); + let mid = mid_of(screen_box(app)); + let p = xf(app).to_doc(a); + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, NONE); + assert_eq!(app.prefs_rt.snap.as_ref().map(|s| &s.gesture), Some(&crate::snap_ui::Gesture::CropImage)); + for t in [0.5, 1.0] { + let p = xf(app).to_doc(a + (b - a) * t); + tool_event(app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, NONE); + } + if let Some((vertical, pos)) = line { + assert!(app.prefs_rt.snap_lines.iter().any(|l| l.vertical == vertical && l.pos == pos), "{:?}", app.prefs_rt.snap_lines); + } + let p = xf(app).to_doc(b); + tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, NONE); + mid + } + + fn assert_frame(app: &PhotocraftApp, want: [f64; 4], what: &str) { + let r = app.ui.crop_rect.unwrap(); + assert!(r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-3), "{what}: {r:?}, want {want:?}"); + } + + /// #8580: moving the image under the box snaps the frame's edges and centre with View › Snap + /// on, so the content can be centred in it without leaving the Crop tool. The box stays put. + #[test] + fn moving_the_image_snaps_the_frame_to_the_document_centre() { + for (snap, zoom, flip) in [(false, 1.0, false), (true, 1.0, false), (true, 2.0, false), (true, 1.0, true)] { + let mut app = app(); + app.ui.extras.snap = snap; + app.ui.views[0].zoom = zoom; + app.ui.view.flip_horizontal = flip; + with_frame(&mut app); + // The frame [40, 30, 100, 70] goes 27 px right over the image: its centre lands 3 px + // short of the document's (100, 50), inside the snap radius at either zoom. + let mid = image_drag(&mut app, [70.0, 50.0], [-27.0, 0.0], snap.then_some((true, 100.0))); + let what = format!("snap {snap}, zoom {zoom}, flip {flip}"); + assert_frame(&app, if snap { [70.0, 30.0, 130.0, 70.0] } else { [67.0, 30.0, 127.0, 70.0] }, &what); + assert!(mid_of(screen_box(&app)).distance(mid) < 0.01, "{what}: the box moved on screen"); + assert!(app.prefs_rt.snap.is_none() && app.prefs_rt.snap_lines.is_empty()); + } + } + + /// The issue's case: a box larger than the canvas (to extend it) centres on the content's + /// layer, both ways. A turned box doesn't snap (its edges don't line up with anything). + #[test] + fn moving_the_image_centres_a_larger_box_on_a_layer() { + let mut app = app(); + app.ui.extras.snap = true; + app.run("layer.new.layer", json!({"name": "logo"})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + crate::crop_ui::ensure_frame(&mut app); + app.ui.crop_rect = Some([-40.0, -60.0, 140.0, 120.0]); + app.crop.default_frame = false; + // Its centre (50, 30) goes to (42, 38): 2 px from the logo's centre (40, 40). + image_drag(&mut app, [50.0, 30.0], [8.0, -8.0], Some((false, 40.0))); + assert_frame(&app, [-50.0, -50.0, 130.0, 130.0], "centred on the logo"); + + app.ui.crop_angle = 30.0; + sync(&mut app, None); + let p = xf(&app).to_doc(mid_of(screen_box(&app))); + tool_event(&mut app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, NONE); + assert!(matches!(app.crop.drag, Some(CropDrag::MoveImage { .. }))); + assert!(app.prefs_rt.snap.is_none(), "a turned box doesn't snap"); + tool_event(&mut app, ToolEvent::Up { x: p[0], y: p[1] }, NONE); + } + + /// With View › Snap To › Layers off, smart guides still centre the box on a layer, although + /// the document's bounds are snap targets that aren't within reach. + #[test] + fn moving_the_image_snaps_to_smart_guides_beside_out_of_reach_targets() { + for smart in [false, true] { + let mut app = app(); + app.ui.extras.snap = true; + app.ui.view.snap_to.layers = false; + app.ui.view.show.smart_guides = smart; + app.run("layer.new.layer", json!({"name": "logo"})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + crate::crop_ui::ensure_frame(&mut app); + app.ui.crop_rect = Some([-40.0, -60.0, 140.0, 120.0]); + app.crop.default_frame = false; + image_drag(&mut app, [50.0, 30.0], [8.0, -8.0], smart.then_some((false, 40.0))); + let want = if smart { [-50.0, -50.0, 130.0, 130.0] } else { [-48.0, -52.0, 132.0, 128.0] }; + assert_frame(&app, want, &format!("smart guides {smart}")); + } + } + /// #7088: the box's own Undo/Redo of a move (or a turn) in default mode puts the frame back /// and moves the image, not the box: it stays put on screen (Auto Center Preview off) or goes /// to the middle of the canvas (on). diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index b83646af29..4bead9828a 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -36,6 +36,9 @@ pub enum Gesture { DrawMove { rect: [f64; 4] }, /// A guide being moved with the Move tool. Guide, + /// Default crop mode, a drag inside an upright box: the image moves under it, so the frame + /// moves the other way over the document and its edges and centre snap ([`crop_image`]). + CropImage, } /// Snapping state of the drag in progress. @@ -238,12 +241,21 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // its edges. let gesture = if app.crop.default_frame { Gesture::Point } else { Gesture::Move { rect } }; Some((gesture, Vec::new())) + } else if tool == Tool::Crop + && crate::crop_mode::moves_image(app) + && !app.crop.default_frame + && crate::crop_ui::angle(app) == 0.0 + && app.ui.crop_rect.is_some_and(|r| crate::crop_ui::hit(r, p, crop_tol) == crate::crop_ui::Hit::Inside) + { + // The default mode moves the image under an upright box: the frame's edges and centre + // snap, as an edited Classic Mode frame's do, so content can be centred in it (#8580). + Some((Gesture::CropImage, Vec::new())) } else if tool == Tool::Crop && (crate::crop_ui::turns_at(app, p) || app.ui.crop_rect.is_some_and(|r| crate::crop_ui::hit_turned(r, crate::crop_ui::angle(app), p, crop_tol) == crate::crop_ui::Hit::Inside)) { // Turning the frame, moving a turned one (its edges don't line up with anything), or in - // the default mode moving or turning the image under the box: no snapping. + // the default mode turning the image or moving it under a turned box: no snapping. None } else if let Some(cut) = crate::canvas::selection_drag_kind(app, tool, p, mods) { // A marquee or lasso inside the selection moves the outline (⌘: cuts its pixels into a @@ -276,7 +288,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // A moving selection never snaps back to where it started. targets = targets.filtered(|k| k != SnapKind::Selection); } - let smart = if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; + let smart = if snap_on(app) && matches!(gesture, Gesture::Move { .. } | Gesture::CropImage) && smart_on(app) { + build(app, &exclude, true) + } else { + SnapTargets::default() + }; app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); } @@ -324,6 +340,12 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 let (adj, lines) = set.snap_rect(moved, tol); ([p[0] + adj[0], p[1] + adj[1]], lines) } + Gesture::CropImage => { + // Smart guides still snap when nothing in View › Snap To is within reach: the + // document's bounds alone keep `targets` from ever being empty. + let snapped = |set| crop_image(app, p, set, tol).filter(|(_, lines)| !lines.is_empty()); + snapped(&snap.targets).or_else(|| snapped(&snap.smart)).unwrap_or((p, Vec::new())) + } }; app.prefs_rt.snap_lines = lines; out @@ -413,6 +435,22 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines)) } +/// Snap the image being moved under the crop box (default mode): the frame moves by the image's +/// travel the other way over the document, so its edges and centre snap and the pointer moves by +/// the frame's correction the other way too (#8580). As `crop_ui` reads this drag, the pointer is +/// read in the camera at the press, since the view pans with the frame meanwhile. +fn crop_image(app: &PhotocraftApp, p: [f64; 2], targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec)> { + let crate::crop_ui::CropDrag::MoveImage { start, rect, view } = app.crop.drag? else { return None }; + let q = crate::crop_mode::to_press(app, view, p); + let d = [q[0] - start[0], q[1] - start[1]]; + let (adj, lines) = targets.snap_rect([rect[0] - d[0], rect[1] - d[1], rect[2] - d[0], rect[3] - d[1]], tol); + if adj == [0.0, 0.0] { + return Some((p, lines)); + } + let q = [q[0] - adj[0], q[1] - adj[1]]; + Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines)) +} + /// The bounds `[x0, y0, x1, y1]` of what drag `d` draws so far, for the tools whose drag Space /// repositions and that snap (`None` for the rest, or a shape still too small to draw). fn drawn_rect(app: &PhotocraftApp, d: &crate::canvas::Drag) -> Option<[f64; 4]> { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8afece06db..f1ed065ced 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (View › Extras hides the Move transform controls, #6699; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -148,6 +148,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Overlays (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O | done | TOOL-214-20 | | Crop shield, Show Cropped Area (H) | done | TOOL-214-22 | | Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 | +| Default mode: moving the image under an upright box snaps the frame's edges and centre (View › Snap), so content centres in the box without the Move tool | done | `crop_mode.rs::moving_the_image_snaps_the_frame_to_the_document_centre`, `crop_mode.rs::moving_the_image_centres_a_larger_box_on_a_layer` (#8580, 2026-10-22) | | W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) | | Saved custom crop presets (New / Delete Crop Preset, kept with the preferences; moving them to another computer is not offered) | done | TOOL-214-9 (`crop_presets.rs`, `tool.presets.*` with tool `crop`, #2723) | | Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/it/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) | @@ -240,6 +241,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Crop default mode: a drag inside an upright box snaps the frame's edges and centre to the document, layers, guides and grid, as a Classic Mode frame's move does (#8580); no percentage changed | | 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | | 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed | | 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) | From 13e4d8ceededffca9fcb073d59a491994efdde8a Mon Sep 17 00:00:00 2001 From: ZFzuofeng <84183556+ZFzuofeng@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:39:08 +0800 Subject: [PATCH 14/40] fix(engine): select files correctly in Windows Explorer (#8518) --- crates/engine/src/file_cmds/tests.rs | 12 ++++++++++++ crates/engine/src/layer_menu_cmds.rs | 3 ++- crates/engine/src/layer_menu_cmds/tests.rs | 16 +++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3115d713b6..9132af44d9 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1416,6 +1416,18 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { assert_eq!(r["args"], json!(args)); } +#[cfg(windows)] +#[test] +fn reveal_in_finder_on_windows_passes_the_clicked_tabs_saved_path() { + let mut s = session(8, 8, 8); + s.active_mut().unwrap().path = Some("C:/项目 图片/海报 🖼.png".into()); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "other"})).unwrap(); + + let r = s.execute("file.revealInFinder", json!({"document": 0, "dryRun": true})).unwrap(); + assert_eq!(r["program"], "explorer"); + assert_eq!(r["args"], json!(["/select,", r"C:\项目 图片\海报 🖼.png"])); +} + /// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in /// subfolders too, each result keeping its subfolder under the output folder (so same-named files /// don't collide), and skip the output folder when it sits inside the input folder. diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 9165990122..f86d18b646 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -421,7 +421,8 @@ fn reveal_command_on(os: &str, path: &str) -> Result<(&'static str, Vec) // Explorer splits `/select,` arguments at commas and reads quotes itself, so such a path // would select something else. "windows" if path.contains([',', '"']) => Err(other(format!("{path} can't be shown in Explorer: its path contains a comma or a quotation mark"))), - "windows" => Ok(("explorer", vec![format!("/select,{path}")])), + // Keep the switch outside the path's quotes: Explorer parses its own command line. + "windows" => Ok(("explorer", vec!["/select,".into(), path.replace('/', "\\")])), _ => { let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); Ok(("xdg-open", vec![parent])) diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 4f5c6dc12f..b0cceb72a8 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -541,12 +541,26 @@ fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() { s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); assert_eq!(reveal_command_on("macos", "-a.png").unwrap(), ("open", vec!["-R".to_string(), "--".into(), "-a.png".into()])); - assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec![r"/select,C:\pics\a.png".to_string()])); + assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec!["/select,".to_string(), r"C:\pics\a.png".to_string()])); for path in [r"C:\pics\a,b.png", r#"C:\pics\a".png"#] { assert!(reveal_command_on("windows", path).is_err(), "{path}"); } } +/// Explorer parses `/select,` itself: it must remain outside the quoted file path. +#[test] +fn reveal_on_windows_keeps_the_select_switch_separate_from_the_path() { + for (path, expected) in [ + (r"C:\my pictures\a file.png", r"C:\my pictures\a file.png"), + ("C:/my pictures/a file.png", r"C:\my pictures\a file.png"), + (r"C:\my pictures/a file.png", r"C:\my pictures\a file.png"), + ("C:/项目 图片/海报 🖼.png", r"C:\项目 图片\海报 🖼.png"), + ("//server/share/my pictures/a file.png", r"\\server\share\my pictures\a file.png"), + ] { + assert_eq!(reveal_command_on("windows", path).unwrap(), ("explorer", vec!["/select,".to_string(), expected.to_string()]), "{path}"); + } +} + /// `xdg-open` launches a file it is given, so Reveal hands it the link's folder only when that is a /// folder: a link stored as `tool.jar/a.png` names a file, not a folder, as its parent. #[test] From 45833ece7cfb7bfd1b93a751fc63d7246c83725c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:39:13 +0200 Subject: [PATCH 15/40] Fix EngineData format_real emitting a bare trailing dot (#8486) Found while testing crates/text/src/engine_data.rs:331 (format_real) Co-authored-by: Claude Haiku 5.5 --- crates/text/src/engine_data.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index a68d093669..19f3af1886 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -342,6 +342,9 @@ fn format_real(r: f64) -> String { if matches!(s.as_str(), "0." | "-0.") { return "0.0".into(); } + if s.ends_with('.') { + s.push('0'); + } if let Some(rest) = s.strip_prefix("0.") { s = format!(".{rest}"); } else if let Some(rest) = s.strip_prefix("-0.") { @@ -356,6 +359,18 @@ mod tests { const SAMPLE: &[u8] = b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor\n\t\t<<\n\t\t\t/Text (\xFE\xFF\x00H\x00i\x00\\)\x00\r)\n\t\t>>\n\t\t/Nums [ 1.0 .5 -2 0 ]\n\t\t/Flag true\n\t\t/Kind /Roman\n\t\t/Runs [\n\t\t<<\n\t\t\t/A 1\n\t\t>>\n\t\t]\n\t>>\n>>"; + #[test] + fn format_real_never_ends_with_bare_dot() { + assert_eq!(format_real(5.000001), "5.0"); + assert_eq!(format_real(12.000004), "12.0"); + assert_eq!(format_real(-5.000001), "-5.0"); + assert_eq!(format_real(1e20), "100000000000000000000.0"); + assert_eq!(format_real(0.5), ".5"); + assert_eq!(format_real(-0.25), "-.25"); + assert_eq!(format_real(1e-9), "0.0"); + assert_eq!(format_real(3.0), "3.0"); + } + #[test] fn parses_sample() { let v = parse(SAMPLE).unwrap(); From 151eae66bebd41c1b217fa94e4c2e44feaa46c8b Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:39:19 -0500 Subject: [PATCH 16/40] fix(app): no unused screen picker code on FreeBSD (#8469) FreeBSD (and every other OS without a capture backend) compiles the screen colour picker's stub capture, which never reads monitor placements or builds images. The build warned about the unused ScreenImage import, the never-read Placement fields, find_monitor and count. Compile those only where a capture backend or the tests use them. Seen in the FreeBSD aarch64 CI build; checked with `cargo check -p photocraft --all-targets` on FreeBSD 15.1 aarch64 (no warnings) and macOS. --- apps/photocraft/src/screen_color.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/screen_color.rs b/apps/photocraft/src/screen_color.rs index 8a0d4ca901..ec8cd6378c 100644 --- a/apps/photocraft/src/screen_color.rs +++ b/apps/photocraft/src/screen_color.rs @@ -1,6 +1,11 @@ //! Desktop colour capture starts only on an explicit UI request. No snapshots are written to //! disk, transmitted or retained after a pick. Wayland delegates to its permission portal. -use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service}; +use photocraft_ui_egui::screen_picker::{Capture, Pending, Service}; +// Only the platform capture backends (and the tests) build images and check sizes. +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] +use photocraft_ui_egui::screen_picker::MAX_PIXELS; +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +use photocraft_ui_egui::screen_picker::ScreenImage; pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option { if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) { @@ -34,6 +39,7 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option Option { placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index) } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] fn count(width: usize, height: usize) -> Result { width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into()) } From 2d9640bb0b34bb59fac35567eff505753646af06 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 23 Oct 2026 15:39:24 -0600 Subject: [PATCH 17/40] fix(ui): restore extras when showing the grid (#8962) Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/ui-egui/src/menus.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 709a425837..e9721b765d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -546,6 +546,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: }; *slot = !*slot; let on = *slot; + if id == "view.show.grid" && on { + // Like the other Show items, enabling Grid also restores hidden Extras. + app.ui.view.extras = true; + } if id == "view.rulers" { crate::prefs_ui::remember_rulers(app, on); } @@ -1646,6 +1650,27 @@ pub fn apply_workspace(app: &mut PhotocraftApp) { mod tests { use super::*; + #[test] + fn showing_grid_restores_hidden_extras() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.ui.extras.grid = false; + app.ui.view.extras = false; + + assert_eq!(invoke(&mut app, &ctx, "view.show.grid", json!({})).unwrap(), json!(true)); + assert_eq!(checked(&app, "view.show.grid"), Some(true)); + assert!(app.ui.view.extras, "showing Grid must make the overlay visible when Extras was hidden"); + + assert_eq!(invoke(&mut app, &ctx, "view.show.grid", json!({})).unwrap(), json!(false)); + assert!(!app.ui.extras.grid); + assert!(app.ui.view.extras); + + app.ui.extras.grid = true; + app.ui.view.extras = false; + assert_eq!(invoke(&mut app, &ctx, "view.show.grid", json!({})).unwrap(), json!(false)); + assert!(!app.ui.view.extras, "hiding Grid must leave hidden Extras alone"); + } + #[test] fn sync_with_system_menu_keeps_saved_slots_and_its_checkmark_across_palettes() { use photocraft_engine::prefs::{AppearanceMode, DarkTheme, LightTheme}; From 316a07c8c968451045b9ce04986ce8dac0e7d1e7 Mon Sep 17 00:00:00 2001 From: jturnbach <70334515+jturnbach@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:39:30 -0400 Subject: [PATCH 18/40] =?UTF-8?q?fix(text):=20lay=20out=20a=20variable=20f?= =?UTF-8?q?ont's=20named=20instance=20from=20its=20PostScri=E2=80=A6=20(#8?= =?UTF-8?q?909)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(text): lay out a variable font's named instance from its PostScript name A PSD type style set in a named instance of a variable font, such as Bahnschrift Light Condensed, keeps the instance only as its PostScript name ("Bahnschrift-LightCondensed"); its variations are empty. When PhotoCraft re-laid the layer out, FontDb::select_named_face looked for a face with that PostScript name, or one whose style and weight matched. A variable family is a single face, so neither matched, and the text was laid out at the font's default instance: about 35% wider than the instance Photoshop drew with Bahnschrift. select_named_face now falls back to the family's variable faces when the PostScript name matches no face. It reads each face's fvar named instances and compares the name with the instance's own PostScript name, or, when the instance has none, with the name Adobe Technical Note #5902 derives (variations prefix or family name, a hyphen, then the subfamily without spaces). Names compare without punctuation or case. On a match, that face is selected and the instance's axis values are added to the style's variations, keeping any axis the style already sets. Static faces still match exactly as before, and PSD import is unchanged. Tests: - font_names_tests::postscript_name_of_a_named_instance_selects_its_axis_values rebuilds the bundled Inter at test time as a variable font (fvar with wght/wdth axes and two named instances, one with its own PostScript name and one derived) and checks the axis values each name selects, an unknown name, and a style's own axis value. It needs no installed font and no font file in the repository. - font_names_tests::exact_static_face_gets_no_instance_axes checks that an exact static match gets no axis values. - tests::postscript_name_of_a_variable_instance_lays_out_at_that_instance lays out "Hamburgefonstiv 0123" with Bahnschrift (Windows) or Ubuntu Sans (Ubuntu) when installed and checks that the instance's PostScript name gives the same width as its axis values. With Ubuntu Sans before the fix: UbuntuSans-CondensedLight 980.3 px (= the default instance), its axes 766.4 px; after the fix both are 766.4 px. A style with only a weight (no PostScript name) is still not turned into a wght axis value on a variable font; the issue reports that too, but its fix and test are about the PostScript name, so that is left for a separate change. Fixes #8797 * fix(text): try a variable font's named instance before the style fallback Review pointed out that select_named_face ran the style-and-weight fallback before the named-instance lookup. A style that carries a named instance's PostScript name and also a populated font_style matching the variable face itself ("Regular", 400) selected that face at its default instance, and the instance's axis values were never added. Faces are now tried in order: exact PostScript name, named instance, then style, weight and italic. Review also noted that the layout-width test only runs when Bahnschrift or Ubuntu Sans is installed. The synthesized variable font in font_names_tests now carries an HVAR table that narrows every advance on wdth, so layout width shows which instance was used. Two tests run everywhere: - named_instance_wins_over_a_matching_font_style - postscript_name_of_a_named_instance_lays_out_at_that_instance Both fail before this change. The installed-font test is kept as a check against a real font. --------- Co-authored-by: John Turnbach --- crates/text/src/font_names_tests.rs | 217 ++++++++++++++++++++++++++++ crates/text/src/fonts.rs | 59 +++++++- crates/text/src/tests.rs | 32 ++++ 3 files changed, 307 insertions(+), 1 deletion(-) diff --git a/crates/text/src/font_names_tests.rs b/crates/text/src/font_names_tests.rs index 8b8bcd419c..a1eda9b801 100644 --- a/crates/text/src/font_names_tests.rs +++ b/crates/text/src/font_names_tests.rs @@ -251,3 +251,220 @@ fn craft_font_collections_keep_their_face_families() { assert!(db.has_family("Craft Test A") && db.has_family("Craft Test B"), "{:?}", db.families()); assert!(!db.has_family("Craft Test Collection")); } + +/// A 16.16 fixed-point number as `fvar` stores it. +fn fixed(v: f32) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +/// `source` rebuilt as a variable font with `wght` 100–900 and `wdth` 75–100 axes and two named +/// instances, without redistributing a variable font (#8797): "Light Condensed" (300, 75) has no +/// PostScript name of its own, so its name is derived as `InstanceTest-LightCondensed`; +/// "Heavy" (900, 100) names itself `InstanceTest-Black`. Outlines do not vary, but an `HVAR` table +/// (see [`width_hvar`]) narrows every advance on `wdth`, so layout width shows which instance drew. +fn instance_font(source: &[u8]) -> Vec { + let names = [ + (1u16, "Instance Test"), + (2, "Regular"), + (6, "InstanceTest-Regular"), + (16, "Instance Test"), + (256, "Weight"), + (257, "Width"), + (258, "Light Condensed"), + (259, "InstanceTest-Black"), + (260, "Heavy"), + ]; + let mut name = Vec::new(); + let mut strings = Vec::new(); + for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] { + name.extend(value.to_be_bytes()); + } + for (id, value) in names { + let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] { + name.extend(n.to_be_bytes()); + } + strings.extend(encoded); + } + name.extend(strings); + // Two axes of 20 bytes, then two instances of 4 + 2 * 4 + 2 bytes (with PostScript name IDs). + let mut fvar = Vec::new(); + for n in [1u16, 0, 16, 2, 2, 20, 2, 14] { + fvar.extend(n.to_be_bytes()); + } + for (tag, min, default, max, name_id) in [(b"wght", 100.0, 400.0, 900.0, 256u16), (b"wdth", 75.0, 100.0, 100.0, 257)] { + fvar.extend(tag); + for v in [min, default, max] { + fvar.extend(fixed(v)); + } + fvar.extend([0, 0]); + fvar.extend(name_id.to_be_bytes()); + } + for (subfamily, coords, ps) in [(258u16, [300.0, 75.0], 0xFFFFu16), (260, [900.0, 100.0], 259)] { + fvar.extend(subfamily.to_be_bytes()); + fvar.extend([0, 0]); + for v in coords { + fvar.extend(fixed(v)); + } + fvar.extend(ps.to_be_bytes()); + } + // Rebuild the table directory: every table but `name` as it was, plus the new `name`, `fvar` + // and `HVAR`. + let count = u16::from_be_bytes(source[4..6].try_into().unwrap()) as usize; + let mut tables: Vec<([u8; 4], Vec)> = (0..count) + .map(|i| { + let r = 12 + i * 16; + let at = u32::from_be_bytes(source[r + 8..r + 12].try_into().unwrap()) as usize; + let len = u32::from_be_bytes(source[r + 12..r + 16].try_into().unwrap()) as usize; + (source[r..r + 4].try_into().unwrap(), source[at..at + len].to_vec()) + }) + .filter(|(tag, _)| tag != b"name") + .collect(); + let table = |tag: &[u8; 4]| tables.iter().find(|(t, _)| t == tag).map(|(_, data)| data.as_slice()).unwrap(); + let hvar = width_hvar(table(b"hhea"), table(b"hmtx"), table(b"maxp")); + tables.push((*b"name", name)); + tables.push((*b"fvar", fvar)); + tables.push((*b"HVAR", hvar)); + tables.sort_by_key(|(tag, _)| *tag); + let mut out = source[0..4].to_vec(); + for n in [tables.len() as u16, 0, 0, 0] { + out.extend(n.to_be_bytes()); + } + let mut at = 12 + 16 * tables.len(); + for (tag, data) in &tables { + out.extend(tag); + out.extend(0u32.to_be_bytes()); + out.extend((at as u32).to_be_bytes()); + out.extend((data.len() as u32).to_be_bytes()); + at += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + out.extend(data); + out.resize(out.len().next_multiple_of(4), 0); + } + out +} + +/// An `HVAR` table for [`instance_font`]'s axes (`wght`, then `wdth`) that takes 30% off every +/// glyph's advance at `wdth` 75 and nothing at the default 100, scaled linearly between. Advances +/// come from `hmtx`; glyph IDs map to deltas directly, as no advance mapping is given. +fn width_hvar(hhea: &[u8], hmtx: &[u8], maxp: &[u8]) -> Vec { + let glyphs = u16::from_be_bytes(maxp[4..6].try_into().unwrap()); + let metrics = u16::from_be_bytes(hhea[34..36].try_into().unwrap()) as usize; + let advance = |gid: usize| { + let at = 4 * gid.min(metrics - 1); + i32::from(u16::from_be_bytes(hmtx[at..at + 2].try_into().unwrap())) + }; + let mut out = Vec::new(); + // Header: version 1.0, item variation store at 20, no advance, LSB or RSB mappings. + for n in [1u16, 0] { + out.extend(n.to_be_bytes()); + } + for n in [20u32, 0, 0, 0] { + out.extend(n.to_be_bytes()); + } + // Item variation store: format 1, region list at 12, one item variation data at 28. + out.extend(1u16.to_be_bytes()); + out.extend(12u32.to_be_bytes()); + out.extend(1u16.to_be_bytes()); + out.extend(28u32.to_be_bytes()); + // One region over two axes as (start, peak, end) in F2Dot14: `wght` unused, `wdth` -1..0 + // peaking at -1 (75). + for n in [2u16, 1, 0, 0, 0, 0xC000, 0xC000, 0] { + out.extend(n.to_be_bytes()); + } + // Item variation data: one 16-bit delta per glyph for region 0. + for n in [glyphs, 1, 1, 0] { + out.extend(n.to_be_bytes()); + } + for gid in 0..glyphs as usize { + out.extend((-(advance(gid) * 3 / 10) as i16).to_be_bytes()); + } + out +} + +/// The axis values `select_named_face` gives a style of the synthesized variable family that +/// carries the PostScript name `ps`, sorted by axis. +fn instance_axes(db: &mut FontDb, ps: &str, variations: Vec) -> Vec<(String, f32)> { + let mut style = CharStyle { font_family: "Instance Test".into(), postscript_name: Some(ps.into()), weight: 300, variations, ..Default::default() }; + db.select_named_face(&mut style); + let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect(); + axes.sort_by(|a, b| a.0.cmp(&b.0)); + axes +} + +/// #8797: a PostScript name that names a named instance of a variable font (as a PSD stores +/// "Bahnschrift-LightCondensed") selects that instance's axis values, whether the instance names +/// itself or its name is derived from the family and subfamily names. A name no instance has +/// leaves the style at the default instance, and axes the style sets itself are kept. +#[test] +fn postscript_name_of_a_named_instance_selects_its_axis_values() { + use photocraft_doc::text::FontVariation; + let mut db = FontDb::new(); + db.register_font_data(instance_font(&INTER_REGULAR)); + assert_eq!(db.faces("Instance Test").len(), 1, "a variable family is one face"); + let axes = |pairs: &[(&str, f32)]| pairs.iter().map(|(a, v)| (a.to_string(), *v)).collect::>(); + assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", vec![]), axes(&[("wdth", 75.0), ("wght", 300.0)])); + assert_eq!(instance_axes(&mut db, "InstanceTest-Black", vec![]), axes(&[("wdth", 100.0), ("wght", 900.0)])); + assert_eq!(instance_axes(&mut db, "InstanceTest-Missing", vec![]), axes(&[])); + let own = vec![FontVariation { axis: "wght".into(), value: 350.0 }]; + assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", own), axes(&[("wdth", 75.0), ("wght", 350.0)])); +} + +/// A static face whose PostScript name matches exactly is selected as before (#8797 only adds +/// a fallback for names no face has), and gets no axis values. +#[test] +fn exact_static_face_gets_no_instance_axes() { + let mut db = FontDb::new(); + db.register_font_data(numeric_font(&INTER_SEMIBOLD, "30", "StaticPS30")); + let mut style = CharStyle { font_family: "Numeric Test".into(), postscript_name: Some("StaticPS30".into()), ..Default::default() }; + db.select_named_face(&mut style); + assert_ne!(style.font_family, "Numeric Test", "the exact face is selected through its alias"); + assert!(style.variations.is_empty(), "{:?}", style.variations); +} + +/// A style whose `font_style` and weight also match the variable face itself ("Regular", 400) still +/// gets the named instance its PostScript name gives: the instance is tried before the +/// style-and-weight fallback, which would select the face at its default instance (#8797). +#[test] +fn named_instance_wins_over_a_matching_font_style() { + let mut db = FontDb::new(); + db.register_font_data(instance_font(&INTER_REGULAR)); + let mut style = CharStyle { + font_family: "Instance Test".into(), + font_style: "Regular".into(), + weight: 400, + postscript_name: Some("InstanceTest-LightCondensed".into()), + ..Default::default() + }; + db.select_named_face(&mut style); + assert_ne!(style.font_family, "Instance Test", "the variable face is selected through its alias"); + let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect(); + axes.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(axes, vec![("wdth".to_string(), 75.0), ("wght".to_string(), 300.0)]); +} + +/// #8797 end to end without an installed font: text whose style carries a named instance's +/// PostScript name lays out as wide as that instance's axis values, which the synthesized `HVAR` +/// makes narrower than the default instance. The style's `font_style` and weight match the +/// variable face itself, so the default instance would be chosen if the instance were skipped. +#[test] +fn postscript_name_of_a_named_instance_lays_out_at_that_instance() { + use photocraft_doc::TextLayer; + use photocraft_doc::text::{FontVariation, TextRun}; + let mut engine = crate::TextEngine::new(); + engine.fonts.register_font_data(instance_font(&INTER_REGULAR)); + let text = "Hamburgefonstiv 0123"; + let mut width = |style: CharStyle| { + let layer = TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() }; + let l = engine.layout(&layer, 96.0); + l.lines[0].x1 - l.lines[0].x0 + }; + let base = CharStyle { font_family: "Instance Test".into(), font_style: "Regular".into(), size_pt: 72.0, weight: 400, ..Default::default() }; + let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }]; + let family = width(base.clone()); + let instance = width(CharStyle { postscript_name: Some("InstanceTest-LightCondensed".into()), ..base.clone() }); + let explicit = width(CharStyle { variations: axes, ..base }); + assert!(explicit < family * 0.8, "wdth 75 is narrower: {explicit} vs {family}"); + assert!((instance - explicit).abs() < 0.5, "instance: {instance} px, its axes: {explicit} px, default instance: {family} px"); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 9f43c705b1..a0b64f4e3b 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -483,10 +483,29 @@ impl FontDb { /// Select an exact face for shaping. Private aliases are runtime-only: the document keeps /// its public family and PostScript name. CSS matching alone cannot distinguish e.g. the /// numeric subfamilies of Yoon fonts, all of which declare weight 400 / normal. + /// + /// A PostScript name that no face has may name an instance of a variable face + /// ("Bahnschrift-LightCondensed"): that face is selected and the instance's axis values are + /// added to the style's variations, keeping any axis the style sets itself (#8797). Faces are + /// tried in this order: exact PostScript name, named instance, then style, weight and italic. pub(crate) fn select_named_face(&mut self, style: &mut photocraft_doc::text::CharStyle) { let family = style.font_family.clone(); let faces = self.faces(&family); - let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps))).or_else(|| { + let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps))); + // The named instance is tried before the style-and-weight fallback: a variable face's own + // style ("Regular") can match a populated `font_style` and would drop the instance's axes. + let instance = match (index, &style.postscript_name) { + (None, Some(ps)) => self.named_instance(&family, &faces, ps), + _ => None, + }; + if let Some((_, axes)) = &instance { + for (axis, value) in axes { + if !style.variations.iter().any(|v| &v.axis == axis) { + style.variations.push(photocraft_doc::text::FontVariation { axis: axis.clone(), value: *value }); + } + } + } + let index = index.or(instance.map(|(i, _)| i)).or_else(|| { faces.iter().position(|f| { !style.font_style.is_empty() && f.style.eq_ignore_ascii_case(&style.font_style) @@ -514,6 +533,44 @@ impl FontDb { style.italic = face.italic; } + /// The variable face of `family` (index into `faces`) with a named instance whose PostScript + /// name is `ps`, and that instance's axis values. An instance without a PostScript name of its + /// own is named as Adobe Technical Note #5902 derives it: the variations prefix (name 25, else + /// the family name) and the subfamily name without spaces, joined by a hyphen. Names compare + /// without punctuation or case, and the subfamily alone matches the part of `ps` after the + /// first hyphen, since the prefix a document stored can differ from the font's. + fn named_instance(&mut self, family: &str, faces: &[FaceInfo], ps: &str) -> Option<(usize, Vec<(String, f32)>)> { + let key = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect::(); + let wanted = key(ps); + let wanted_style = ps.split_once('-').map(|(_, s)| key(s)); + let info = self.fcx.collection.family_by_name(family)?; + for (index, face) in info.fonts().iter().enumerate() { + if faces.get(index).is_none_or(|f| f.axes.is_empty()) { + continue; + } + let Some(blob) = face.load(Some(&mut self.fcx.source_cache)) else { continue }; + let Ok(font) = skrifa::FontRef::from_index(blob.as_ref(), face.index()) else { continue }; + let name = |id: StringId| font.localized_strings(id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()); + let prefix = name(StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX) + .or_else(|| name(StringId::TYPOGRAPHIC_FAMILY_NAME)) + .or_else(|| name(StringId::FAMILY_NAME)) + .unwrap_or_default(); + for instance in font.named_instances().iter() { + let subfamily = name(instance.subfamily_name_id()).unwrap_or_default(); + let own = instance.postscript_name_id().and_then(name); + let matches = match own { + Some(own) => key(&own) == wanted, + None => key(&prefix) + &key(&subfamily) == wanted || (!subfamily.is_empty() && wanted_style.as_deref() == Some(key(&subfamily).as_str())), + }; + if matches { + let axes = font.axes().iter().map(|a| a.tag().to_string()).zip(instance.user_coords()).collect(); + return Some((index, axes)); + } + } + } + None + } + /// Finds a face by PostScript name (as stored in PSD files): exact match by reading the /// `name` table of candidate faces, else a heuristic split (`Arial-BoldMT` → Arial, 700). pub fn resolve_postscript(&mut self, ps: &str) -> ResolvedFont { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c341722703..6c0f1fedb1 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -847,6 +847,38 @@ fn variable_font_axes() { assert!(heavy > light * 1.5, "{light} → {heavy}"); } +/// #8797: a style that carries the PostScript name of a variable font's named condensed light +/// instance lays out as wide as that instance's axis values, not at the default instance. Uses +/// Bahnschrift (Windows) or Ubuntu Sans (Ubuntu) when installed, and skips otherwise; the +/// synthesized-font tests in `font_names_tests` cover the instance lookup and its layout width +/// everywhere. +#[test] +fn postscript_name_of_a_variable_instance_lays_out_at_that_instance() { + use photocraft_doc::text::FontVariation; + let candidates = [ + ("C:\\Windows\\Fonts\\bahnschrift.ttf", "Bahnschrift-LightCondensed"), + ("/usr/share/fonts/truetype/ubuntu/UbuntuSans[wdth,wght].ttf", "UbuntuSans-CondensedLight"), + ]; + let Some((bytes, ps)) = candidates.iter().find_map(|(p, ps)| std::fs::read(p).ok().map(|b| (b, *ps))) else { + return; + }; + let mut e = TextEngine::new(); + let Some(fam) = e.fonts.register_font_data(bytes).first().cloned() else { + return; + }; + let width = |e: &mut TextEngine, style: CharStyle| { + let l = e.layout(&styled("Hamburgefonstiv 0123", style), 96.0); + l.lines[0].x1 - l.lines[0].x0 + }; + let base = CharStyle { font_family: fam.clone(), size_pt: 72.0, weight: 300, ..Default::default() }; + let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }]; + let family = width(&mut e, base.clone()); + let instance = width(&mut e, CharStyle { postscript_name: Some(ps.into()), ..base.clone() }); + let explicit = width(&mut e, CharStyle { variations: axes, ..base }); + assert!(explicit < family * 0.9, "the condensed light axes are narrower: {explicit} vs {family}"); + assert!((instance - explicit).abs() < 0.5, "{ps}: {instance} px, its axes: {explicit} px, default instance: {family} px"); +} + #[test] fn warp_bends_rendered_text_and_outlines() { use photocraft_doc::text::TextWarp; From 20d61a90fed448bd2a9040573862ff3cc00c4f6a Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 03:09:35 +0530 Subject: [PATCH 19/40] fix(io): read XCF linked-layer payloads by field size (#8865) --- crates/io/src/xcf.rs | 26 ++++++++++--- crates/io/src/xcf/tests.rs | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/crates/io/src/xcf.rs b/crates/io/src/xcf.rs index d5cda4aa4d..5ec8b5edb2 100644 --- a/crates/io/src/xcf.rs +++ b/crates/io/src/xcf.rs @@ -205,15 +205,17 @@ impl<'a> Cursor<'a> { /// A STRING: `n+1` then `n` bytes and a NUL (0 for the empty string). fn string(&mut self) -> Result { + Ok(String::from_utf8_lossy(self.string_bytes()?).into_owned()) + } + + /// The same bounded STRING read without allocating or decoding an unused field. + fn string_bytes(&mut self) -> Result<&'a [u8]> { let n = self.u32()? as usize; - if n == 0 { - return Ok(String::new()); - } if n > MAX_STRING { return Err(invalid(format!("a string of {n} bytes exceeds the limit"))); } let s = self.take(n)?; - Ok(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned()) + Ok(s.strip_suffix(&[0]).unwrap_or(s)) } fn skip(&mut self, n: usize) -> Result<()> { @@ -227,8 +229,8 @@ struct Prop<'a> { data: &'a [u8], } -/// Reads a property list; `fixed` gives the native payload sizes the spec says GIMP trusts over -/// the length word. Ends at PROP_END (type 0) or at the end of the file. +/// Reads a property list, measuring known payloads whose length words GIMP writes incorrectly. +/// Ends at PROP_END (type 0). fn props<'a>(c: &mut Cursor<'a>) -> Result>> { let mut out = Vec::new(); loop { @@ -239,12 +241,14 @@ fn props<'a>(c: &mut Cursor<'a>) -> Result>> { } // PROP_COLORMAP's length word was wrong in some releases (n+4 for 3n+4 bytes); its real // size follows from the count. PROP_USER_UNIT's length is wrong in every release. + // PROP_LINK_LAYER's length can be four bytes short (#8857); read all its fields. let len = match kind { 1 => { let n = c.at(c.pos).u32()? as usize; 4usize.checked_add(n.checked_mul(3).ok_or_else(|| invalid("colormap overflow"))?).ok_or_else(|| invalid("colormap overflow"))? } 24 => user_unit_len(c)?, + 48 => link_layer_len(c)?, _ => len, }; if len > MAX_PAYLOAD { @@ -258,6 +262,16 @@ fn props<'a>(c: &mut Cursor<'a>) -> Result>> { } } +/// A PROP_LINK_LAYER payload: flags, a path STRING, then the source width and height. +/// The dimensions are present for raster links too, where their values are unused. +fn link_layer_len(c: &Cursor<'_>) -> Result { + let mut p = c.at(c.pos); + p.skip(4)?; + p.string_bytes()?; + p.skip(8)?; + Ok(p.pos - c.pos) +} + /// The real size of a PROP_USER_UNIT payload: a float, a count and three (XCF 21+) or five /// strings. Measured without consuming. fn user_unit_len(c: &Cursor<'_>) -> Result { diff --git a/crates/io/src/xcf/tests.rs b/crates/io/src/xcf/tests.rs index 400887f92b..24481f1956 100644 --- a/crates/io/src/xcf/tests.rs +++ b/crates/io/src/xcf/tests.rs @@ -577,6 +577,82 @@ fn clipping_text_vector_effects_and_floating_selections_are_reported() { assert!(w.contains("text layer") && w.contains("vector layer") && w.contains("floating selection"), "{w}"); } +/// #8857: GIMP's link-layer length omits one u32. Trusting it shifts the hierarchy pointer +/// by four bytes, even though the cached pixels and every offset in the file are valid. +#[test] +fn linked_layers_open_cached_pixels_with_short_or_correct_property_lengths() { + for (precision, depth, data) in [ + (150, SampleType::U8, vec![51, 102, 153, 255]), + (250, SampleType::U16, [13107u16, 26214, 39321, 65535].into_iter().flat_map(u16::to_be_bytes).collect()), + (600, SampleType::F32, [0.2f32, 0.4, 0.6, 1.0].into_iter().flat_map(f32::to_be_bytes).collect()), + ] { + for (path, width, height) in [("assets/étiquette.svg", 2000, 1270), ("assets/label.png", 0, 0), ("", 0, 0)] { + let mut payload = W { out: Vec::new(), wide: true }; + payload.u32(1); + payload.string(path); + payload.u32(width); + payload.u32(height); + let payload = payload.out; + let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat(); + let mut img = TestImage::rgb8(1, 1); + img.version = 25; + img.precision = precision; + let mut linked = TestLayer::rgba("Linked", 1, 1, data.clone()); + // Later properties must still be read, including unknown ones skipped by length. + linked.props = vec![(48, payload.clone()), (99, vec![1, 2, 3]), (33, 0.375f32.to_be_bytes().to_vec()), (8, 0u32.to_be_bytes().to_vec())]; + img.layers.push(linked); + img.layers.push(TestLayer::rgba("Bottom", 1, 1, data.clone())); + for short in [false, true] { + let mut bytes = img.write(); + let at = bytes.windows(marker.len()).position(|w| w == marker).unwrap(); + if short { + bytes[at + 4..at + 8].copy_from_slice(&((payload.len() - 4) as u32).to_be_bytes()); + } + let r = open(&bytes).unwrap_or_else(|e| panic!("precision {precision}, path {path:?}, short {short}: {e}")); + assert!(r.source_read_only); + assert_eq!(r.document.depth, depth); + assert_eq!(r.document.layers.len(), 2); + let linked = layer_at(&r.document.layers, "Linked"); + assert_eq!(linked.opacity, 0.375); + assert!(!linked.visible); + for layer in [linked, layer_at(&r.document.layers, "Bottom")] { + for (got, expected) in pixel(&layer, 0, 0).into_iter().zip([0.2, 0.4, 0.6, 1.0]) { + assert!((got - expected).abs() < 1e-5, "precision {precision}: {got} != {expected}"); + } + } + assert!(r.warnings.iter().any(|w| w.contains("is a link layer and opens as pixels"))); + } + } + } +} + +#[test] +fn truncated_and_oversized_link_layer_fields_are_errors() { + let mut payload = W { out: Vec::new(), wide: true }; + payload.u32(1); + payload.string("assets/label.svg"); + payload.u32(2000); + payload.u32(1270); + let payload = payload.out; + let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat(); + let mut img = TestImage::rgb8(1, 1); + img.version = 25; + let mut linked = TestLayer::rgba("Linked", 1, 1, vec![51, 102, 153, 255]); + linked.props.push((48, payload.clone())); + img.layers.push(linked); + let bytes = img.write(); + let start = bytes.windows(marker.len()).position(|w| w == marker).unwrap() + 8; + // Cut within the flags, string length, path and either dimension. + for end in start..start + payload.len() { + assert!(open(&bytes[..end]).is_err(), "truncated at {end}"); + } + for len in [(MAX_STRING + 1) as u32, u32::MAX] { + let mut oversized = bytes.clone(); + oversized[start + 4..start + 8].copy_from_slice(&len.to_be_bytes()); + assert!(open(&oversized).is_err(), "path length {len}"); + } +} + #[test] fn version_3_files_use_32_bit_pointers_and_legacy_modes() { let mut img = TestImage::rgb8(5, 5); From 10a20743ea5c42e0f12e319692c1dafe4c8305b6 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Fri, 23 Oct 2026 23:39:40 +0200 Subject: [PATCH 20/40] Soft Light: dark backdrops follow Photoshop's W3C curve (#8804) (#8817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(blend): Soft Light uses the W3C curve over dark backdrops (#8804) Photoshop's Soft Light moves the backdrop towards D(cb) in the upper half, and D(cb) is the cubic ((16·cb − 12)·cb + 4)·cb below a quarter, not sqrt(cb). With sqrt the Soft Light cell of psd-tools rgb-blend-modes.psd was up to 20 levels too light over the dark rainbow; with the cubic it is within 4 of Photoshop's merged image. Same change in compose.wgsl so the GPU compositor stays at CPU parity. Krita's own soft_light (the sqrt curve) now opens as the approximation and svg:soft-light / krita:soft_light_svg as the exact match, without warning. Fixes #8804 * compose: the see-through Soft Light test expects the W3C curve over a dark backdrop (#8804) The #6232 test computed Photoshop's Soft Light of yellow as sqrt(b) for red and green. Its backdrop #04170F is below a quarter in both channels, where the W3C curve (Photoshop's, measured in #8804) uses ((16b - 12)b + 4)b instead. --- crates/color/src/blend.rs | 46 ++++++++++++++-- crates/compose/src/advanced_tests.rs | 8 +-- crates/gpu/src/compose.wgsl | 6 ++- crates/io/src/ora.rs | 11 ++-- crates/io/src/ora/tests.rs | 22 +++++++- crates/io/tests/it/blend_modes_corpus.rs | 67 ++++++++++++++++++++++++ crates/io/tests/it/main.rs | 1 + docs/ora.md | 5 +- 8 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 crates/io/tests/it/blend_modes_corpus.rs diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 5cf5fbd691..508c26977e 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -1,7 +1,7 @@ //! Photoshop and Paint.NET blend modes (scalar reference implementation). //! //! Formulas follow the public PDF / ISO 32000-2 and W3C Compositing definitions, -//! with Photoshop's variants where they differ (Soft Light, Hard Mix, Darker/Lighter Color, +//! with Photoshop's variants where they differ (Hard Mix, Darker/Lighter Color, //! Linear Burn/Dodge, Vivid/Linear/Pin Light, Subtract, Divide). This is the CPU reference //! that the GPU kernels must match. @@ -323,10 +323,19 @@ fn hard_light(cb: f32, cs: f32) -> f32 { } } -/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half). +/// Photoshop's Soft Light: the W3C/PDF curve. In the upper half (`cs > 0.5`) the backdrop moves +/// towards `D(cb)`, which is `sqrt(cb)` above a quarter and the cubic `((16·cb − 12)·cb + 4)·cb` +/// below it; with `sqrt` throughout, dark backdrops came out up to 20 levels too light (#8804, +/// measured on psd-tools `blend-modes/rgb-blend-modes.psd`). #[inline] fn soft_light_ps(cb: f32, cs: f32) -> f32 { - if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) } + if cs <= 0.5 { + 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) + } else { + let b = cb.max(0.0); + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + 2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0) + } } // ---- non-separable helpers (W3C Compositing Level 1) ---- @@ -572,6 +581,37 @@ mod tests { assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0)); } + /// #8804: Photoshop's Soft Light over a dark backdrop (cb ≤ 0.25, cs > 0.5) follows the W3C + /// curve `D(cb) = ((16·cb − 12)·cb + 4)·cb`, not `sqrt(cb)`. Measured on psd-tools + /// `blend-modes/rgb-blend-modes.psd` (Photoshop's merged image), in 8-bit levels: + /// (backdrop, source, Photoshop). + #[test] + fn soft_light_follows_photoshop_over_dark_backdrops() { + for (cb, cs, ps) in [(4u8, 247u8, 11.0f32), (6, 254, 19.0), (9, 252, 25.0), (10, 237, 32.0), (14, 253, 46.0)] { + let got = blend_channel(BlendMode::SoftLight, f32::from(cb) / 255.0, f32::from(cs) / 255.0) * 255.0; + assert!((got - ps).abs() <= 7.0, "cb {cb} cs {cs}: {got} vs Photoshop {ps}"); + } + } + + #[test] + fn soft_light_is_the_w3c_formula() { + let d = |cb: f32| if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() }; + let w3c = |cb: f32, cs: f32| if cs <= 0.5 { cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) } else { cb + (2.0 * cs - 1.0) * (d(cb) - cb) }; + for b in (0..=255u8).step_by(3) { + for s in (0..=255u8).step_by(5) { + let (cb, cs) = (f32::from(b) / 255.0, f32::from(s) / 255.0); + let got = blend_channel(BlendMode::SoftLight, cb, cs); + assert!((got - w3c(cb, cs)).abs() < 1e-5, "cb {b} cs {s}: {got} vs {}", w3c(cb, cs)); + } + } + // Continuous where the two halves of D meet (both give 0.5 at cb = 0.25). + let below = blend_channel(BlendMode::SoftLight, 0.25, 1.0); + let above = blend_channel(BlendMode::SoftLight, 0.25 + 1e-6, 1.0); + assert!(close(below, 0.5) && (above - below).abs() < 1e-4, "{below} {above}"); + // A negative backdrop (32-bit documents) counts as black under a light source, as before. + assert!(close(blend_channel(BlendMode::SoftLight, -0.1, 1.0), 0.0)); + } + #[test] fn vivid_light_source_extremes_and_interior_values() { for cb in [0.0, 0.25, 0.5, 0.75, 1.0] { diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 22f374af6e..a0fdf52cb0 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -270,10 +270,12 @@ fn interior_effects_blend_over_the_layer_mode_unless_grouped() { fn interior_effects_blend_with_the_backdrop_through_a_see_through_normal_layer() { // #6232: #04170F under a Normal layer at fill 0 with a yellow Soft Light colour overlay. The // overlay soft-lights the dark green it sees through the layer (Photoshop's soft light of - // yellow: sqrt(b) for red and green, b² for blue), not transparency, which would leave plain - // yellow. A half-transparent part of the layer mixes it in at half strength. + // yellow: the W3C D(b) for red and green, which is the cubic ((16b − 12)b + 4)b below a + // quarter (#8804), and b² for blue), not transparency, which would leave plain yellow. A + // half-transparent part of the layer mixes it in at half strength. let bg: [f32; 3] = [4.0 / 255.0, 23.0 / 255.0, 15.0 / 255.0]; - let lit = [bg[0].sqrt(), bg[1].sqrt(), bg[2] * bg[2]]; + let dark = |b: f32| ((16.0 * b - 12.0) * b + 4.0) * b; + let lit = [dark(bg[0]), dark(bg[1]), bg[2] * bg[2]]; let half = |c: usize| (bg[c] + lit[c]) / 2.0; for depth in DEPTHS { let mut d = doc(depth, false); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index a6c0c63579..c10f27d4f2 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -139,9 +139,13 @@ fn hard_light(cb: f32, cs: f32) -> f32 { let s = 2.0 * cs - 1.0; return cb + s - cb * s; } +// The W3C curve, as Photoshop: D(cb) is the cubic below a quarter, sqrt above it (#8804). fn soft_light_ps(cb: f32, cs: f32) -> f32 { if (cs <= 0.5) { return 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs); } - return 2.0 * cb * (1.0 - cs) + sqrt(max(cb, 0.0)) * (2.0 * cs - 1.0); + let b = max(cb, 0.0); + var d = sqrt(b); + if (b <= 0.25) { d = ((16.0 * b - 12.0) * b + 4.0) * b; } + return 2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0); } fn vivid_light_ps(cb: f32, cs: f32) -> f32 { if (cs <= 0.5) { diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index 6d1bcfd87f..916ac839a2 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -77,9 +77,9 @@ pub(crate) fn krita_blend(id: &str) -> Option<(BlendMode, bool)> { "dodge" => (B::ColorDodge, false), "burn" => (B::ColorBurn, false), "hard_light" => (B::HardLight, false), - "soft_light" => (B::SoftLight, false), - // The W3C soft light curve, close to but not Photoshop's. - "soft_light_svg" => (B::SoftLight, true), + // Krita's own Soft Light keeps sqrt(cb) over dark backdrops; Photoshop's is the W3C curve. + "soft_light" => (B::SoftLight, true), + "soft_light_svg" => (B::SoftLight, false), "diff" => (B::Difference, false), "exclusion" => (B::Exclusion, false), "linear_burn" => (B::LinearBurn, false), @@ -124,7 +124,7 @@ pub(crate) fn krita_id(mode: BlendMode) -> Option<&'static str> { B::LinearDodge => "linear_dodge", B::LighterColor => "lighter color", B::Overlay => "overlay", - B::SoftLight => "soft_light", + B::SoftLight => "soft_light_svg", B::HardLight => "hard_light", B::VividLight => "vivid_light", B::LinearLight => "linear light", @@ -167,9 +167,6 @@ const SVG_OPS: [(&str, BlendMode); 15] = [ /// A composite-op attribute as a blend mode; unknown names fall back to Normal with a warning. fn composite_op(op: &str, layer: &str, warnings: &mut Warnings) -> BlendMode { if let Some((_, mode)) = SVG_OPS.iter().find(|(name, _)| *name == op) { - if op == "svg:soft-light" { - warnings.note("svg:soft-light (the W3C curve) opens as Photoshop's Soft Light, which differs slightly"); - } return *mode; } match op { diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index c7c253ef29..922135a42e 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -327,9 +327,29 @@ fn reads_other_applications_stacks() { assert_eq!(get("Strange").blend, BlendMode::Normal); assert!(get("Lost").surface().unwrap().content_bounds().is_empty()); let all = r.warnings.join("\n"); - for needle in ["svg:dst-in", "", "missing from the archive", "krita:hard mix", "svg:soft-light"] { + for needle in ["svg:dst-in", "", "missing from the archive", "krita:hard mix"] { assert!(all.contains(needle), "{needle} not in {all}"); } + // svg:soft-light is the W3C curve, which is Photoshop's Soft Light (#8804): no warning. + assert!(!all.contains("soft-light"), "{all}"); +} + +#[test] +fn krita_soft_light_ids_map_to_photoshop_soft_light() { + // Krita's own soft_light keeps sqrt(cb) over dark backdrops, so it is the approximation; + // soft_light_svg is the W3C curve Photoshop uses (#8804). + assert_eq!(super::krita_blend("soft_light"), Some((BlendMode::SoftLight, true))); + assert_eq!(super::krita_blend("soft_light_svg"), Some((BlendMode::SoftLight, false))); + assert_eq!(super::krita_id(BlendMode::SoftLight), Some("soft_light_svg")); + let xml = r#" + + + "#; + let r = crate::import("soft.ora", &archive(xml, &[("a.png", solid_png(2, 2, [40, 40, 40, 255]))])).unwrap(); + assert!(r.document.layers.iter().all(|l| l.blend == BlendMode::SoftLight)); + let all = r.warnings.join("\n"); + assert!(all.contains("krita:soft_light blend mode is approximated"), "{all}"); + assert!(!all.contains("soft_light_svg"), "{all}"); } #[test] diff --git a/crates/io/tests/it/blend_modes_corpus.rs b/crates/io/tests/it/blend_modes_corpus.rs new file mode 100644 index 0000000000..296e2e2a50 --- /dev/null +++ b/crates/io/tests/it/blend_modes_corpus.rs @@ -0,0 +1,67 @@ +//! Blend modes against Photoshop's own merged image of psd-tools `blend-modes/rgb-blend-modes.psd`: +//! one 64 × 64 cell per mode, a duck over a rainbow backdrop. +#![cfg(feature = "corpus")] + +use photocraft_color::BlendMode; +use photocraft_geom::Rect; +use photocraft_io::{import, merged_composite}; +use photocraft_psd::PsdFile; + +const CELL: i32 = 64; + +/// The largest and mean difference in 8-bit levels between our flatten and Photoshop's merged image +/// over the cell of `mode`, on the pixels where that cell's duck is fully opaque (its anti-aliased +/// edge mixes in the backdrop, which says nothing about the mode). +fn cell_error(mode: BlendMode) -> (f32, f32) { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/rgb-blend-modes.psd"); + let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools"); + let doc = import("rgb-blend-modes.psd", &bytes).expect("opens").document; + let file = PsdFile::from_bytes(&bytes).expect("parses"); + let merged = merged_composite(&file).expect("merged image"); + let ours = photocraft_compose::flatten(&doc).px; + let width = doc.size.width as i32; + let duck = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == "rubber_duck_PNG1" && l.blend == mode).expect("the mode's duck"); + let surface = duck.surface().expect("duck pixels"); + let bounds = surface.content_bounds(); + let (x0, y0) = ((bounds.x0 / CELL) * CELL, (bounds.y0 / CELL) * CELL); + let mut alpha = vec![[0.0f32; 4]; (CELL * CELL) as usize]; + surface.read_rgba_into(Rect::from_xywh(x0, y0, CELL as u32, CELL as u32), &mut alpha); + let (mut max, mut sum, mut n) = (0.0f32, 0.0f32, 0u32); + for y in 1..CELL - 1 { + for x in 1..CELL - 1 { + // Opaque, and so are its four neighbours (one pixel in from the edge). + let opaque = |dx: i32, dy: i32| alpha[((y + dy) * CELL + x + dx) as usize][3] >= 1.0; + if !(opaque(0, 0) && opaque(-1, 0) && opaque(1, 0) && opaque(0, -1) && opaque(0, 1)) { + continue; + } + let i = ((y0 + y) * width + x0 + x) as usize; + for c in 0..3 { + let d = ((ours[i][c] * 255.0).round() - (merged[i][c] * 255.0).round()).abs(); + max = max.max(d); + sum += d; + n += 1; + } + } + } + assert!(n > 3000, "{mode:?}: only {n} opaque samples"); + (max, sum / n as f32) +} + +/// #8804: Soft Light over the dark parts of the rainbow. With `sqrt(cb)` below a quarter the cell +/// was up to 20 levels too light; the W3C curve Photoshop uses is within 7. +#[test] +fn soft_light_matches_photoshop_over_dark_backdrops() { + let (max, mean) = cell_error(BlendMode::SoftLight); + eprintln!("Soft Light: max {max} mean {mean:.3}"); + assert!(max <= 7.0 && mean < 0.6, "max {max} mean {mean}"); +} + +/// The modes #8804 checked next to Soft Light stay where they were. +#[test] +fn separable_light_modes_match_photoshop() { + for mode in [BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay, BlendMode::HardLight] { + let (max, mean) = cell_error(mode); + eprintln!("{mode:?}: max {max} mean {mean:.3}"); + assert!(max <= 3.0, "{mode:?}: max {max} mean {mean}"); + } +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 662d37c307..5519fc0d6b 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -8,6 +8,7 @@ mod affinity_corpus; mod annotations; mod artboard_guide_order; mod banded; +mod blend_modes_corpus; mod cancel; mod channels; mod cmyk_composite_export; diff --git a/docs/ora.md b/docs/ora.md index 61125f060b..f981a74d8d 100644 --- a/docs/ora.md +++ b/docs/ora.md @@ -43,8 +43,9 @@ everything: the document keeps its file and its unsaved changes. Automation foll OpenRaster elements other than `stack` and `layer` (MyPaint's text, for example), missing layer images and unknown composite operations open with a warning instead of failing the file. -Krita's W3C Soft Light (`svg:soft-light`, `krita:soft_light_svg`) and its own Hard Mix open as -Photoshop's Soft Light and Hard Mix, which differ slightly, also with a warning. +Krita's W3C Soft Light (`svg:soft-light`, `krita:soft_light_svg`) is Photoshop's Soft Light and +opens as it. Krita's own Soft Light (`krita:soft_light`, `sqrt` over dark backdrops) and its own +Hard Mix open as Photoshop's Soft Light and Hard Mix, which differ slightly, with a warning. ## Limits and safety From 0986582a61e5dd226a2b5da6766c13a8c7d9b741 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:39:45 +0100 Subject: [PATCH 21/40] fix(actions): a recorded Layers-panel drag keeps its drop target by name (#8781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(actions): a recorded Layers-panel drag keeps its drop target by name Dragging a layer while recording stored layer.moveTo's target as a layer id, which means nothing in the document the action later plays in, so playback stopped with "no such layer". The target is now recorded by name (with targetOccurrence among same-named layers), as layer.select records its layer, and layer.moveTo accepts a target by name. Fixes #7845 Co-Authored-By: Claude Opus 5.5 * fix(actions): count a drag's same-named targets without the moved layer Steps are recorded after they run, so a target's occurrence among same-named layers could shift when the moved layer shares its name. It is now counted among the other layers only, at recording and at replay, which the move doesn't reorder. Only a drag of the selected layer is recorded by name; drags of several layers, or of an unselected one, keep their ids. Co-Authored-By: Claude Opus 5.5 * fix(actions): a moved group's contents don't count as drop targets Same-named layers inside a moved group also changed order with the move, so they are left out of the occurrence count with the group. Co-Authored-By: Claude Opus 5.5 * fix(actions): leave every moved layer out of a named drop target A move of several layers given a target by name no longer counts the moved layers (or their contents) as candidates. Co-Authored-By: Claude Opus 5.5 * fix(actions): a recorded ⌥-drag copy beside itself replays by name A copy leaves its source in place, so the source stays a candidate for the named target, at recording and at replay. Co-Authored-By: Claude Opus 5.5 * fix(actions): a recorded copy's own contents don't count as drop targets The step is recorded after the copy exists; the copy (and, for a group, its same-named contents) is left out of the occurrence count, as at replay it doesn't exist yet. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/actions_cmds.rs | 167 ++++++++++++++++++++++++++ crates/engine/src/commands.rs | 2 +- crates/engine/src/layer_multi_cmds.rs | 86 ++++++++++++- 3 files changed, 253 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 6f559a5659..9f3d74e3e0 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -1167,6 +1167,173 @@ mod tests { } } + /// #7845: dragging a layer in the Layers panel while recording keeps the drop target by name, + /// so the action plays on another document (whose layer ids differ) instead of failing with + /// "no such layer". The reporter's steps, replayed on a new document. + #[test] + fn recorded_layer_drag_replays_on_another_document() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap(); + s.execute("actions.record", json!({"name": "Retouch"})).unwrap(); + s.execute("layer.stampVisible", json!({})).unwrap(); + let layer1 = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layerViaCopy", json!({})).unwrap(); + let layer2 = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.select", json!({"layer": layer1.0, "mode": "replace"})).unwrap(); + s.execute("filter.noise.median", json!({"radius": 6})).unwrap(); + s.execute("layer.new.layerViaCopy", json!({})).unwrap(); + let copy = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.moveTo", json!({"layer": copy.0, "target": layer2.0, "position": "above"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let (id, step) = s.actions.list[0].steps.last().unwrap(); + assert_eq!((id.as_str(), step), ("layer.moveTo", &json!({"target": "Layer 1 copy", "position": "above"}))); + let names = |s: &Session| s.active().unwrap().doc.layers.iter().rev().map(|l| l.name.clone()).collect::>(); + let recorded = names(&s); + 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": 16, "height": 16, "depth": depth})).unwrap(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + assert_eq!(names(&replay), recorded, "{depth}: the same stack"); + } + // Several layers of the drop target's name: the recorded occurrence picks the right one. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + let upper = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Mover"})).unwrap(); + let mover = s.active().unwrap().active_layer.unwrap(); + s.execute("actions.record", json!({"name": "Drop"})).unwrap(); + s.execute("layer.moveTo", json!({"layer": mover.0, "target": upper.0, "position": "below"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 1, "position": "below"})); + // The moved layer shares the target's name: the occurrence counts the other "Twin"s only, + // so it means the same layer before the move (on replay) as after it (when recorded). + let build = |s: &mut Session| { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + for _ in 0..3 { + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + } + s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>() + }; + let mut s = Session::new(); + let ids = build(&mut s); + let (lowest, top) = (ids[1], ids[3]); + s.execute("actions.record", json!({"name": "Twins"})).unwrap(); + s.execute("layer.moveTo", json!({"layer": top.0, "target": lowest.0, "position": "below"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 0, "position": "below"})); + let order = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); + let want: Vec<_> = order(&s).iter().map(|id| ids.iter().position(|x| x == id).unwrap()).collect(); + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + let ids2 = build(&mut replay); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let got: Vec<_> = order(&replay).iter().map(|id| ids2.iter().position(|x| x == id).unwrap()).collect(); + assert_eq!(got, want, "the same layer became the lowest"); + // A moved group holding a same-named layer: its contents don't count either, or moving the + // group past another "Twin" would shift the occurrence between recording and replay. + let build_group = |s: &mut Session| { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + let top = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap(); + (group, top) + }; + let mut g = Session::new(); + let (group, top) = build_group(&mut g); + g.execute("actions.record", json!({"name": "Group"})).unwrap(); + g.execute("layer.moveTo", json!({"layer": group.0, "target": top.0, "position": "above"})).unwrap(); + g.execute("actions.stop", json!({})).unwrap(); + let recorded = g.actions.list[0].steps[0].1.clone(); + let saved = serde_json::to_string(&g.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + let (group2, _) = build_group(&mut replay); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{recorded}: {result}"); + let top_layer = |s: &Session| s.active().unwrap().doc.layers.last().map(|l| l.id); + assert_eq!(top_layer(&replay), Some(group2), "{recorded}: the group went to the top"); + // Several moved layers named like the target are left out too, so the name is unambiguous. + let mut m = Session::new(); + m.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let a = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + let b = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + let target = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + m.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": "Twin", "position": "above"})).unwrap(); + assert_eq!(m.active().unwrap().doc.layers.last().map(|l| l.id), Some(b), "both moved above the third Twin"); + assert_eq!(m.active().unwrap().doc.layers.iter().rev().nth(2).map(|l| l.id), Some(target)); + // An ⌥-drag copy of the selected layer beside itself replays by name too. + let mut c = Session::new(); + c.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let src = photocraft_doc::LayerId(c.execute("layer.new.layer", json!({"name": "Solo"})).unwrap()["layer"].as_u64().unwrap()); + c.execute("actions.record", json!({"name": "Copy"})).unwrap(); + c.execute("layer.moveTo", json!({"layer": src.0, "target": src.0, "position": "above", "copy": true})).unwrap(); + c.execute("actions.stop", json!({})).unwrap(); + assert_eq!(c.actions.list[0].steps[0].1, json!({"target": "Solo", "position": "above", "copy": true})); + let saved = serde_json::to_string(&c.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})).unwrap(); + replay.execute("layer.new.layer", json!({"name": "Solo"})).unwrap(); + let before = replay.active().unwrap().doc.layers.len(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(replay.active().unwrap().doc.layers.len(), before + 1, "one copy"); + // Copying a group holding a layer named like the target: the new copy's contents (made + // before the step is recorded) don't count, so the occurrence matches the replay. + let build_copy = |s: &mut Session| { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().active_layer.unwrap(); + let a = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + let b = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap(); + (group, a, b) + }; + let mut gc = Session::new(); + let (group, a, _) = build_copy(&mut gc); + gc.execute("actions.record", json!({"name": "Copy group"})).unwrap(); + // Below `a`: the copy's "Twin" then comes before `a` in document order. + gc.execute("layer.moveTo", json!({"layer": group.0, "target": a.0, "position": "below", "copy": true})).unwrap(); + gc.execute("actions.stop", json!({})).unwrap(); + let saved = serde_json::to_string(&gc.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + let (_, a2, _) = build_copy(&mut replay); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let layers = &replay.active().unwrap().doc.layers; + let at = layers.iter().position(|l| l.id == a2).unwrap(); + let below = at.checked_sub(1).and_then(|i| layers.get(i)).map(|l| l.name.as_str()); + assert_eq!(below, Some("Group 1 copy"), "the copy landed below the same Twin"); + // A drag of several layers keeps its ids (not covered by name). + s.execute("actions.record", json!({"name": "Several"})).unwrap(); + s.execute("layer.moveTo", json!({"layers": [ids[2].0, ids[3].0], "target": ids[1].0, "position": "above"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[1].steps[0].1["target"], json!(ids[1].0)); + // Bad targets are errors, not panics. + for bad in [ + json!({"target": "Nobody"}), + json!({"target": "Twin"}), + json!({"target": "Twin", "targetOccurrence": 9}), + json!({"target": "Twin", "targetOccurrence": -1}), + json!({"target": true}), + ] { + assert!(s.execute("layer.moveTo", bad.clone()).is_err(), "{bad}"); + } + } + /// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain /// layer's recorded copy must still export to .atn, while an artboard's must be refused rather /// than exported as a copy beside the original. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2b65788fcc..ac5663683b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1135,7 +1135,7 @@ fn build() -> Vec { "Reorder Layer", [], None, - r##"{"layer":id?| "layers":[id,…]?, "target":id,"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row)"##, + r##"{"layer":id?| "layers":[id,…]?, "target":id|name,"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; a recorded action keeps the target by name)"##, has_layer, crate::layer_multi_cmds::move_to ), diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 83d4e815a2..47114f7e5d 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -118,6 +118,86 @@ pub(crate) fn recorded_selection(s: &Session, mut p: Value) -> Value { p } +/// The layers named `name`, in document order, other than the ones being moved and (groups) their +/// contents. Moving layers never changes the others' order, so an occurrence counted among these +/// means the same layer before the move (when an action replays it) and after it (when the step +/// is recorded). +fn same_named_targets(doc: &photocraft_doc::Document, name: &str, moved: &[LayerId]) -> Vec { + fn subtree(l: &Layer, out: &mut Vec, depth: usize) { + out.push(l.id); + if depth < 256 + && let Some(children) = l.children() + { + children.iter().for_each(|c| subtree(c, out, depth + 1)); + } + } + let mut skip = Vec::new(); + for l in moved.iter().filter_map(|id| doc.layer(*id)) { + subtree(l, &mut skip, 0); + } + doc.walk().into_iter().filter(|(_, _, l)| l.name == name && !skip.contains(&l.id)).map(|(_, _, l)| l.id).collect() +} + +/// A Layers-panel drag of the selected layer records its drop target by name (and which of +/// several same-named layers it is), as [`recorded_selection`] does: the id means nothing in the +/// document the action plays in later, so playback failed with "no such layer" (#7845). Drags of +/// several layers, or of a layer that isn't the selected one, keep their ids. +fn recorded_move(s: &Session, mut p: Value, pre_selected: Option<&[LayerId]>) -> Value { + if p.get("layers").is_some() { + return p; + } + let Some(st) = s.active() else { return p }; + let moved = match p.get("layer") { + None => st.active_layer, + Some(v) => match v.as_u64().map(LayerId) { + Some(id) if pre_selected.map_or_else(|| st.selected_layers().as_slice() == [id], |sel| sel == [id]) => Some(id), + _ => return p, + }, + }; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + } + let Some(id) = p.get("target").and_then(Value::as_u64).map(LayerId) else { return p }; + let Some(layer) = st.doc.layer(id) else { return p }; + // A copy leaves its source where it is (it may even land beside it), so the source stays a + // candidate; the new copy (the active layer once the step has run, which is when it is + // recorded) is left out instead, as it doesn't exist yet when the step replays. + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let skip = if copy { st.active_layer.filter(|a| Some(*a) != moved) } else { moved }; + let matches = same_named_targets(&st.doc, &layer.name, skip.as_slice()); + let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p }; + if let Some(fields) = p.as_object_mut() { + fields.insert("target".into(), json!(layer.name)); + if matches.len() > 1 { + fields.insert("targetOccurrence".into(), json!(occurrence)); + } + } + p +} + +/// `layer.moveTo`'s drop target: an id, or a name with `targetOccurrence` choosing among layers +/// that share it (in document order, the `moved` layers aside; required when several do). +fn move_target(s: &Session, p: &Value, moved: &[LayerId]) -> Result { + const CMD: &str = "layer.moveTo"; + match p.get("target") { + Some(Value::String(name)) => { + let occurrence = p + .get("targetOccurrence") + .map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, "`targetOccurrence` must be a non-negative integer"))) + .transpose()?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let matches = same_named_targets(&st.doc, name, moved); + if matches.len() > 1 && occurrence.is_none() { + return Err(bad(CMD, format!("multiple layers named `{name}`; specify `targetOccurrence`"))); + } + let index = occurrence.unwrap_or(0); + matches.get(index).copied().ok_or_else(|| bad(CMD, format!("no layer named `{name}` at occurrence {index}"))) + } + Some(v) => v.as_u64().map(LayerId).ok_or_else(|| bad(CMD, "`target` must be a layer id or name")), + None => Err(bad(CMD, "missing `target`")), + } +} + /// Normalise parameters when recording an action step. /// /// For `layer.select`, captures the layer by name. For other commands, if a numeric `layer` @@ -128,6 +208,9 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio if id == "layer.select" { return recorded_selection(s, p); } + if id == "layer.moveTo" { + return recorded_move(s, p, pre_selected); + } let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; let is_sole_selected = match pre_selected { Some(sel) => sel == [layer_id], @@ -1267,7 +1350,6 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: & /// selected nodes are detached, and every reference is validated before editing. pub fn move_to(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.moveTo"; - let target = LayerId(p.get("target").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "missing `target`"))?); let pos = p.get("position").and_then(Value::as_str).unwrap_or("above"); if !matches!(pos, "above" | "below" | "into") { return Err(bad(CMD, "position must be above, below, or into")); @@ -1293,6 +1375,8 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } else { vec![crate::commands::layer_param(s, p)?] }; + // A copy moves nothing, so its source stays a candidate (⌥-dragging a layer beside itself). + let target = move_target(s, p, if copy { &[] } else { &requested })?; let st = s.active().ok_or(EngineError::NoDocument)?; let doc = &st.doc; let target_path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?; From c06dd9c27c4c8064be845367366085930a174258 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:39:50 +0100 Subject: [PATCH 22/40] fix(gpu): rebuild the GPU canvas after an out-of-memory error (#8774) * fix(gpu): rebuild the GPU canvas after an out-of-memory error An uncaptured wgpu error of any kind dropped the GPU canvas for the rest of the session, so one recoverable Out of Memory on a working device left every redraw on the CPU until Retry GPU. Dropping the canvas frees its memory, so after an out-of-memory error a new GPU canvas is now built at once on the same device, with a fresh health flag and no dialog, up to three times a session; past that the CPU renderer and the Retry GPU dialog stay as before. Validation errors and device losses are unchanged. Fixes #6564 Co-Authored-By: Claude Opus 5.5 * fix(gpu): only wgpu's out-of-memory error is retried The fault is classified by wgpu's Error::OutOfMemory variant and recorded as exactly "Out of Memory", so a validation error that mentions memory keeps its fallback. Co-Authored-By: Claude Opus 5.5 * fix(gpu): a later out-of-memory error replaces an earlier error So the canvas still rebuilds itself when memory ran out after a validation error in the same frame; a device loss still wins. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/gpu/src/health.rs | 37 +++++++++++++++++-- crates/ui-egui/src/gpu_status.rs | 63 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 3 ++ 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/gpu/src/health.rs b/crates/gpu/src/health.rs index 61aacf8b58..948ef2804a 100644 --- a/crates/gpu/src/health.rs +++ b/crates/gpu/src/health.rs @@ -30,6 +30,14 @@ impl Fault { matches!(self, Fault::Lost(_)) } + /// An out-of-memory error on a device that still works: freeing memory and trying again can + /// succeed, unlike after a loss or a validation error (#6564). Only wgpu's own out-of-memory + /// error is recorded with exactly [`OUT_OF_MEMORY`]; a validation error that merely mentions + /// memory is not one. + pub fn is_out_of_memory(&self) -> bool { + matches!(self, Fault::Error(s) if s == OUT_OF_MEMORY) + } + /// The driver's or wgpu's message. pub fn detail(&self) -> &str { match self { @@ -48,6 +56,9 @@ impl fmt::Display for Fault { } } +/// The message an out-of-memory [`Fault::Error`] carries (wgpu's `Error::OutOfMemory`). +pub const OUT_OF_MEMORY: &str = "Out of Memory"; + #[derive(Default)] struct Inner { faulted: AtomicBool, @@ -102,16 +113,24 @@ impl DeviceHealth { // Errors about invalid or lost objects follow a device loss whose callback may not have // run yet (it fires on the next poll). let lower = text.to_ascii_lowercase(); - let fault = if lower.contains("lost") || lower.contains("destroyed") { Fault::Lost(first_line(&text)) } else { Fault::Error(first_line(&text)) }; + let fault = if matches!(e, wgpu::Error::OutOfMemory { .. }) { + Fault::Error(OUT_OF_MEMORY.into()) + } else if lower.contains("lost") || lower.contains("destroyed") { + Fault::Lost(first_line(&text)) + } else { + Fault::Error(first_line(&text)) + }; self.mark(fault); } - /// Mark the device unusable. The first fault is kept; a later device loss upgrades an error. + /// Mark the device unusable. The first fault is kept; a later device loss upgrades an error, + /// and a later out-of-memory error upgrades another one, so the canvas can still recover by + /// itself when memory ran out after a validation error in the same frame (#6564). pub fn mark(&self, fault: Fault) { let mut slot = self.0.fault.lock().unwrap_or_else(PoisonError::into_inner); let replace = match slot.as_ref() { None => true, - Some(old) => !old.is_lost() && fault.is_lost(), + Some(old) => !old.is_lost() && (fault.is_lost() || (fault.is_out_of_memory() && !old.is_out_of_memory())), }; if replace { *slot = Some(fault); @@ -229,5 +248,17 @@ mod tests { assert_eq!(Fault::Error("x".into()).to_string(), "GPU error: x"); assert_eq!(first_line("\n Buffer 'pc' is invalid\nmore"), "Buffer 'pc' is invalid"); assert_eq!(first_line(&"a".repeat(300)).chars().count(), 201); + assert!(Fault::Error("Out of Memory".into()).is_out_of_memory()); + assert!(!Fault::Error("Validation Error".into()).is_out_of_memory()); + assert!(!Fault::Error("Validation Error: buffer allocation would run out of memory".into()).is_out_of_memory()); + // A later out-of-memory error replaces another error, but never a loss. + let h = DeviceHealth::new(); + h.mark(Fault::Error("Validation Error".into())); + h.mark(Fault::Error(OUT_OF_MEMORY.into())); + assert!(h.fault().is_some_and(|f| f.is_out_of_memory())); + h.mark(Fault::Lost("reset".into())); + h.mark(Fault::Error(OUT_OF_MEMORY.into())); + assert!(h.fault().is_some_and(|f| f.is_lost())); + assert!(!Fault::Lost("out of memory".into()).is_out_of_memory()); } } diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index cd2bb7bc4d..e00c384790 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -19,10 +19,25 @@ const STARTED_MAX_FRAMES: u64 = 120; /// Hook run once the app has rendered its first frames (see [`PhotocraftApp::on_started`]). pub type StartedHook = Box; -/// Per-frame check: switch to the CPU canvas when the GPU faulted, and run the started hook. +/// Out-of-memory errors after which the GPU canvas is rebuilt by itself; past them the CPU +/// renderer stays until the user chooses Retry GPU, so a document that never fits doesn't rebuild +/// the canvas on every frame. +pub const OOM_AUTO_RETRIES: u32 = 3; + +/// Per-frame check: switch to the CPU canvas when the GPU faulted, and run the started hook. An +/// out-of-memory error on a device that still works is not the end of the GPU for the session +/// (#6564): the dropped canvas freed its memory, so a new one is built at once and the views +/// redraw through it, up to [`OOM_AUTO_RETRIES`] times. pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { if let Some(fault) = app.gpu.as_ref().and_then(|g| g.fault()) { fall_back(app, &fault); + if fault.is_out_of_memory() && app.gpu_oom_retries < OOM_AUTO_RETRIES && can_retry_now(app) { + app.gpu_oom_retries += 1; + if retry_now(app) { + log::warn!("{fault}; rebuilt the GPU canvas (automatic retry {} of {OOM_AUTO_RETRIES})", app.gpu_oom_retries); + app.ui.gpu_fallback_notice = None; + } + } ctx.request_repaint(); } if app.started.is_some() { @@ -372,6 +387,47 @@ mod tests { assert_eq!(saves.get(), 2, "retry choice writes once"); } + /// #6564: an out-of-memory error on a device that still works rebuilds the GPU canvas by itself, + /// with no notice and the documents untouched, a few times a session; then the CPU renderer + /// stays until Retry GPU. Other errors and a loss still fall back at once. + #[test] + fn out_of_memory_rebuilds_the_gpu_canvas_by_itself() { + let _gpu = photocraft_testkit::gpu_lock(); + let rs = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())); + let Ok(rs) = rs else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ctx = egui::Context::default(); + let oom = || photocraft_gpu::Fault::Error("Out of Memory".into()); + for n in 1..=OOM_AUTO_RETRIES { + app.gpu_health().unwrap().mark(oom()); + check(&mut app, &ctx); + assert!(app.gpu_active(), "retry {n}: the GPU canvas is back"); + assert!(app.gpu_health().unwrap().is_ok(), "retry {n}: with a fresh health flag"); + assert_eq!(app.perf.gpu_info.canvas, "gpu"); + assert!(app.ui.gpu_fallback_notice.is_none(), "retry {n}: no recovery dialog"); + assert_eq!(app.session.documents().len(), 1); + } + // Past the limit the CPU renderer stays and the dialog offers Retry GPU. + app.gpu_health().unwrap().mark(oom()); + check(&mut app, &ctx); + assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some()); + assert_eq!(app.perf.gpu_info.canvas, "cpu"); + + // A validation error is not retried by itself. + let rs = egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Validation Error".into())); + check(&mut app, &ctx); + assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some()); + } + /// #6577: after a GPU error on a device that still works (out of memory, validation), Retry /// GPU builds a new GPU canvas at once, without a restart or closing documents. After a loss /// the device can't be used again, so the retry still waits for the next launch. @@ -388,8 +444,9 @@ mod tests { app.set_wgpu(rs); app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); let ctx = egui::Context::default(); - // An error on a live device: Retry GPU rebuilds the canvas now. - app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Out of Memory".into())); + // An error on a live device (one not retried by itself, see #6564): Retry GPU rebuilds + // the canvas now. + app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Validation Error".into())); check(&mut app, &ctx); assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some()); assert!(can_retry_now(&app)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6ea5992d43..e37aaf6ef4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -955,6 +955,8 @@ pub struct PhotocraftApp { gpu_render_state: Option, /// Why the GPU canvas was dropped (see `gpu_status::fall_back`). gpu_fault: Option, + /// Out-of-memory errors this session after which the GPU canvas was rebuilt by itself (#6564). + gpu_oom_retries: u32, /// Run once the first frames have rendered (see [`Self::on_started`]). started: Option, /// Frame and canvas-upload timings (exposed via `ui.inspect`). @@ -1135,6 +1137,7 @@ impl PhotocraftApp { window_recovery_retry: None, gpu_render_state: None, gpu_fault: None, + gpu_oom_retries: 0, started: None, perf: Default::default(), stroke_trace: stroke_trace::StrokeTrace::from_env(), From 7264368a75ffc7e9d6e349a445aeb42c54f61b50 Mon Sep 17 00:00:00 2001 From: hai Date: Sat, 24 Oct 2026 05:39:55 +0800 Subject: [PATCH 23/40] Layers: show effect row names through tl! (#8754) The Layers panel drew Effect::label and "Effects" in English even when catalogs already translated them. Co-authored-by: zhanghaijun01 --- crates/ui-egui/src/panels.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4dbec87cdc..7a51e10681 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -4755,9 +4755,10 @@ fn start_fx_drag(ctx: &egui::Context, layer: LayerId, effect: Option) { fn fx_drag_feedback(ctx: &egui::Context, doc: &photocraft_doc::Document) { let Some((from, effect)) = ctx.data(|d| d.get_temp::(fx_drag_key())) else { return }; let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + // `Effect::label` is the English key; translate for the drag ghost like the Layers panel rows. let label = match effect { - Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map_or("", |e| e.label()), - None => "Effects", + Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map(|e| tl!(e.label())).unwrap_or_default(), + None => tl!("Effects"), }; crate::layer_transfer::ghost(ctx, p, label); if ctx.input(|i| i.modifiers.alt) { @@ -4797,10 +4798,12 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: & fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); let indent = 30.0 + depth as f32 * 14.0 + 34.0; - let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; + // English `Effect::label` keys stay for kind matching; display goes through `tl!` (catalogs + // already have Drop Shadow → 投影, Effects → 效果, …). + let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![(tl!("Effects").into(), l.effects.enabled, None)]; for e in &l.effects.items { let kind = crate::layer_style::KINDS.iter().find(|k| k.1 == e.label()).map(|k| k.0); - rows.push((e.label().to_string(), e.enabled(), kind)); + rows.push((tl!(e.label()).into(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); From 44db720b22b2fed8b6a1cd93c69e83362b923830 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:40:00 +0900 Subject: [PATCH 24/40] Black & White: keep 32-bit superwhites (#8537) (#8748) The grey was clamped to 0..1 at every depth, so a 32-bit (2, 2, 2) came out as (1, 1, 1). In 32-bit documents it now clamps to 0..16, as Photoshop 27.11 does; 8 and 16-bit still clip at 1. CPU and GPU both. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/adjust.rs | 26 +++++++++++++++++++++++--- crates/gpu/src/compose.wgsl | 3 ++- crates/gpu/tests/it/parity.rs | 1 + 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index d122a09993..d03c7a5079 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -198,8 +198,11 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } Adjustment::BlackWhite { weights, tint } => { let hdr = depth == Some(SampleType::F32); + // 32-bit greys keep their superwhites: Photoshop 27.11 maps negatives to 0 and caps + // neutrals at 16, but leaves 2.0 at 2.0 (#8537). + let hi = if hdr { 16.0 } else { 1.0 }; map_rgb(buf, |c| { - let g = black_white_gray(c, weights); + let g = black_white_gray(c, weights).clamp(0.0, hi); match tint { // Photoshop tints like the Color blend mode: the tint's hue and saturation at // the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In @@ -744,7 +747,8 @@ pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] { /// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a /// secondary-colour part (yellow, cyan or magenta: middle − smallest) and a primary part (red, /// green or blue: largest − middle); the sliders (percent, Photoshop defaults 40 60 40 60 20 80 for -/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts. +/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts. Unclipped: the +/// document's depth decides the range. pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 { let (r, g, b) = (c[0], c[1], c[2]); let (max, min) = (r.max(g).max(b), r.min(g).min(b)); @@ -764,7 +768,7 @@ pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 { } else { 5 // red + blue = magenta }; - (min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0).clamp(0.0, 1.0) + min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0 } /// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y). @@ -2197,6 +2201,22 @@ mod tone_tests { assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d)); } + /// #8537: 32-bit superwhites stay above 1 (Photoshop 27.11: 2.0 stays 2.0, neutrals cap at + /// 16, negatives go to 0); 8-bit still clips at 1. + #[test] + fn black_white_keeps_32_bit_superwhites() { + let adj = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }; + let run = |depth, c: f32| { + let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c, c, c, 1.0]] }; + apply_depth(&adj, &mut b, Transfer::Srgb, Some(depth)); + b.px[0][0] + }; + assert_eq!(run(SampleType::F32, 2.0), 2.0); + assert_eq!(run(SampleType::F32, 20.0), 16.0); + assert_eq!(run(SampleType::F32, -0.5), 0.0); + assert_eq!(run(SampleType::U8, 2.0), 1.0); + } + /// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint /// is blended like the Color blend mode: its hue and saturation at the grey's luminosity. #[test] diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index c10f27d4f2..a06996a214 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -751,7 +751,8 @@ fn adjust(c: vec3) -> vec3 { if (c.r >= c.g && c.r >= c.b) { primary = 0; } else if (c.g >= c.b) { primary = 2; } var secondary = 5; if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; } - let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0); + // 32-bit: superwhites stay, up to Photoshop's 16 + let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, select(1.0, 16.0, hdr)); if (p1.z > 0.5) { if (hdr) { return set_lum(p2.rgb, g, F32_MAX); } // 32-bit: nothing clips at 1 return clamp(set_lum(p2.rgb, g, 1.0), vec3(0.0), vec3(1.0)); // Color blend of the tint diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 464ad1f1a2..c07b43833a 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -65,6 +65,7 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { Adjustment::PhotoFilter { color: [0.97, 0.76, 0.0], density: 0.4, preserve_luminosity: true }, Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false }, Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) }, + Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }, ] { let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32); let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F); From 159fe710a2febee873d8d5b34bb3b56935628721 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:40:06 +0100 Subject: [PATCH 25/40] feat(guides): New Guide Layout takes lengths with units (#8719) * feat(guides): New Guide Layout takes lengths with units view.newGuideLayout took pixels only. Its widths, gutters and margins now also take text with a unit as typed in Photoshop's dialog ("1 cm", "0.25in", "12 pt", "3pc", "10%"), at the document's resolution; percent is of the target's width for columns, gutters and left/right margins and of its height for rows and top/bottom margins, per artboard. The dialog's Width and Height fields already pass such text through, so they accept units now too. Part of #4984 Co-Authored-By: Claude Opus 5.5 * test(guides): New Guide Layout percent resolves per selected artboard Co-Authored-By: Claude Opus 5.5 * test(guides): assert the full guide lists after a selected-artboard layout Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 2 +- crates/engine/src/file_cmds/tests.rs | 44 +++++++++++++++++++ crates/engine/src/guide_layout.rs | 65 +++++++++++++++++++++++----- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index ebc7c49640..b5878fa3db 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -2061,7 +2061,7 @@ pub fn specs() -> Vec { "New Guide Layout…", &["View"], None, - r##"{"columns":n=0,"width":px?,"gutter":px=0,"rows":n=0,"height":px?,"rowGutter":px=gutter,"margin":px|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"}"##, + r##"{"columns":n=0,"width":len?,"gutter":len=0,"rows":n=0,"height":len?,"rowGutter":len=gutter,"margin":len|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"} (len: pixels, or text with px, in, cm, mm, pt, pc or % as typed in Photoshop, e.g. "1 cm"; % is of the target's width for columns, gutters and left/right margins, its height for rows and top/bottom margins)"##, has_doc, new_guide_layout ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 9132af44d9..f034a62656 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1242,6 +1242,50 @@ fn exported_selected_cube_round_trips_through_lookup_importer() { assert_eq!(s.active().unwrap().doc.layers.len(), 3); } +/// #4984: New Guide Layout takes lengths with units as typed in Photoshop's dialog, at the +/// document's resolution; percent is of the target's width or height, by axis. +#[test] +fn guide_layouts_take_units() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1200, "height": 600, "resolution": 300})).unwrap(); + let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01); + // 1 cm at 300 ppi is 118.11 px; 0.25 in is 75 px. + s.execute("view.newGuideLayout", json!({"columns": 2, "gutter": "1 cm", "margin": "0.25in"})).unwrap(); + let g = &doc(&s).guides; + let col = (1050.0 - 118.110_236) / 2.0; + assert!(close(&g.vertical, &[75.0, 1125.0, 75.0 + col, 75.0 + col + 118.110_236]), "{:?}", g.vertical); + assert!(close(&g.horizontal, &[75.0, 525.0]), "{:?}", g.horizontal); + // Percent of the width for columns, of the height for rows and top/bottom margins; points + // follow Units & Rulers (72 per inch: 36 pt is 150 px, centred like the columns). + s.execute( + "view.newGuideLayout", + json!({"columns": 1, "width": "25%", "centerColumns": true, "rows": 1, "height": "36 pt", "margin": ["10%", 0, "10%", 0], "clearExisting": true}), + ) + .unwrap(); + let g = &doc(&s).guides; + assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0]), "{:?}", g.vertical); + assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0]), "{:?}", g.horizontal); + // With selected artboards, percent is of each board, not of the document. + s.execute("layer.new.artboard", json!({"rect": [200, 100, 140, 40]})).unwrap(); + s.execute( + "view.newGuideLayout", + json!({"columns": 1, "width": "50%", "centerColumns": true, "rows": 1, "height": "50%", "target": "selectedArtboards", "clearExisting": true}), + ) + .unwrap(); + let g = &doc(&s).guides; + // Clear Existing removes guides inside the board only; those outside it stay. + assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0, 235.0, 305.0]), "{:?}", g.vertical); + assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0, 110.0, 130.0]), "{:?}", g.horizontal); + for bad in [ + json!({"columns": 2, "gutter": "1 furlong"}), + json!({"columns": 2, "gutter": "-1 cm"}), + json!({"columns": 2, "margin": [0, "x", 0, 0]}), + json!({"columns": 2, "gutter": true}), + ] { + assert!(s.execute("view.newGuideLayout", bad.clone()).is_err(), "{bad}"); + } +} + #[test] fn guide_layouts() { let mut s = session(100, 50, 8); diff --git a/crates/engine/src/guide_layout.rs b/crates/engine/src/guide_layout.rs index 4a4261faff..6c4eb89c00 100644 --- a/crates/engine/src/guide_layout.rs +++ b/crates/engine/src/guide_layout.rs @@ -4,6 +4,7 @@ use photocraft_doc::Guides; use photocraft_geom::Rect; use serde_json::Value; +use crate::prefs::Unit; use crate::{EngineError, Result, Session}; const COMMAND: &str = "view.newGuideLayout"; @@ -14,10 +15,46 @@ fn bad(message: impl Into) -> EngineError { EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() } } -fn number(p: &Value, key: &str, default: f64) -> Result { +/// A length as given: a number of pixels, or text with a unit as typed in Photoshop's New Guide +/// Layout fields ("2 cm", "0.25in", "12 pt", "10%"), resolved against each target (#4984). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Length { + Px(f64), + In(f64, Unit), +} + +impl Length { + /// Pixels at the document's `dpi`; percent is of `extent` (the target's width for column + /// sizes and left/right margins, its height for row sizes and top/bottom margins). + fn px(self, key: &str, dpi: f64, extent: f64, ppi: f64) -> Result { + let v = match self { + Length::Px(v) => v, + Length::In(v, unit) => unit.to_px(v, dpi, extent, ppi), + }; + if v.is_finite() && v >= 0.0 { Ok(v) } else { Err(bad(format!("{key} must be a finite non-negative length"))) } + } +} + +fn length_of(key: &str, v: &Value) -> Result { + let invalid = || bad(format!("{key} must be a non-negative number of pixels, or text with px, in, cm, mm, pt, pc or %")); + let length = match (v.as_f64(), v.as_str()) { + (Some(n), _) => Length::Px(n), + (None, Some(text)) => match Unit::parse_length(text).ok_or_else(invalid)? { + (n, None | Some(Unit::Pixels)) => Length::Px(n), + (n, Some(unit)) => Length::In(n, unit), + }, + _ => return Err(invalid()), + }; + match length { + Length::Px(n) | Length::In(n, _) if n.is_finite() && n >= 0.0 => Ok(length), + _ => Err(invalid()), + } +} + +fn number(p: &Value, key: &str, default: Length) -> Result { match p.get(key) { None | Some(Value::Null) => Ok(default), - Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).ok_or_else(|| bad(format!("{key} must be a finite non-negative number"))), + Some(v) => length_of(key, v), } } @@ -110,9 +147,10 @@ pub fn calculate(s: &Session, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let cols = count(p, "columns")?; let rows = count(p, "rows")?; - let width = number(p, "width", 0.0)?; - let height = number(p, "height", 0.0)?; - let gap = number(p, "gutter", 0.0)?; + let zero = Length::Px(0.0); + let width = number(p, "width", zero)?; + let height = number(p, "height", zero)?; + let gap = number(p, "gutter", zero)?; let row_gap = number(p, "rowGutter", gap)?; let center = flag(p, "centerColumns")?; let clear = flag(p, "clearExisting")?; @@ -121,15 +159,16 @@ pub fn calculate(s: &Session, p: &Value) -> Result { if a.len() != 4 { return Err(bad("margin must contain top, left, bottom and right")); } - let mut m = [0.0; 4]; + let mut m = [zero; 4]; for (out, value) in m.iter_mut().zip(a) { - *out = value.as_f64().filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| bad("margins must be finite non-negative numbers"))?; + *out = length_of("margin", value)?; } m } - _ => [number(p, "margin", 0.0)?; 4], + _ => [number(p, "margin", zero)?; 4], }; - let [top, left, bottom, right] = margins; + let dpi = f64::from(st.doc.resolution_dpi); + let ppi = s.prefs().units_and_rulers.point_size.per_inch(); let bounds = targets(s, p)?; // Bound the aggregate work as well as each count, including multi-artboard requests. if (cols + rows + 4).saturating_mul(2).saturating_mul(bounds.len() as u64) > MAX_EDGES { @@ -137,16 +176,22 @@ pub fn calculate(s: &Session, p: &Value) -> Result { } let mut added = Guides::default(); for rect in &bounds { + let (across, down) = (f64::from(rect.width()), f64::from(rect.height())); + let [top, left, bottom, right] = + [(margins[0], down), (margins[1], across), (margins[2], down), (margins[3], across)].map(|(m, extent)| m.px("margin", dpi, extent, ppi)); + let (top, left, bottom, right) = (top?, left?, bottom?, right?); let (x0, y0, x1, y1) = (f64::from(rect.x0) + left, f64::from(rect.y0) + top, f64::from(rect.x1) - right, f64::from(rect.y1) - bottom); if x0 >= x1 || y0 >= y1 { return Err(bad("margins must leave space inside the target")); } - if margins.iter().any(|x| *x != 0.0) { + if [top, left, bottom, right].iter().any(|x| *x != 0.0) { push_unique(&mut added.vertical, x0)?; push_unique(&mut added.vertical, x1)?; push_unique(&mut added.horizontal, y0)?; push_unique(&mut added.horizontal, y1)?; } + let (width, gap) = (width.px("width", dpi, across, ppi)?, gap.px("gutter", dpi, across, ppi)?); + let (height, row_gap) = (height.px("height", dpi, down, ppi)?, row_gap.px("rowGutter", dpi, down, ppi)?); axis(x0, x1, cols, width, gap, center, &mut added.vertical)?; axis(y0, y1, rows, height, row_gap, center, &mut added.horizontal)?; } From 7bc6fd8a4f29a08eabbaed57fe3fa7a0d3a47813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= <79342117+mercuryVM@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:40:11 -0300 Subject: [PATCH 26/40] Write the Levels and Curves composite channel as Chnl.Cmps in .atn export (#8686) Photoshop records the composite entry of a Levels or Curves Set as Chnl/Chnl/Cmps and silently leaves the adjustment unchanged when it is Chnl/Ordn/Trgt, which is what the export wrote. Import already reads both. Fixes #8543 --- crates/engine/src/atn_cmds.rs | 8 +++----- crates/engine/src/atn_cmds/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index a3f7b0e0da..30a05192ec 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -377,13 +377,11 @@ fn channel_key(d: &Descriptor) -> Option> { }) } +/// A Levels or Curves entry's channel: `Chnl.Cmps` for the composite, as Photoshop records it. +/// Photoshop silently skips a Set whose composite is `Ordn.Trgt` (#8543). fn channel_ref(key: Option<&str>) -> D { let value = key.and_then(|k| lookup_back(CHANNELS, k)); - D::Reference(vec![ReferenceItem::Enumerated { - class: class("Chnl"), - type_id: code(if value.is_some() { "Chnl" } else { "Ordn" }), - value: code(value.unwrap_or("Trgt")), - }]) + D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(value.unwrap_or("Cmps")) }]) } // ------------------------------------------------------------------ field tables diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 065080b02e..02474b8858 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -2383,6 +2383,32 @@ fn defaults_photoshop_plays_differently_are_written() { assert_eq!(get_list(&balance, "ShdL").map(<[D]>::len), Some(3)); } +/// Photoshop records the composite of a Levels or Curves entry as `Chnl.Cmps` and silently skips +/// a Set that names it `Ordn.Trgt` (#8543). A colour channel keeps its own code. +#[test] +fn levels_and_curves_name_the_composite_channel_as_photoshop_records_it() { + let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export")); + let channels = |d: &Descriptor| -> Vec<(String, String)> { + get_list(d, "Adjs") + .unwrap_or_default() + .iter() + .filter_map(|e| match e { + D::Descriptor(e) => match get_ref(e, "Chnl")?.first()? { + ReferenceItem::Enumerated { type_id, value, .. } => Some((trimmed(type_id), trimmed(value))), + _ => None, + }, + _ => None, + }) + .collect() + }; + let composite = ("Chnl".to_string(), "Cmps".to_string()); + let red = ("Chnl".to_string(), "Rd".to_string()); + let levels = exported("image.adjustments.levels", json!({"inBlack": 31, "red": {"inBlack": 2}})); + assert_eq!(channels(&levels), vec![composite.clone(), red.clone()]); + let curves = exported("image.adjustments.curves", json!({"points": [[0, 0], [128, 140], [255, 255]], "red": [[0, 10], [255, 245]]})); + assert_eq!(channels(&curves), vec![composite, red]); +} + /// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one. #[test] fn set_colors_exports_only_a_single_colour() { From 4092fe79c94c3eed9ba66f8f82bc362f6eb560a6 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 23 Oct 2026 23:40:16 +0200 Subject: [PATCH 27/40] fix(cms): reject malformed hex data in .look LUTs (#8657) Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com> --- crates/cms/src/lutfile.rs | 41 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 240aac36f8..dbe6281d8f 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -221,7 +221,14 @@ pub fn parse_look(text: &str) -> Result { }; let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no in .look".into()))?; let (entries, _) = checked_data_len(size)?; - let hex: Vec = tag("data").ok_or_else(|| LutError("no in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect(); + let hex: Vec = tag("data").ok_or_else(|| LutError("no in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if hex.iter().any(|b| !b.is_ascii_hexdigit()) { + return err("invalid hexadecimal in .look"); + } + // Each float needs eight hex digits; as_chunks would silently discard a partial value. + if !hex.len().is_multiple_of(8) { + return err("incomplete float in .look "); + } let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8; let bytes: Vec = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect(); let floats: Vec = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); @@ -456,6 +463,38 @@ DOMAIN_MAX 0.5 1 1 assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data); } + #[test] + fn look_rgb_and_rgba_allow_whitespace_in_hex_data() { + let id = LutFile::identity(2); + for channels in [3, 4] { + let mut hex = String::new(); + for rgb in id.data.as_chunks::<3>().0 { + for value in rgb.iter().copied().chain((channels == 4).then_some(1.0)) { + for byte in value.to_le_bytes() { + hex.push_str(&format!("{byte:02x} \t\r\n")); + } + } + } + let xml = format!("2\"{hex}\""); + assert_eq!(parse("valid.look", xml.as_bytes()).unwrap().data, id.data); + } + } + + #[test] + fn look_rejects_partial_floats_and_invalid_hex() { + let id = LutFile::identity(2); + let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + for suffix in ["0", "00", "0000", "000000", "G", "é"] { + let xml = format!("2{hex}{suffix}"); + assert!(parse("bad.look", xml.as_bytes()).is_err(), "accepted malformed suffix {suffix:?}"); + } + let invalid = hex.replacen('0', "G", 1); + let xml = format!("2{invalid}"); + assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("invalid hexadecimal")); + let xml = format!("2{hex}00"); + assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("incomplete float")); + } + #[test] fn look_3d_size_exponent_overflow_is_rejected() { let text = format!("{}00", usize::MAX); From c1158f92e8128c45625f7e102eb374d9ba395321 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:40:22 +0200 Subject: [PATCH 28/40] Accept all-caps possessives in spell check (#8628) Found while testing crates/text/src/spell.rs:91 (Dictionary::check) Co-authored-by: Claude Haiku 5.5 --- crates/text/src/spell.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/text/src/spell.rs b/crates/text/src/spell.rs index 83576c8999..bc2afa63c8 100644 --- a/crates/text/src/spell.rs +++ b/crates/text/src/spell.rs @@ -88,7 +88,7 @@ impl Dictionary { /// Whether `word` is spelled correctly (also accepting `user` words, compared without case). pub fn check(&self, word: &str, user: &HashSet) -> bool { let w = normalize(word); - let w = w.strip_suffix("'s").filter(|b| !b.is_empty()).unwrap_or(&w); + let w = w.strip_suffix("'s").or_else(|| w.strip_suffix("'S")).filter(|b| !b.is_empty()).unwrap_or(&w); let w = w.trim_matches('\''); if w.is_empty() || w.chars().any(|c| c.is_numeric()) || !w.chars().any(char::is_alphabetic) { return true; @@ -258,6 +258,15 @@ mod tests { HashSet::new() } + #[test] + fn all_caps_possessive_is_accepted() { + let d = Dictionary::english(); + for w in ["MOTHER'S", "MONDAY'S", "Mother's", "mother's", "MOTHER’S"] { + assert!(d.check(w, &none()), "{w}"); + } + assert!(!d.check("MOTHER'SX", &none())); + } + #[test] fn bundled_dictionary_loads() { let d = Dictionary::english(); From 03dd76022bc40f98bd644945919c110bad4e9939 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:40:27 +0200 Subject: [PATCH 29/40] Reject non-hex characters in engine hex color parser (#8541) Found while testing crates/engine/src/commands.rs:283-291 (parse_hex) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/commands.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ac5663683b..54aa08fc80 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -282,6 +282,10 @@ pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> } pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { let s = s.trim_start_matches('#'); + // `from_str_radix` accepts a leading '+', so "+f" would read as 0x0f. + if !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0); match s.len() { 6 => Some([b(0)?, b(2)?, b(4)?, 1.0]), @@ -1839,3 +1843,18 @@ mod set_props_tests { assert_eq!(layer(&s), (0.0, 1.0)); } } + +#[cfg(test)] +mod parse_hex_tests { + use super::parse_hex; + + #[test] + fn signs_and_other_non_hex_characters_are_rejected() { + for bad in ["#+f+f+f", "+f+f+f+f", "#-f0000", "#gg0000", "#0f0f+f"] { + assert_eq!(parse_hex(bad), None, "{bad:?} is not hex digits"); + } + let g = 15.0 / 255.0; + assert_eq!(parse_hex("#0f0f0f"), Some([g, g, g, 1.0])); + assert_eq!(parse_hex("#0f0f0fff"), Some([g, g, g, 1.0])); + } +} From 4bda8d73886af6d9c97ada2917f5d2016ae30197 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:40:32 -0500 Subject: [PATCH 30/40] test(panic_hunt): give the panic-counting self-test its own deadline (#8473) contained_panics_count_and_graceful_errors_do_not used the 4 s hang budget meant for fuzzed commands. With RUST_BACKTRACE=1 the panic hook prints a backtrace, and on loaded Windows runners the first symbol lookup took long enough (19.6 s) that run_watched returned None instead of Some(false). The self-test now waits up to 120 s; the fuzz keeps 4 s. --- crates/engine/tests/panic_hunt.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index 893ba5c23d..91f42be306 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -30,9 +30,14 @@ static PANICS: AtomicUsize = AtomicUsize::new(0); /// One run at a time, so a panic is charged to the run that caused it. static RUN: Mutex<()> = Mutex::new(()); -/// Run `f` on a worker thread. `None` if it timed out, else whether it finished without -/// panicking, contained or not (returning Ok or a graceful Err both count). -fn run_watched(f: impl FnOnce() + Send + 'static) -> Option { +/// Budget for the self-test below, which checks how panics are counted, not hangs. The panic hook +/// prints a backtrace under `RUST_BACKTRACE=1` (as in CI), and on a loaded Windows runner the +/// first symbol lookup alone can take 15–20 s. +const SELF_TEST_DEADLINE: Duration = Duration::from_secs(120); + +/// Run `f` on a worker thread. `None` if it took longer than `deadline`, else whether it +/// finished without panicking, contained or not (returning Ok or a graceful Err both count). +fn run_watched(deadline: Duration, f: impl FnOnce() + Send + 'static) -> Option { static HOOK: Once = Once::new(); HOOK.call_once(|| { let default = std::panic::take_hook(); @@ -48,13 +53,13 @@ fn run_watched(f: impl FnOnce() + Send + 'static) -> Option { let r = catch_unwind(AssertUnwindSafe(f)); let _ = tx.send(r.is_ok()); }); - rx.recv_timeout(DEADLINE).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) + rx.recv_timeout(deadline).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) } /// Run `id` with `p` on a fresh session; see [`run_watched`]. fn run_once(id: &str, p: &Value) -> Option { let (id, p) = (id.to_string(), p.clone()); - run_watched(move || { + run_watched(DEADLINE, move || { let mut s = fresh(); let _ = s.execute(&id, p); }) @@ -107,11 +112,11 @@ fn no_command_panics_or_hangs_on_adversarial_params() { /// #4982: a panic the code under test contains still fails the run; a graceful `Err` does not. #[test] fn contained_panics_count_and_graceful_errors_do_not() { - let contained = run_watched(|| { + let contained = run_watched(SELF_TEST_DEADLINE, || { let _ = catch_unwind(|| panic!("contained, as Session::execute does")); }); assert_eq!(contained, Some(false)); - let graceful = run_watched(|| { + let graceful = run_watched(SELF_TEST_DEADLINE, || { let mut s = fresh(); assert!(s.execute("layer.delete", json!({"layer": 999999})).is_err()); }); From 0bd37f81af5b44f1195bf4cdc39990616beab345 Mon Sep 17 00:00:00 2001 From: paulou-jpg <79102098+paulou-jpg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:40:39 -0700 Subject: [PATCH 31/40] Puppet Warp pins go where they are dragged, without snapping (#8217) A pin drag took the active tool's snapping. With the Move tool that was a layer move's Smart Guides, which View > Snap doesn't turn off, so pins jumped to other layers' edges with snapping off. Puppet Warp and Perspective Warp drags no longer snap, as in Photoshop. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/snap_ui.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 4bead9828a..e19c95a2e7 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -204,7 +204,9 @@ pub(crate) fn retarget_move(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Mo fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { app.prefs_rt.snap = None; app.prefs_rt.snap_lines.clear(); - if app.session.active().is_none() { + // Puppet Warp's pins and Perspective Warp's quads go where they are dragged, as in Photoshop: + // the press is theirs, not the tool's (with the Move tool it took a layer move's Smart Guides). + if app.session.active().is_none() || app.distort.active() { return; } // The tool the press acts as: ⌘ makes a painting tool the Move tool, and a selection tool @@ -711,6 +713,30 @@ mod tests { assert!(app.prefs_rt.snap.is_none()); } + /// Puppet Warp's pins go where they are dragged: they don't snap, with View › Snap on or + /// off, whatever tool was active when it started. With the Move tool a pin drag took the + /// layer move's Smart Guides and jumped to the other layer's edges. + #[test] + fn puppet_warp_pins_never_snap() { + for (tool, snap) in [(Tool::Move, false), (Tool::Move, true), (Tool::RectMarquee, true), (Tool::Brush, false)] { + let mut app = app_with_box(); + app.ui.tool = tool; + app.ui.extras.snap = snap; + app.ui.view.show.smart_guides = true; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 321})).unwrap(); + let ctx = egui::Context::default(); + crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + // The mover's left edge 3 px right of the target's, the pin 2 px from the guide. + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 323.0, y: 100.0, pressure: 1.0 }, m); + assert!(app.prefs_rt.snap_lines.is_empty(), "{tool:?}, snap {snap}: {:?}", app.prefs_rt.snap_lines); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m); + let pins = &app.distort.puppet.as_ref().unwrap().warp.pins; + assert_eq!(pins.iter().map(|p| p.dst).collect::>(), vec![[323.0, 100.0]], "{tool:?}, snap {snap}"); + } + } + /// ⌘ turns a painting tool, or a marquee outside the selection, into the Move tool for the /// drag: it gets the Move tool's box and smart-guide snapping, not the selected tool's. #[test] From cd2b4aca0c62a440a21329e83a1482dea631d9e3 Mon Sep 17 00:00:00 2001 From: Yongjip Kim <13984989+yongjip@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:40:45 +0900 Subject: [PATCH 32/40] test(liquify): expand freeze-boundary regression coverage (#8082) --- .../algo/tests/it/liquify_freeze_formats.rs | 107 ++++++++++++++++++ crates/algo/tests/it/main.rs | 1 + 2 files changed, 108 insertions(+) create mode 100644 crates/algo/tests/it/liquify_freeze_formats.rs diff --git a/crates/algo/tests/it/liquify_freeze_formats.rs b/crates/algo/tests/it/liquify_freeze_formats.rs new file mode 100644 index 0000000000..e5def7106f --- /dev/null +++ b/crates/algo/tests/it/liquify_freeze_formats.rs @@ -0,0 +1,107 @@ +//! Final-render coverage for #7989, extending the boundary fix in #8012. +use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn bounds() -> Rect { + // Cross the tile boundary at x=256, with a nonzero origin on both axes. + Rect::new(192, -32, 320, 96) +} + +fn source(mode: ColorMode, sample: SampleType) -> Surface { + let format = PixelFormat::new(mode, sample, true); + let mut pixels = Vec::with_capacity(128 * 128 * format.channels()); + for y in 0..128 { + for x in 0..128 { + let alpha = if (48..52).contains(&x) { 0.0 } else { ((x + 3 * y) % 5 + 1) as f32 / 5.0 }; + for channel in 0..mode.color_channels() { + pixels.push(if alpha == 0.0 { 0.0 } else { ((x * 19 + y * 7 + channel * 43) % 251 + 2) as f32 / 255.0 }); + } + pixels.push(alpha); + } + } + let mut source = Surface::new(format); + source.write_region(bounds(), &pixels); + source +} + +fn warp(points: &[[f64; 2]], size: f64, pressure: f64) -> LiquifyStroke { + let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, size); + stroke.pressure = pressure; + stroke.points = points.iter().map(|point| point.to_vec()).collect(); + stroke +} + +fn freeze_left() -> LiquifyStroke { + let mut stroke = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0); + stroke.amount = Some(1.0); + stroke.points = vec![vec![192.0, -32.0], vec![256.0, -32.0], vec![256.0, 96.0], vec![192.0, 96.0]]; + stroke +} + +fn check_frozen_output(prior_warp: bool) { + for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = source(mode, sample); + for cell in [2.5, 4.0] { + let mut strokes = Vec::new(); + if prior_warp { + strokes.push(warp(&[[238.0, 20.0], [261.0, 28.0]], 64.0, 55.0)); + } + strokes.push(freeze_left()); + let mut field = LiquifyField::from_strokes(bounds(), cell, &strokes); + // #8012 establishes the bilinear mapping when the mask is applied. + // This test checks later edits, not exact pre-mask cubic preservation. + let before = apply_liquify(&source, &field); + assert_eq!(before.format(), source.format()); + assert_eq!(before == source, !prior_warp, "the fixture must retain an earlier warp and keep a fresh mask-only operation unchanged"); + strokes.push(warp(&[[268.0, 32.0], [252.0, 32.0]], 48.0, 80.0)); + field.apply_stroke(&strokes[strokes.len() - 1]); + let after = apply_liquify(&source, &field); + assert_eq!(after.format(), source.format()); + + let (mut protected, mut fractional_alpha, mut unfrozen_changes) = (0, 0, 0); + let mut frozen_warped = 0; + for y in bounds().y0..bounds().y1 { + for x in bounds().x0..bounds().x1 { + let pixel = before.pixel(x, y); + if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 1.0 { + protected += 1; + frozen_warped += usize::from(pixel != source.pixel(x, y)); + let alpha = pixel[mode.color_channels()]; + fractional_alpha += usize::from(alpha > 0.0 && alpha < 1.0); + // An unchanged F32 pixel can round on premultiply/unpremultiply. + // Integer-depth pixels must remain exactly equal. + let tolerance = if sample == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 }; + for (channel, (actual, expected)) in after.pixel(x, y).iter().zip(&pixel).enumerate() { + assert!( + (actual - expected).abs() <= tolerance, + "{mode:?}/{sample:?}, mesh {cell}, prior warp {prior_warp}: frozen channel {channel} changed at {x},{y}: {expected} -> {actual}" + ); + } + } else if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 0.0 { + unfrozen_changes += usize::from(after.pixel(x, y) != pixel); + } + } + } + assert!(protected > 7_000 && fractional_alpha > 3_000, "the fixture must cover frozen pixels with fractional alpha"); + assert_eq!(frozen_warped > 0, prior_warp, "the fixture must retain the earlier warp inside the fully frozen region"); + assert!(unfrozen_changes > 0, "the adjacent unfrozen region must still move"); + let replay = LiquifyField::from_strokes(bounds(), cell, &strokes); + assert!(field == replay, "stroke replay must reproduce the field exactly"); + assert!(apply_liquify(&source, &replay) == after, "stroke replay must reproduce the final pixels exactly"); + } + } + } +} + +#[test] +fn fresh_frozen_output_preserves_color_and_alpha_across_formats() { + check_frozen_output(false); +} + +#[test] +fn existing_frozen_mapping_survives_adjacent_warps_across_formats() { + check_frozen_output(true); +} diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs index 107626cfc2..e611f93cb9 100644 --- a/crates/algo/tests/it/main.rs +++ b/crates/algo/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact; mod warp_clipped; From b491de8e299d6fb438890f34f5ab3fb8ad8f3313 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:40:51 +0200 Subject: [PATCH 33/40] fix(layers): keep blend mode, Opacity, locks and Fill visible but greyed with no layer selected (#8880) (#8904) --- crates/ui-egui/src/layer_pct_slider_tests.rs | 15 +++++ crates/ui-egui/src/panels.rs | 58 ++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/crates/ui-egui/src/layer_pct_slider_tests.rs b/crates/ui-egui/src/layer_pct_slider_tests.rs index 4a717e210f..1d22cd255f 100644 --- a/crates/ui-egui/src/layer_pct_slider_tests.rs +++ b/crates/ui-egui/src/layer_pct_slider_tests.rs @@ -163,3 +163,18 @@ fn dragging_opacity_and_fill_labels_scrubs_percentages_in_one_history_step() { assert!((value(&layer(&h)) - 1.0).abs() < 1e-6, "{name}: undo restores the original value"); } } + +#[test] +fn opacity_and_fill_stay_visible_but_disabled_with_no_layer_selected() { + let mut h = harness(); + h.state_mut().session.execute("select.deselectLayers", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().session.active().unwrap().active_layer.is_none()); + for name in ["Opacity", "Fill"] { + let node = h + .query_all_by_role(Role::Button) + .find(|n| n.accesskit_node().label().as_deref() == Some(name)) + .unwrap_or_else(|| panic!("{name}: the control disappeared with no layer selected")); + assert!(node.accesskit_node().is_disabled(), "{name}: should be greyed out"); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7a51e10681..0e10c4c1e9 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2648,6 +2648,62 @@ fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool) ((available - reserved).max(40.0), show_label) } +/// Blend mode, Opacity, the locks and Fill with no layer selected: Photoshop keeps them in place, +/// greyed out at Normal, 100% and unlocked, instead of removing them (#8880). Same layout as the +/// active layer's rows in `layers`. +fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { + ui.add_enabled_ui(false, |ui| { + ui.horizontal(|ui| { + let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") }; + let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); + let mut m = BlendMode::Normal; + let options = blend_options(false, m); + widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &options, blend_width); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut o = 100.0; + widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); + if show_opacity_label { + scrub_pct_label(ui, opacity_label, &mut o, ui.available_width()); + } + }); + }); + ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0)); + ui.horizontal(|ui| { + let lock_label = if pro { tl!("Lock:") } else { tl!("Lock") }; + let lang = crate::i18n::current(); + let fill_label = if pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") }; + let gap = ui.spacing().item_spacing.x; + let icons_w = if pro { 5.0 * 20.0 + gap } else { 22.0 + gap }; + let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width(); + if fits { + label(ui, lock_label); + } + if pro { + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (icon, tip) in [ + ("grid-3x3", tl!("Lock transparent pixels")), + ("brush", tl!("Lock image pixels")), + ("move", tl!("Lock position")), + ("scan", tl!("Prevent auto-nesting in and out of Artboards and Frames")), + ("lock", tl!("Lock all")), + ] { + icons::button(ui, icon, 20.0, false, tip); + } + }); + } else { + icons::button(ui, "lock-open", 22.0, false, tl!("Lock layer")); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut f = 100.0; + widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); + scrub_pct_label(ui, fill_label, &mut f, ui.available_width()); + }); + }); + ui.add_space(2.0); + }); +} + fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // A new active layer opens its parent groups and is scrolled into view (#152). let reveal = crate::layer_reveal::track(app, ui.ctx()); @@ -2776,6 +2832,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }); ui.add_space(2.0); + } else { + inactive_blend_rows(ui, t.pro); } // Top of the stack first, groups above their contents, closed groups' contents hidden (#126). From 89d127d25981b569d3d7573a568605e5f27d33a9 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:40:56 +0100 Subject: [PATCH 34/40] fix(curves): 32-bit Curves continue past 0..1 like Photoshop (#8782) * fix(curves): 32-bit Curves continue past 0..1 like Photoshop In a 32-bit RGB document an edited Curves clipped every sample to the curve's table, so HDR values above 1 (and negative ones) were flattened. Photoshop extends the curve beyond its end points along its end segments: the straight curve [[0,0],[255,128]] maps -0.5, 0.5 and 2 to -0.250980407, 0.250980407 and 1.003921628 (measured in Photoshop 27.11 on the issue). The CPU compositor, Image > Adjustments and the GPU kernel now do the same, with the end slopes measured over 16 table steps. 8/16-bit documents and unedited curves are unchanged. Fixes #7157 Co-Authored-By: Claude Opus 5.5 * fix(curves): extend 32-bit curves in RGB documents only The curve's space is not the document's: a grayscale, CMYK or Lab 32-bit document with an RGB-space curve keeps the table's ends. The document mode now reaches the decision on the CPU (apply_in, the compositor) and the GPU (adjustment_program). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 45 +++++++++++++++++++++++++++----- crates/compose/src/lib.rs | 3 ++- crates/compose/src/tests.rs | 49 ++++++++++++++++++++++++++++++++++- crates/gpu/src/compose.wgsl | 18 +++++++++++-- crates/gpu/src/plan.rs | 8 +++--- crates/gpu/tests/it/parity.rs | 8 ++++++ 6 files changed, 118 insertions(+), 13 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index d03c7a5079..386ab5319a 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -77,6 +77,12 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) { /// unrounded). Integer depths work on whole levels like Photoshop's (see [`levels_q`] and /// `adjustment_quantum`), 32-bit documents get its float Levels (see [`levels_float`]). pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option) { + apply_depth_mode(adj, buf, transfer, depth, ColorMode::Rgb); +} + +/// [`apply_depth`] in a document of colour `mode` (which decides whether float curves extend, +/// see [`float_tone_extrapolates`]). +fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option, mode: ColorMode) { match adj { Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]), Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]), @@ -143,7 +149,7 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth return; } let luts = tone_luts_depth(adj, depth); - apply_tone(buf, *space, &luts, identity); + apply_tone(buf, *space, &luts, identity, float_tone_extrapolates(adj, depth, mode)); } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges)); @@ -338,7 +344,7 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O }); } } else { - apply_depth(adj, buf, transfer, depth); + apply_depth_mode(adj, buf, transfer, depth, mode); } } @@ -566,8 +572,8 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { } } -/// Float RGB curve channels with no edit must preserve samples outside the LUT domain. -/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle. +/// Float RGB curve channels with no edit must preserve samples outside the LUT domain; edited +/// ones extend past it (see [`float_tone_extrapolates`]). pub fn float_tone_identity(adj: &Adjustment, depth: Option) -> [bool; 3] { match adj { Adjustment::Curves { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => { @@ -577,9 +583,18 @@ pub fn float_tone_identity(adj: &Adjustment, depth: Option) -> [bool } } -pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec; 4], identity: [bool; 3]) { +/// Whether an edited Curves adjustment continues past 0..1 instead of clipping: in a 32-bit RGB +/// document Photoshop extends each curve beyond its end points along the slope of its end +/// segments (#7157: `[[0,0],[255,128]]` maps -0.5 to -0.250980407 and 2 to 1.003921628). Only +/// in RGB documents: the curve's space is not the document's, and other modes keep the table. +pub fn float_tone_extrapolates(adj: &Adjustment, depth: Option, mode: ColorMode) -> bool { + matches!(adj, Adjustment::Curves { space: ToneSpace::Rgb, .. }) && depth == Some(SampleType::F32) && mode == ColorMode::Rgb +} + +pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec; 4], identity: [bool; 3], extrapolate: bool) { + let sample = if extrapolate { lut_extended } else { lut }; match space { - ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })), + ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { sample(&luts[i], c[i]) })), ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(space, luts, c)), } } @@ -996,6 +1011,24 @@ fn lut(table: &[f32], v: f32) -> f32 { table[i] * (1.0 - f) + table[j] * f } +/// Table steps the end slopes of [`lut_extended`] are measured over: one step's difference near +/// 0.5 carries f32 rounding (2e-4 of the slope), while 16 steps are still only 0.4 % of the domain. +pub const TONE_SLOPE_SPAN: usize = 16; + +/// [`lut`] continued past 0..1 along the slope of the table's end segments, as Photoshop applies +/// a curve to float samples ([`float_tone_extrapolates`]). Inside the domain it is [`lut`]; +/// non-finite samples stay with it too, so an infinite one can't become NaN on a flat end. +#[inline] +fn lut_extended(table: &[f32], v: f32) -> f32 { + let n = table.len(); + if !v.is_finite() || (0.0..=1.0).contains(&v) || n < 2 { + return lut(table, v); + } + let k = TONE_SLOPE_SPAN.min(n - 1); + let per_unit = (n - 1) as f32 / k as f32; + if v < 0.0 { table[0] + v * (table[k] - table[0]) * per_unit } else { table[n - 1] + (v - 1.0) * (table[n - 1] - table[n - 1 - k]) * per_unit } +} + /// Photo Filter's linear-light RGB matrix. Photoshop multiplies the pixel's D50 XYZ, relative to /// white, by the filter colour's (mixed in by `density`): a fixed linear map, so it is folded into /// one matrix. With `normalize_y` the map keeps luminance Y (32-bit Preserve Luminosity; 8/16-bit diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6cb6707e0b..9c6da0a5b9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -890,7 +890,8 @@ impl<'a> Ctx<'a> { if identity == [true; 3] { return; } - adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity); + let extrapolate = adjust::float_tone_extrapolates(adj, Some(self.depth), self.mode); + adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity, extrapolate); } else { adjust::apply_in(adj, buf, self.transfer, Some(self.depth), self.mode); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 11da5a62f9..2ede50e9f0 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -94,7 +94,7 @@ fn identity_float_curves_match_previous_mapping_bits() { px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]], }; let mut previous = input.clone(); - adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]); + adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3], false); let mut actual = input.clone(); adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32)); assert_buffer_bits(&actual, &previous); @@ -2798,6 +2798,53 @@ fn exposure_scales_32_bit_samples_as_stored() { } } +/// #7157, measured on Photoshop 27.11: an edited Curves in a 32-bit RGB document continues past +/// 0..1 along its end segments instead of clipping. The straight curve `[[0,0],[255,128]]` maps +/// -0.5, 0.5 and 2 to -0.250980407, 0.250980407 and 1.003921628, through Image › Adjustments and +/// through an adjustment layer alike. 8/16-bit documents still clip to the table. +#[test] +fn float_curves_extend_past_the_unit_range_like_photoshop() { + use photocraft_doc::adjust::CurvePoint; + let line = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }]; + let ident = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]; + let adj = Adjustment::Curves { master: line, per_channel: std::array::from_fn(|_| ident.clone()), space: ToneSpace::Rgb, black: Vec::new(), pencil: false }; + let input = Buffer { rect: Rect::new(0, 0, 3, 1), px: vec![[-0.5, -0.5, -0.5, 1.0], [0.5, 0.5, 0.5, 1.0], [2.0, 2.0, 2.0, 1.0]] }; + // Photoshop's samples, compared in f64 so the reference keeps its printed digits. + let want = [-0.250980407f64, 0.250980407, 1.003921628]; + let check = |got: &Buffer, what: &str| { + for (p, w) in got.px.iter().zip(want) { + assert!(p[..3].iter().all(|v| (f64::from(*v) - w).abs() < 2e-5), "{what}: {p:?} vs {w}"); + assert_eq!(p[3], 1.0); + } + }; + let mut destructive = input.clone(); + adjust::apply_depth(&adj, &mut destructive, adjust::Transfer::Gamma(1.0), Some(SampleType::F32)); + check(&destructive, "Image › Adjustments"); + let doc = Document::new("hdr", Size::new(3, 1), ColorMode::Rgb, SampleType::F32); + let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&doc, &patterns); + let mut layer = input.clone(); + cx.apply_adjustment(&adj, &mut layer); + check(&layer, "adjustment layer"); + // Not at 8 or 16 bits: those samples never leave 0..1, and the table's ends hold. + for depth in [SampleType::U8, SampleType::U16] { + let mut clipped = input.clone(); + adjust::apply_depth(&adj, &mut clipped, adjust::Transfer::for_document(ColorMode::Rgb, depth), Some(depth)); + assert!(clipped.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "{depth:?}: {:?}", clipped.px[2]); + } + // Only in RGB documents: a 32-bit grayscale document keeps the table's end. + let mut gray = input.clone(); + adjust::apply_in(&adj, &mut gray, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Grayscale); + assert!(gray.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "grayscale: {:?}", gray.px[2]); + let mut rgb = input.clone(); + adjust::apply_in(&adj, &mut rgb, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Rgb); + check(&rgb, "apply_in in RGB"); + // Infinite samples don't become NaN on the way out. + let mut odd = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[f32::INFINITY, f32::NEG_INFINITY, 0.5, 1.0]] }; + adjust::apply_depth(&adj, &mut odd, adjust::Transfer::Gamma(1.0), Some(SampleType::F32)); + assert!(odd.px[0].iter().all(|v| !v.is_nan()), "{:?}", odd.px[0]); +} + #[test] fn float_adjustments_preserve_hdr_range() { let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index a06996a214..3eb1cfe172 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -616,8 +616,22 @@ fn levels_float(record: i32, v: f32) -> f32 { return lut_at(base + 3) + mapped * (lut_at(base + 4) - lut_at(base + 3)); } -fn tone_channel(row: i32, v: f32, identity: f32) -> f32 { +fn tone_channel(row: i32, v: f32, identity: f32, extrapolate: f32) -> f32 { if (identity > 0.5) { return v; } + // Mirrors compose::adjust::lut_extended: past 0..1 along the end segments' slopes, measured + // over TONE_SLOPE_SPAN (16) table steps. + if (extrapolate > 0.5 && abs(v) <= 3.0e38) { + if (v < 0.0) { + let a = textureLoad(lut_tex, vec2(0, row), 0).r; + let b = textureLoad(lut_tex, vec2(16, row), 0).r; + return a + v * (b - a) * (4095.0 / 16.0); + } + if (v > 1.0) { + let a = textureLoad(lut_tex, vec2(4079, row), 0).r; + let b = textureLoad(lut_tex, vec2(4095, row), 0).r; + return b + (v - 1.0) * (b - a) * (4095.0 / 16.0); + } + } return lut(row, v); } @@ -651,7 +665,7 @@ fn adjust(c: vec3) -> vec3 { return clamp(v, vec3(0.0), vec3(1.0)); } case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure - case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut) + case 7: { return vec3(tone_channel(0, c.r, p0.x, p0.w), tone_channel(1, c.g, p0.y, p0.w), tone_channel(2, c.b, p0.z, p0.w)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut) case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); } case 8: { // Hue/Saturation let hsl = rgb_to_hsl(c); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 69e4b64852..38cc879b09 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1027,7 +1027,7 @@ impl<'a> Planner<'a> { } let mut p = Pass::new(Kernel::Adjust, 0); p.a = Some(src); - let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, self.cx.depth); + let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, self.cx.depth, self.cx.mode); p.adjust_kind = kind; p.params = params; p.lut = lut; @@ -1656,7 +1656,7 @@ fn transfer_exponent(t: Transfer) -> f32 { } /// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`. -pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType) -> Program { +pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType, mode: photocraft_color::ColorMode) -> Program { let mut p = [[0.0f32; 4]; 4]; match adj { Adjustment::Invert => (1, p, None), @@ -1711,6 +1711,8 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf for (v, identity) in p[0].iter_mut().zip(adjust::float_tone_identity(adj, Some(depth))) { *v = if identity { 1.0 } else { 0.0 }; } + // p0.w: continue the curve past 0..1 (32-bit RGB Curves, #7157). + p[0][3] = if adjust::float_tone_extrapolates(adj, Some(depth), mode) { 1.0 } else { 0.0 }; (7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect())) } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { @@ -1996,7 +1998,7 @@ mod tests { fn color_lookup_with_an_overflowing_stored_size_uploads_no_table() { for size in [1 << 22, u32::MAX] { let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false }; - let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8); + let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8, photocraft_color::ColorMode::Rgb); assert_eq!((kind, rows.is_none()), (0, true), "size {size}"); } } diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index c07b43833a..3305083f4b 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -57,6 +57,14 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { black: Vec::new(), pencil: false, }, + // #7157: an edited curve continues past 0..1 along its end segments (Photoshop's `[[0,0],[255,128]]`). + Adjustment::Curves { + master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }], + per_channel: std::array::from_fn(|_| identity.clone()), + space: Default::default(), + black: Vec::new(), + pencil: false, + }, // 32-bit Photoshop rules: unclipped, legacy pivot 127, the mixer's 1/1024 steps and halved constant. Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }, Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true }, From def4646c0e412207ad098106beeeb3fc51976d40 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:41:02 +0100 Subject: [PATCH 35/40] fix(codecs): WebP export keeps the resolution (#8775) * fix(codecs): WebP export keeps the resolution Every WebP export with metadata warned that the resolution would be dropped, and lost it unless the image already had EXIF with resolution tags. WebP has no resolution field of its own, so both encoders now write the resolution into EXIF, adding the tags (or a minimal EXIF block when the image has none), and WebP reports DPI support. Part of #2399 Co-Authored-By: Claude Opus 5.5 * fix(codecs): report WebP DPI as dropped when the source EXIF can't be parsed That block is written as it is rather than replaced, so its metadata survives and the lost resolution is reported. The metadata test also checks the rest of the EXIF (Orientation) survives. Co-Authored-By: Claude Opus 5.5 * test(codecs): spell the WebP metadata test's bytes as escapes The Orientation tag and the unparseable EXIF block were raw control bytes, which also made git keep the file's CRLF line endings; both are escapes now and the file is LF again. Co-Authored-By: Claude Opus 5.5 * fix(codecs): no EXIF block just for WebP's default 72 ppi A WebP without EXIF already opens at 72 ppi, so writing a minimal EXIF for the default only turned every web export into the extended (VP8X) container (and broke the automation test expecting a simple lossy file). EXIF the image already has still gets its resolution tags updated. Co-Authored-By: Claude Opus 5.5 * fix(codecs): WebP DPI kept by XMP isn't reported as dropped When the image's EXIF can't be parsed but its XMP records the resolution, the WebP keeps it through XMP, which readers check first. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/webp.rs | 36 ++++++++++++++++++++++--- crates/codecs/src/fidelity.rs | 2 +- crates/codecs/src/format.rs | 3 ++- crates/codecs/tests/it/fidelity.rs | 5 ++-- crates/codecs/tests/it/metadata.rs | 43 ++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index 42afce3140..3f69d3c9cd 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -128,9 +128,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Result, CodecError None }; let icc = if opts.embed_icc { img.icc.as_deref() } else { None }; - let exif = if opts.embed_metadata { img.meta.exif.as_deref().map(|e| crate::resolution::export_exif(e, img.meta.dpi)) } else { None }; + let exif = if opts.embed_metadata { export_exif(img) } else { None }; let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None }; Ok(riff(w, h, &frame, alpha.as_deref(), icc, exif.as_deref(), xmp.as_deref().map(str::as_bytes))) } +/// The EXIF to write: the image's, upright (the pixels are written as they are shown, so a viewer +/// must never rotate them again), carrying the resolution. WebP has no resolution field of its own, +/// so the resolution goes into EXIF, added when the image has none (#2399). +pub(crate) fn export_exif(img: &Image) -> Option> { + let exif = img.meta.exif.as_deref().unwrap_or_default(); + // A WebP without EXIF already reads as 72 ppi, so the default needs no block of its own (and + // the extended container it would bring to every web export). + if exif.is_empty() && img.meta.dpi.is_none_or(is_default_ppi) { + return None; + } + let out = crate::resolution::export_exif_adding_missing_resolution(exif, img.meta.dpi).into_owned(); + (!out.is_empty()).then_some(out) +} + +/// Whether a WebP of `img` keeps its resolution: the EXIF written carries it. Not when the image's +/// own EXIF can't be parsed: that block is written as it is rather than replaced, so the +/// resolution is dropped (and reported) instead of the metadata. +pub(crate) fn keeps_dpi(img: &Image) -> bool { + let bare_default = img.meta.exif.as_deref().is_none_or(<[u8]>::is_empty) && img.meta.dpi.is_none_or(is_default_ppi); + // The XMP written beside it carries the resolution too, and readers look there first. + let in_xmp = img.meta.xmp.as_deref().is_some_and(|x| crate::resolution::xmp_resolution(&crate::resolution::export_xmp(x, img.meta.dpi)).is_some()); + bare_default || in_xmp || export_exif(img).is_some_and(|e| crate::resolution::exif_resolution(&e).is_some()) +} + +/// 72 ppi each way: what a file without a resolution opens as. +fn is_default_ppi((x, y): (f32, f32)) -> bool { + (x - 72.0).abs() < 0.01 && (y - 72.0).abs() < 0.01 +} + /// A RIFF chunk: FourCC, little-endian size, payload, padded to an even length. fn chunk(out: &mut Vec, fourcc: &[u8; 4], payload: &[u8]) { out.extend_from_slice(fourcc); diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index d80bb9ef7a..c9bce9671e 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -268,7 +268,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() }); } } - if m.dpi.is_some() && !c.dpi { + if m.dpi.is_some() && (!c.dpi || (format == Format::WebP && !crate::codecs::webp::keeps_dpi(image))) { w.push(W::DpiDropped); } // DPX keeps its own header fields, not free-form text. diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index a602da57b7..a333106ab2 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -256,7 +256,8 @@ pub fn caps(format: Format) -> FormatCaps { } Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base }, Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base }, - Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base }, + // Resolution travels in EXIF (WebP has no field of its own). + Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, dpi: true, animation: true, ..base }, Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base }, Format::Bmp => base, Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base }, diff --git a/crates/codecs/tests/it/fidelity.rs b/crates/codecs/tests/it/fidelity.rs index 2cdaf20c5e..fa77a8d47a 100644 --- a/crates/codecs/tests/it/fidelity.rs +++ b/crates/codecs/tests/it/fidelity.rs @@ -99,13 +99,14 @@ fn exif_xmp_dpi_text_dropped_for_qoi() { assert!(w.contains(&x), "{x:?}"); } } +/// WebP keeps the resolution in EXIF (#2399); only free-form text has nowhere to go. #[test] -fn webp_drops_dpi_and_text_only() { +fn webp_drops_text_only() { let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() }; let w = warns(&img, Format::WebP); - assert_eq!(w, vec![W::DpiDropped, W::TextDropped]); + assert_eq!(w, vec![W::TextDropped]); } #[test] fn float_to_png_reduces_and_clips() { diff --git a/crates/codecs/tests/it/metadata.rs b/crates/codecs/tests/it/metadata.rs index b33ae0a715..f84ec63361 100644 --- a/crates/codecs/tests/it/metadata.rs +++ b/crates/codecs/tests/it/metadata.rs @@ -38,6 +38,12 @@ fn check_format(format: Format) { // TIFF writes the block's directories as its own and rebuilds a block from them on // reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's. assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}"); + } else if c.exif && format == Format::WebP { + // WebP's resolution lives in EXIF, so the block comes back with resolution tags added. + let back_exif = back.meta.exif.as_deref().unwrap_or_else(|| panic!("{format:?} lost exif")); + assert_eq!(photocraft_codecs::resolution::exif_resolution(back_exif).map(|(x, y)| (x.round(), y.round())), Some((300.0, 150.0))); + // The rest of the block survives too: the Orientation tag (0x0112, little-endian). + assert!(back_exif.windows(2).any(|w| w == b"\x12\x01"), "{format:?} lost Orientation"); } else if c.exif { assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif"); } else { @@ -295,3 +301,40 @@ fn metadata_survives_depth_conversion() { assert_eq!(back.icc, img.icc); assert_eq!(back.meta.xmp, img.meta.xmp); } + +/// #2399: a WebP export keeps the resolution even when the image has no EXIF (WebP has no +/// resolution field of its own, so a minimal EXIF block carries it), lossless and lossy alike, +/// and no longer warns that it is dropped. +#[test] +fn webp_keeps_dpi_without_source_exif() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); + img.meta = Metadata { dpi: Some((300.0, 150.0)), ..Default::default() }; + assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + for lossless in [true, false] { + let opts = EncodeOptions { webp_lossless: lossless, ..Default::default() }; + let back = decode(&encode(&img, Format::WebP, &opts).unwrap()).unwrap(); + let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("lossless {lossless}: dpi lost")); + assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "lossless {lossless}: {x},{y}"); + } + // EXIF that can't be parsed is written as it is, so the resolution is reported as dropped. + img.meta = Metadata { exif: Some(b"Exif\0\0garbage".to_vec()), dpi: Some((300.0, 150.0)), ..Default::default() }; + assert!(fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap(); + assert!(back.meta.exif.is_some()); + // ...unless the XMP written beside it records the resolution, which readers look at first. + img.meta.xmp = Some(r#""#.into()); + assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap(); + let (x, y) = back.meta.dpi.expect("dpi from XMP"); + assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{x},{y}"); + // The default 72 ppi needs no EXIF (a WebP without one opens at 72), so a plain export stays + // a simple-format file; nor does an image without a resolution. + img.meta = Metadata { dpi: Some((72.0, 72.0)), ..Default::default() }; + assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap(); + assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..])); + // Without a resolution or EXIF there is still no EXIF chunk. + img.meta = Metadata::default(); + let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap(); + assert!(back.meta.exif.is_none()); +} From 5697c4036180497ca4658468104daba4dbd403ea Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:41:08 -0400 Subject: [PATCH 36/40] PSD: write pattern transparency where Photoshop keeps it (#8054) (#8756) * PSD: write pattern transparency where Photoshop keeps it (#8054) A pattern defined over transparency (delete the Background, Define Pattern, then a Pattern Fill layer from it) was saved with its alpha plane in the virtual memory array list's user mask slot (24), and Photoshop refused the PSD. Photoshop keeps pattern transparency in the last (sheet mask) slot and leaves the user mask unwritten (psd-tools multichannel-pattern-fill.psd, written by Photoshop CC 2019). The same writer serves `Patt`/`Pat2`/`Pat3`, `.pat` and `.abr`. Files saved by older builds still open (the reader takes the first written array after the colour channels as transparency), and saving them again rewrites their pattern block instead of keeping the raw bytes, which `block_has_user_mask` detects. Co-Authored-By: Claude Opus 5.5 * PSD: rewrite only legacy pattern blocks, keep unreadable ones (#8054) When the document's patterns are unchanged, a pattern block with transparency in the user mask array is now rewritten in place from its own patterns, and every other block (pattern blocks that fail to decode included) is kept verbatim. Previously a legacy block forced the replace-all path, which dropped an unreadable pattern block that an unchanged save used to keep. Blocks are decoded in order with one shared budget, as from_global_blocks does. Addresses CodeRabbit review on #8756. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/pattern_map.rs | 68 +++++++++++++++++++-- crates/psd/src/patterns.rs | 113 +++++++++++++++++++++++++++++++++-- 2 files changed, 172 insertions(+), 9 deletions(-) diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index bd84d9aeb1..eeda1943cc 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Pattern}; use photocraft_geom::Rect; -use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; +use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_has_user_mask, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; use photocraft_raster::Surface; use crate::pixels::{deinterleave, interleave, max_sample, psd_depth, sample_for_depth, zero_sample}; @@ -124,12 +124,13 @@ pub fn from_global_blocks(doc: &Document) -> Vec { .collect() } -/// Global blocks to write: the raw ones while they still match [`Document::patterns`], else the -/// raw pattern blocks replaced by one written from the document (at the first one's position). +/// Global blocks to write: the raw ones while they still match [`Document::patterns`] (see +/// [`repair_legacy_blocks`]), else the raw pattern blocks replaced by one written from the +/// document (at the first one's position). pub fn export_global_blocks(doc: &Document) -> Vec { let raw = &doc.metadata.psd_global_blocks; if from_global_blocks(doc) == doc.patterns { - return raw.clone(); + return repair_legacy_blocks(raw); } let key = block_key(psd_depth(doc.depth)); let fresh = write_pattern_block(&doc.patterns.iter().map(to_psd).collect::>()).unwrap_or_default(); @@ -151,6 +152,31 @@ pub fn export_global_blocks(doc: &Document) -> Vec Vec { + if !raw.iter().any(|(_, k, d)| PATTERN_KEYS.contains(k) && block_has_user_mask(d)) { + return raw.to_vec(); + } + let mut budget = PatternBudget::default(); + raw.iter() + .map(|b @ (sig, key, data)| { + if !PATTERN_KEYS.contains(key) { + return b.clone(); + } + match parse_pattern_block_with(data, &mut budget) { + Ok(ps) if block_has_user_mask(data) => match write_pattern_block(&ps) { + Ok(fresh) => (*sig, *key, Arc::new(fresh)), + Err(_) => b.clone(), + }, + _ => b.clone(), + } + }) + .collect() +} + /// Reads a `.pat` pattern file. pub fn read_pat(bytes: &[u8]) -> Result, String> { let ps = photocraft_psd::patterns::parse_pat_file(bytes).map_err(|e| format!("not a readable .pat file: {e}"))?; @@ -217,4 +243,38 @@ mod tests { assert_eq!(from_global_blocks(&d2), d.patterns); assert_eq!(read_pat(&write_pat(&d.patterns).unwrap()).unwrap(), d.patterns); } + + #[test] + fn transparent_pattern_blocks_from_older_builds_are_rewritten_on_save() { + // #8054: Define Pattern over transparency, then a Pattern Fill layer; Photoshop refused + // the PSD because the pattern's transparency sat in the user mask array. + let mut d = Document::new("x", photocraft_geom::Size { width: 4, height: 4 }, ColorMode::Rgb, SampleType::U16); + d.patterns.push(pattern(ColorMode::Rgb, SampleType::U16, true)); + let out = export_global_blocks(&d); + assert_eq!(out.len(), 1); + assert!(!block_has_user_mask(&out[0].2)); + // Move the transparency into the user mask array, as older builds wrote it: a 16-bit + // pattern's array is raw, so it is the last 8 + 23 + 5 × 3 × 2 bytes of the pattern. + let mut legacy = out[0].2.to_vec(); + let end = 4 + u32::from_be_bytes([legacy[0], legacy[1], legacy[2], legacy[3]]) as usize; + legacy[end - (8 + 23 + 30) - 4..end].rotate_left(4); + assert!(block_has_user_mask(&legacy)); + // A pattern block that doesn't decode (pattern version 2) sits beside it. + let unreadable = (*b"8BIM", *b"Patt", Arc::new(vec![0, 0, 0, 8, 0, 0, 0, 2, 0, 0, 0, 0])); + let other = (*b"8BIM", *b"Txt2", Arc::new(vec![1])); + d.metadata.psd_global_blocks = vec![other.clone(), (*b"8BIM", *b"Pat2", Arc::new(legacy)), unreadable.clone()]; + assert_eq!(from_global_blocks(&d), d.patterns, "older files still open"); + // The patterns are unchanged: only the legacy block is rewritten, in place; the other + // blocks are kept verbatim. + let out = export_global_blocks(&d); + assert_eq!(out.len(), 3); + assert_eq!((&out[0], &out[2]), (&other, &unreadable)); + assert_eq!(out[1].1, *b"Pat2"); + assert!(!block_has_user_mask(&out[1].2)); + let mut back = d.clone(); + back.metadata.psd_global_blocks = out; + assert_eq!(from_global_blocks(&back), d.patterns); + // Saving again keeps every block as it is. + assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks); + } } diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 15d88f2ea8..15558a700e 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -18,7 +18,9 @@ //! `.pat` file is `8BPT`, version u16 (1), count u32, then the patterns without length prefixes. //! Photoshop writes the channel count as 24 and only marks the real channels as written; like //! ag-psd (MIT) we take the first written arrays as the colour channels and the next one as -//! transparency. +//! transparency. Photoshop stores a pattern's transparency in the last (sheet mask) array and +//! leaves the user mask unwritten (psd-tools multichannel-pattern-fill.psd, Photoshop CC 2019); a +//! file with transparency in the user mask array is refused by Photoshop (#8054). use crate::compression::{Compression, MAX_DECODED_BYTES, PlaneLayout, decode_planes, encode_planes}; use crate::error::{PsdError, Result}; @@ -143,7 +145,15 @@ impl<'a> Rd<'a> { } } -fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { +/// A pattern's mode, name, id and palette, up to its virtual memory array list. +struct PatternHeader { + mode: u32, + name: String, + id: String, + palette: Option>, +} + +fn read_header(r: &mut Rd) -> Result { let version = r.u32()?; if version != 1 { return Err(PsdError::invalid(format!("pattern version {version}"))); @@ -169,12 +179,22 @@ fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { } else { None }; + Ok(PatternHeader { mode, name, id, palette }) +} + +/// The body of a pattern's virtual memory array list (after its version and length). +fn read_vma_body<'a>(r: &mut Rd<'a>) -> Result<&'a [u8]> { let vver = r.u32()?; if vver != 3 { return Err(PsdError::invalid(format!("virtual memory array list version {vver}"))); } let len = r.u32()? as usize; - let body = r.take(len)?; + r.take(len) +} + +fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { + let PatternHeader { mode, name, id, palette } = read_header(r)?; + let body = read_vma_body(r)?; let mut v = Rd { b: body, p: 0 }; let [top, left, bottom, right] = v.rect()?; let w = right.checked_sub(left).ok_or(PsdError::LimitExceeded("pattern width overflow"))?; @@ -413,12 +433,12 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { for _ in nc..SLOTS as usize { body.extend_from_slice(&0u32.to_be_bytes()); } - // User mask slot: transparency; sheet mask slot: unused. + // User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (#8054). + body.extend_from_slice(&0u32.to_be_bytes()); match &p.alpha { Some(a) => array(a, &mut body)?, None => body.extend_from_slice(&0u32.to_be_bytes()), } - body.extend_from_slice(&0u32.to_be_bytes()); out.extend_from_slice(&3u32.to_be_bytes()); out.extend_from_slice(&(body.len() as u32).to_be_bytes()); out.extend_from_slice(&body); @@ -448,6 +468,48 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu Ok(out) } +/// Whether `Patt` / `Pat2` / `Pat3` block data keeps a pattern's transparency in the user mask +/// array, as PhotoCraft wrote it before #8054. Photoshop refuses such a file, so a save must +/// rewrite the block rather than keep it. Unreadable data counts as `false`. +pub fn block_has_user_mask(data: &[u8]) -> bool { + let mut r = Rd { b: data, p: 0 }; + while r.p + 4 <= data.len() { + let Ok(len) = r.u32() else { return false }; + if len == 0 { + break; + } + let Ok(body) = r.take(len as usize) else { return false }; + if pattern_has_user_mask(&mut Rd { b: body, p: 0 }).unwrap_or(false) { + return true; + } + let pad = (4 - len as usize % 4) % 4; + r.p = (r.p + pad).min(data.len()); + } + false +} + +fn pattern_has_user_mask(r: &mut Rd) -> Result { + read_header(r)?; + let mut v = Rd { b: read_vma_body(r)?, p: 0 }; + v.rect()?; + let count = v.u32()?; + // Every array takes at least 4 bytes, so the body bounds this loop whatever `count` says. + for i in 0..count.saturating_add(2) { + if v.p >= v.b.len() { + break; + } + if v.u32()? == 0 { + continue; + } + let alen = v.u32()? as usize; + if i == count && alen != 0 { + return Ok(true); + } + v.take(alen)?; + } + Ok(false) +} + /// Serializes patterns as `Patt` / `Pat2` / `Pat3` block data. pub fn write_pattern_block(patterns: &[PsdPattern]) -> Result> { let mut out = Vec::new(); @@ -536,6 +598,47 @@ mod tests { } } + /// Offsets of the virtual memory arrays of a one-pattern block (each at its "written" flag), + /// with whether each is written, and the end of the array list. + fn vma_slots(block: &[u8], p: &PsdPattern) -> (Vec<(usize, bool)>, usize) { + let rect = pattern_vma_rect_offset(4, p); + let end = rect + u32::from_be_bytes(block[rect - 4..rect].try_into().unwrap()) as usize; + let (mut at, mut slots) = (rect + 20, Vec::new()); + while at < end { + let written = u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) != 0; + slots.push((at, written)); + at += 4; + if written { + at += 4 + u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) as usize; + } + } + (slots, end) + } + + #[test] + fn transparency_goes_in_the_sheet_mask_array_like_photoshop() { + // #8054: transparency in the user mask array (slot 24) made Photoshop refuse the file; + // Photoshop keeps it in the sheet mask array (slot 25). + for depth in [8, 16, 32] { + let p = pat(depth, true); + let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); + let (slots, end) = vma_slots(&block, &p); + let written: Vec = slots.iter().enumerate().filter(|(_, s)| s.1).map(|(i, _)| i).collect(); + assert_eq!((slots.len(), written), (26, vec![0, 1, 2, 25]), "{depth}-bit"); + assert!(!block_has_user_mask(&block)); + // The layout older PhotoCraft builds wrote still opens, and is detected. + block[slots[24].0..end].rotate_left(4); + let (slots, _) = vma_slots(&block, &p); + assert!(slots[24].1 && !slots[25].1); + assert!(block_has_user_mask(&block)); + assert_eq!(parse_pattern_block(&block).unwrap(), vec![p]); + } + assert!(!block_has_user_mask(&write_pattern_block(&[pat(8, false)]).unwrap())); + for junk in [&[][..], &[0, 0, 0, 9, 1], &[0xff; 64]] { + assert!(!block_has_user_mask(junk)); + } + } + #[test] fn malformed_input_errors_without_panicking() { let blk = write_pattern_block(&[pat(8, true)]).unwrap(); From 2cd1bd86644a808a13f309c8549dd8da94fd921b Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:41:14 +0100 Subject: [PATCH 37/40] fix(ui): cap text fields and oversized text events (#8722) * fix(ui): cap text fields and oversized text events Plain text fields took any amount of text, so pasting a huge string grew the string (and its laid-out galley) without bound; #2258 fixed this for numeric fields only. The shared text_field now holds at most 1024 characters unless its caller sets a limit, and the app cuts every text, paste and IME event to 1 MiB of characters (at a character boundary) before any widget sees it, so the dozens of hand-built TextEdits are bounded too. Part of #2150 Co-Authored-By: Claude Opus 5.5 * fix(ui): bound oversized existing text and synthetic text events A text_field whose value is already past its limit lays out a bounded copy and writes it back only on an edit; the text-event cap runs after the control channel's synthetic events join the input. Co-Authored-By: Claude Opus 5.5 * test(ui): raw_input_hook caps text the control channel queues Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/widgets.rs | 147 ++++++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e37aaf6ef4..cd9216893a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2357,6 +2357,8 @@ impl eframe::App for PhotocraftApp { wheel_nav::fold_legacy_pinch(ctx, raw_input); } raw_input.events.extend(self.take_synthetic_step()); + // After the control channel's own events (`ui.type`) join the OS's, before any widget. + widgets::cap_text_events(&mut raw_input.events); self.control_click.normalize(&mut raw_input.events, cfg!(target_os = "macos")); // A pen held still is no touch screen's press-and-hold right click (stylus.rs). let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 223d0cfa8e..ad5cba2105 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -814,6 +814,40 @@ pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::o out } +/// The most characters a [`text_field`] holds unless its caller sets a limit: far beyond any +/// search, name or hex value, yet a pasted multi-gigabyte string is cut at the field instead of +/// being laid out every frame (#2150). +pub const TEXT_FIELD_LIMIT: usize = 1024; + +/// The most characters one typed, pasted or IME-committed text event carries into the app +/// ([`cap_text_events`]): a megabyte of text, more than any field or text layer is pasted. +pub const INPUT_EVENT_LIMIT: usize = 1 << 20; + +/// Cuts every text, paste and IME event to [`INPUT_EVENT_LIMIT`] characters (at a character +/// boundary) before any widget sees it, so no text field -- whatever its own limit -- has to +/// lay out an 8 GB paste (#2150). +pub fn cap_text_events(events: &mut [egui::Event]) { + fn cut(text: &mut String) -> bool { + let Some((end, _)) = text.char_indices().nth(INPUT_EVENT_LIMIT) else { return false }; + text.truncate(end); + true + } + for event in events { + match event { + egui::Event::Text(text) | egui::Event::Paste(text) | egui::Event::Ime(egui::ImeEvent::Commit(text)) => { + cut(text); + } + // The highlighted range indexes the old text; past the cut it would point nowhere. + egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { + if cut(text) && active_range_chars.as_ref().is_some_and(|r| r.end > INPUT_EVENT_LIMIT) { + *active_range_chars = None; + } + } + _ => {} + } + } +} + /// Height of a [`text_field`]'s box, matching a [`value_field`]. pub const TEXT_FIELD_H: f32 = 24.0; @@ -832,7 +866,8 @@ pub fn text_field(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, widt text_field_ex(ui, id, text, hint, width, mono, None) } -/// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say). +/// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say), or to +/// [`TEXT_FIELD_LIMIT`] without one. pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, mono: bool, char_limit: Option) -> Response { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width.max(16.0), TEXT_FIELD_H), Sense::hover()); @@ -842,13 +877,25 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w } let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; let inner = rect.shrink2(TEXT_FIELD_PAD); + let limit = char_limit.unwrap_or(TEXT_FIELD_LIMIT); + // A value already past the limit (restored from a state file, set by automation) is shown and + // edited as its first `limit` characters, so it is never laid out whole; the caller's string + // changes only when that copy is edited. + let mut bounded = text.char_indices().nth(limit).map(|(end, _)| text[..end].to_string()); + let shown = match bounded.as_mut() { + Some(b) => b, + None => &mut *text, + }; // TextEdit draws its text at the top of its rect; fill the box and centre the line in it, so // the text sits on the field's middle like a dropdown's or value field's. - let mut edit = - egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font).min_size(inner.size()).vertical_align(egui::Align::Center); - if let Some(limit) = char_limit { - edit = edit.char_limit(limit); - } + let edit = egui::TextEdit::singleline(shown) + .id(id) + .hint_text(hint) + .frame(egui::Frame::NONE) + .font(font) + .min_size(inner.size()) + .vertical_align(egui::Align::Center) + .char_limit(limit); // A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected // list rows and slider trails, which must keep the theme's. let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown); @@ -856,7 +903,13 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w // The theme's 24 pt interact height would make the edit taller than the box's inside. child.spacing_mut().interact_size.y = inner.height(); child.style_mut().visuals.selection.bg_fill = t.field_selection(); - child.add(edit) + let response = child.add(edit); + if let Some(b) = bounded + && response.changed() + { + *text = b; + } + response } fn first_field_id() -> egui::Id { @@ -2225,6 +2278,86 @@ mod tests { assert_eq!(h.state().0, "abc", "typing reaches the bound string"); } + /// #2150: a huge paste into a plain text field stops at its limit instead of growing the + /// string (and its laid-out text) without bound. + #[test] + fn text_field_caps_a_huge_paste() { + use egui::accesskit::Role; + let mut h = Harness::new_ui_state(|ui, s: &mut String| drop(super::text_field(ui, egui::Id::new("tf"), s, "", 140.0, false)), String::new()); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + h.get_by_role(Role::TextInput).click(); + h.run_steps(1); + h.event(egui::Event::Paste("9".repeat(200_000))); + h.run_steps(1); + assert_eq!(h.state().chars().count(), super::TEXT_FIELD_LIMIT); + // A second paste adds nothing past the limit. + h.event(egui::Event::Paste("9".repeat(10))); + h.run_steps(1); + assert_eq!(h.state().chars().count(), super::TEXT_FIELD_LIMIT); + } + + /// #2150: a value already past the limit is laid out only up to it, and stays the caller's + /// until the field is edited, which keeps the bounded copy. + #[test] + fn text_field_bounds_an_oversized_existing_value() { + use egui::accesskit::Role; + let long = "9".repeat(super::TEXT_FIELD_LIMIT * 4); + let mut h = Harness::new_ui_state(|ui, s: &mut String| drop(super::text_field(ui, egui::Id::new("tf"), s, "", 140.0, false)), long.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + assert_eq!(*h.state(), long, "drawing alone leaves the caller's value alone"); + assert_eq!(h.get_by_role(Role::TextInput).value().map(|v| v.chars().count()), Some(super::TEXT_FIELD_LIMIT)); + h.get_by_role(Role::TextInput).click(); + h.run_steps(1); + h.event(egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().chars().count() < super::TEXT_FIELD_LIMIT, "an edit keeps the bounded copy"); + } + + /// #2150: text queued by the control channel (`ui.type`) joins the input in `raw_input_hook` + /// and is cut like the OS's, so the cap must run after it is appended. + #[test] + fn raw_input_hook_caps_synthetic_text() { + use eframe::App as _; + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.synthetic.push(egui::Event::Text("x".repeat(super::INPUT_EVENT_LIMIT + 10))); + let ctx = egui::Context::default(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + let texts: Vec = raw.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.chars().count()) } else { None }).collect(); + assert_eq!(texts, [super::INPUT_EVENT_LIMIT]); + } + + /// #2150: every text-carrying event is cut to the app-wide limit at a character boundary + /// before any widget sees it; other events and short text pass untouched. + #[test] + fn oversized_text_events_are_cut_before_widgets_see_them() { + let long = "é".repeat(super::INPUT_EVENT_LIMIT + 5); + let mut events = vec![ + egui::Event::Paste(long.clone()), + egui::Event::Text(long.clone()), + egui::Event::Ime(egui::ImeEvent::Commit(long.clone())), + egui::Event::Ime(egui::ImeEvent::Preedit { text: long, active_range_chars: Some(0..super::INPUT_EVENT_LIMIT + 2) }), + egui::Event::Text("ok".into()), + egui::Event::Copy, + ]; + super::cap_text_events(&mut events); + for e in &events[..4] { + let (egui::Event::Paste(t) + | egui::Event::Text(t) + | egui::Event::Ime(egui::ImeEvent::Commit(t)) + | egui::Event::Ime(egui::ImeEvent::Preedit { text: t, .. })) = e + else { + panic!("{e:?}") + }; + assert_eq!(t.chars().count(), super::INPUT_EVENT_LIMIT); + } + assert!(matches!(&events[3], egui::Event::Ime(egui::ImeEvent::Preedit { active_range_chars: None, .. })), "the range past the cut is dropped"); + assert_eq!(events[4], egui::Event::Text("ok".into())); + assert_eq!(events[5], egui::Event::Copy); + } + /// Sets up a test with one value field. Its state is its value and how many times it changed. fn field(value: f32, range: std::ops::RangeInclusive) -> Harness<'static, (f32, u32)> { let mut h = Harness::new_ui_state( From 35cb32be202f8aa961b877889c4db7c2d6ae1f63 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:41:19 -0400 Subject: [PATCH 38/40] fix(export): report skipped layers in doc_export_layers (#8491) (#8713) * fix(export): report skipped layers across automation surfaces * test(export): use the MCP visible_only parameter spelling * test(export): restore old adapter for hosted regression proof * Revert "test(export): restore old adapter for hosted regression proof" This reverts commit 397a5ffa2a583561068667af843f373d9905f9a7. * docs(export): qualify workspace-only response guarantees --- crates/automation/src/headless.rs | 10 ++++- crates/automation/src/rpc.rs | 53 +++++++++++++++++++++++ crates/automation/src/server.rs | 2 +- crates/automation/tests/it/mcp.rs | 37 ++++++++++++++++ crates/engine/src/file_cmds.rs | 57 +++++++++++++++++++------ crates/engine/src/file_cmds/tests.rs | 63 ++++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/development.md | 10 +++++ 8 files changed, 218 insertions(+), 16 deletions(-) diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index bed78c0f65..1430fb978d 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -171,10 +171,16 @@ impl Headless { _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), }; let mut outputs = workspace.export_sequence(dir)?; - let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| { + let report = file_cmds::export_layers_report_with(&this.session, params, |name, bytes| { outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) }); - exported(dir, written, &mut outputs) + let (written, skipped) = match report { + Ok(report) => (Ok(report.files), report.skipped), + Err(error) => (Err(error), Vec::new()), + }; + let mut result = exported(dir, written, &mut outputs)?; + result["skipped"] = json!(skipped); + Ok(result) }) } diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index a0923a6744..7d52291f42 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -724,6 +724,59 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn doc_export_layers_reports_skips_in_workspace_and_trusted_sessions() { + for trusted in [false, true] { + let base = std::env::temp_dir().join(format!("pc-export-skips-{}-{trusted}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let mut h = + if trusted { Headless::trusted_local() } else { Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&base), Some(&base)).unwrap()) }; + h.handle("doc.new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).unwrap(); + let adjustment = h.handle("engine.execute", json!({"command": "layer.newAdjustmentLayer.levels"})).unwrap()["layer"].clone(); + h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"name": "Levels"}})).unwrap(); + let hidden = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Hidden"}})).unwrap()["layer"].clone(); + h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"visible": false}})).unwrap(); + // Exercise the actual JSON-lines serve envelope, not only direct dispatch. + let h = Mutex::new(h); + let out_dir = base.join("out").to_string_lossy().into_owned(); + let request = json!({"id": 8491, "method": "doc.exportLayers", "params": {"dir": out_dir}}).to_string() + "\n"; + let mut output = Vec::new(); + serve_lines(&h, request.as_bytes(), &mut output).unwrap(); + let reply: Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(reply["id"], 8491); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["files"], json!([format!("{out_dir}/card_0001_Background.png")])); + if !trusted { + assert_eq!(reply["result"]["count"], 1); + } + assert_eq!( + reply["result"]["skipped"], + json!([ + {"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"}, + {"layer": adjustment, "name": "Levels", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 1); + let mut h = h.into_inner().unwrap(); + let all_dir = base.join("all").to_string_lossy().into_owned(); + let all = h.export_layers(None, &all_dir, &json!({"visibleOnly": false})).unwrap(); + assert_eq!(all["files"].as_array().unwrap().len(), 2); + assert_eq!(all["skipped"].as_array().unwrap().len(), 1); + // Workspace no-overwrite errors still fail and remove only newly created files. + if !trusted { + let collision = base.join("collision"); + std::fs::create_dir_all(&collision).unwrap(); + std::fs::write(collision.join("card_0002_Background.png"), b"keep").unwrap(); + assert!(h.export_layers(None, &collision.to_string_lossy(), &json!({"visibleOnly": false})).is_err()); + assert_eq!(std::fs::read_dir(&collision).unwrap().count(), 1); + assert_eq!(std::fs::read(collision.join("card_0002_Background.png")).unwrap(), b"keep"); + } + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + } + #[test] fn doc_export_layers_writes_only_beneath_the_write_root() { let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id())); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 56d85509a6..1174c5f84f 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -645,7 +645,7 @@ impl PhotocraftMcp { } #[tool( - description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite." + description = "Export top-level layers as files (groups merged). Returns files and skipped: [{layer, name, reason}] for hidden layers (visible_only) and adjustments with no pixels of their own. Workspace exports resolve beneath the write root, also return count of written files, and reject existing files instead of overwriting them. Trusted-local/bridge exports use the engine command's file access policy." )] async fn doc_export_layers(&self, Parameters(p): Parameters) -> Result { let mut opts = serde_json::Map::new(); diff --git a/crates/automation/tests/it/mcp.rs b/crates/automation/tests/it/mcp.rs index 88d62f6595..cd1e9b84d1 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -768,6 +768,43 @@ async fn doc_exports_and_data_set_import_are_capability_scoped() { cleanup(&base); } +/// Skipped-layer details survive the MCP tool result, including the all-skipped case. +#[tokio::test(flavor = "multi_thread")] +async fn doc_export_layers_reports_skipped_layers() { + let base = tmp("skipped-layers"); + let client = connect(headless_in(&base)).await; + json_of(&call(&client, "doc_new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).await); + let background = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"][0]["id"].clone(); + let adjustment = json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.levels"})).await)["layer"].clone(); + json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"name": "Levels/α"}})).await); + let hidden = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Hidden"}})).await)["layer"].clone(); + json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"visible": false}})).await); + let result = json_of(&call(&client, "doc_export_layers", json!({"dir": "layers"})).await); + assert_eq!(result["count"], 1); + assert_eq!(result["files"], json!(["layers/card_0001_Background.png"])); + assert_eq!( + result["skipped"], + json!([ + {"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"}, + {"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + let all = json_of(&call(&client, "doc_export_layers", json!({"dir": "all", "visible_only": false})).await); + assert_eq!(all["count"], 2); + assert_eq!(all["skipped"].as_array().unwrap().len(), 1); + assert_eq!(std::fs::read_dir(base.join("layers")).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(base.join("all")).unwrap().count(), 2); + // Every top-level layer is still accounted for when none can be exported. + json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"layer": background, "visible": false}})).await); + let none = json_of(&call(&client, "doc_export_layers", json!({"dir": "none"})).await); + assert_eq!(none["files"], json!([])); + assert_eq!(none["count"], 0); + assert_eq!(none["skipped"].as_array().unwrap().len(), 3); + assert_eq!(std::fs::read_dir(base.join("none")).unwrap().count(), 0); + client.cancel().await.unwrap(); + cleanup(&base); +} + /// The template workflow stays in memory, including saving nested contents back to the parent. #[tokio::test(flavor = "multi_thread")] async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() { diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index b5878fa3db..58dc42c951 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1668,16 +1668,41 @@ fn flatten_all_masks(s: &mut Session) -> Result { fn layers_to_files(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.layersToFiles"; let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string(); - let names = export_layers_with(s, p, |name, bytes| write_file(&join(&dir, name), bytes))?; - let files: Vec = names.iter().map(|name| join(&dir, name)).collect(); - Ok(json!({"files": files})) + let report = export_layers_report_with(s, p, |name, bytes| write_file(&join(&dir, name), bytes))?; + let files: Vec = report.files.iter().map(|name| join(&dir, name)).collect(); + Ok(json!({"files": files, "skipped": report.skipped})) } /// The bodies of `file.export.layersToFiles`, streamed to `write` as a single-component filename /// plus its bytes: one file per exported layer (top to bottom, like Photoshop's numbering), /// encoded but never touched as a path. Automation writes exactly these bytes beneath its write /// root (#3216), so the two paths cannot drift. Returns the filenames written, in order. -pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result> { +pub fn export_layers_with(s: &Session, p: &Value, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result> { + export_layers_report_with(s, p, write).map(|report| report.files) +} + +/// A top-level layer omitted from a successful Layers to Files export. +#[derive(Debug, serde::Serialize)] +pub struct SkippedExportLayer { + /// Session layer id (names need not be unique). + pub layer: u64, + /// Original name, not the sanitized filename component. + pub name: String, + /// Why the layer produced no file. + pub reason: &'static str, +} + +/// Filenames written and layers skipped, each in top-to-bottom order. +#[derive(Debug, Default)] +pub struct LayerExportReport { + pub files: Vec, + pub skipped: Vec, +} + +/// The same export as [`export_layers_with`], with an explanation for every omitted top-level +/// layer. Groups export merged, so their children are not separate exports or skips. Encoding +/// and write failures remain errors, never successful skips. Existing numbering is preserved. +pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); @@ -1688,24 +1713,32 @@ pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[ let d = s.active().ok_or(EngineError::NoDocument)?; let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name)); let doc = d.doc.clone(); - let mut written = Vec::new(); - // Top-level layers (groups export merged), top to bottom like Photoshop's numbering. - let layers: Vec<&Layer> = doc.layers.iter().rev().filter(|l| l.visible || !visible_only).collect(); - for (i, l) in layers.iter().enumerate() { + let mut report = LayerExportReport::default(); + // Hidden layers never consumed a filename index; adjustments that pass the visibility + // filter did. Keep those filenames stable while reporting both kinds of omission. + let mut i = 0usize; + for l in doc.layers.iter().rev() { + if !l.visible && visible_only { + report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "hidden by visibleOnly" }); + continue; + } + let index = i; + i += 1; if matches!(l.content, LayerContent::Adjustment(_)) { + report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "adjustment layer has no pixels of its own" }); continue; } let mut one = (*doc).clone(); - let mut only = (*l).clone(); + let mut only = l.clone(); only.visible = true; only.clipped = false; one.layers = vec![only]; - let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name)); + let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)); let (bytes, _warnings) = encode(&one, &name, save)?; write(&name, &bytes)?; - written.push(name); + report.files.push(name); } - Ok(written) + Ok(report) } /// Adjustment layers that can be represented as an RGB-only LUT, in document stack order. diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index f034a62656..6dc73d1c54 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1080,6 +1080,69 @@ fn flatten_all_layer_effects_keeps_a_pattern_overlay() { } } +#[test] +fn layers_to_files_reports_skipped_layers_without_changing_files() { + for depth in [8, 16, 32] { + let dir = tmp(&format!("skipped-layers-{depth}")); + let mut s = session(4, 4, depth); + let adjustment = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].clone(); + s.execute("layer.setProps", json!({"name": "Levels/α"})).unwrap(); + let hidden = s.execute("layer.new.layer", json!({"name": "Levels/α"})).unwrap()["layer"].clone(); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let result = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "doc"})).unwrap(); + assert_eq!( + result["skipped"], + json!([ + {"layer": hidden, "name": "Levels/α", "reason": "hidden by visibleOnly"}, + {"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + assert_eq!(result["files"], json!([join(&dir, "doc_0001_Background.png")])); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + assert_eq!(s.active().unwrap().doc.layers.len(), before.layers.len()); + assert!(!s.active().unwrap().doc.layer(LayerId(hidden.as_u64().unwrap())).unwrap().visible); + let all = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "all", "visibleOnly": false})).unwrap(); + assert_eq!( + all["skipped"], + json!([ + {"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + assert_eq!(all["files"], json!([join(&dir, "all_0000_Levels_α.png"), join(&dir, "all_0002_Background.png")])); + } +} + +#[test] +fn layers_to_files_reports_groups_as_exports_and_failures_as_errors() { + let mut s = session(4, 4, 8); + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + s.execute("layer.new.groupFromLayers", json!({"name": "Pack"})).unwrap(); + let params = json!({"prefix": "doc"}); + let mut names = Vec::new(); + let report = export_layers_report_with(&s, ¶ms, |name, bytes| { + assert!(!bytes.is_empty()); + names.push(name.to_string()); + Ok(()) + }) + .unwrap(); + assert_eq!(names, vec!["doc_0000_Pack.png", "doc_0001_Background.png"]); + assert_eq!(report.files, names); + assert!(report.skipped.is_empty(), "a group's adjustment is not an omitted top-level layer"); + assert_eq!(export_layers_with(&s, ¶ms, |_, _| Ok(())).unwrap(), names, "legacy Rust helper remains compatible"); + assert!(export_layers_report_with(&s, ¶ms, |_, _| Err(EngineError::Other("disk full".into()))).unwrap_err().to_string().contains("disk full")); + assert!(export_layers_report_with(&s, &json!({"format": "not-a-format"}), |_, _| panic!("must not write")).is_err()); + assert!(export_layers_report_with(&Session::new(), ¶ms, |_, _| panic!("must not write")).is_err()); + s.edit("Empty", |doc, _| { + doc.layers.clear(); + Ok(()) + }) + .unwrap(); + let empty = export_layers_report_with(&s, ¶ms, |_, _| panic!("must not write")).unwrap(); + assert!(empty.files.is_empty()); + assert!(empty.skipped.is_empty()); +} + #[test] fn layers_to_files_writes_each_visible_layer() { let dir = tmp("layers"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e60af2136..07d3a09183 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -378,7 +378,7 @@ no MCP framing, no app start-up per command. Configure its file access with the | `session.list` | open documents and the active index | | `doc.open` / `doc.new` | `{path}` / `file.new` params | | `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path | -| `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, count}`: one file per exported layer, named like `file.export.layersToFiles` names them. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file | +| `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | | `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root | | `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | diff --git a/docs/development.md b/docs/development.md index b4cacd0a61..40b044fdd5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -261,6 +261,16 @@ Tools: - `jobs_list` (running background jobs with progress, then recently finished ones with their result or error), `jobs_cancel` (one job by id, or every running job) - bridge only: `ui_inspect`, `ui_screenshot`, `ui_pointer`, `ui_menu_invoke`, `ui_set`, `control_call` +`doc_export_layers` in workspace headless mode returns `{files, count, skipped}`. `count` is +only the number of files written. Each skipped top-level layer is reported as +`{layer, name, reason}` (session id, original name, explanation); `skipped` is `[]` when every +layer exports. Hidden layers are reported when `visible_only` is true (the default; `visibleOnly` in the engine/serve params), and +standalone adjustments are reported because they have no pixels of their own. Groups still +export merged, so their children are not separate skips. Filename numbering is unchanged; +encoding or write failures remain errors. The engine command and trusted-local exports add +the same `skipped` array to their existing `{files}` response. See the +[`doc.exportLayers` protocol entry](control-protocol.md#headless-server) for details. + Claude Code (`.mcp.json` in the repo root, or `claude mcp add`): ```json From 5de94a7e05f3c6f7d2e1d54748b5320c9606a8db Mon Sep 17 00:00:00 2001 From: Mathieu Rancourt <40959576+MatRanc@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:41:25 -0400 Subject: [PATCH 39/40] Slice tool: Style offers Fixed Ratio and Fixed Size (#8360) (#8638) * Slice tool: Style offers Fixed Ratio and Fixed Size (#8360) The Slice tool's options bar showed "Style: Normal" as plain text, so a slice could only be drawn freely. It now has the marquee's Style dropdown (Normal, Fixed Ratio, Fixed Size) with Width and Height, and a new slice follows it while it is drawn and when it is made, through the marquee's chrome_ui::marquee_end. The options are kept with the other tool options, checked when loaded, and reset by Reset Tool. * Slice tool: a Fixed Size slice has exactly its size, also a pixel or two A press inside a pixel (10.25) made a 64 x 64 Fixed Size slice 65 x 65 pixels, since its far edge was rounded out. Fixed Size now starts on the pixel the press is in. A Fixed Size of 1 or 2 pixels was taken for a click that selects; Fixed Size makes its slice at any size. * Slice tool: Fixed Ratio keeps its ratio and Fixed Size whole pixels A fractional drag rounded each edge of a Fixed Ratio slice apart, so 2 : 1 could become 5 x 3. A fixed style now starts on the pixel the press is in and takes both sides from one scale: a whole-number ratio comes in whole multiples of it in its lowest terms, any other as close as whole pixels allow. Fixed Size rounds its Width and Height to whole pixels, in the field and when the slice is made, so 1.5 px no longer grows to 2. * Slice tool: Fixed Ratio follows the drag, Fixed Size fields in whole px Whole multiples of the ratio grew a 1366 : 768 slice in steps of 683 px, made a 4 x 1 slice from a plain click under 4 : 1, and jumped a 39.8 px 16 : 9 drag to 48 x 27. The longer side now follows the drag to within a pixel and the shorter side takes the ratio from it. Fixed Size Width and Height show whole pixels, so the field matches the slice it makes. --- crates/ui-egui/src/panels.rs | 8 +- crates/ui-egui/src/slice_ui.rs | 207 +++++++++++++++++++++++++++++- crates/ui-egui/src/state.rs | 11 ++ crates/ui-egui/src/tool_memory.rs | 20 ++- crates/ui-egui/src/tool_reset.rs | 2 +- 5 files changed, 237 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0e10c4c1e9..04b6f1ac59 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1206,7 +1206,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if fixed_size { picked = fixed_size_field(ui, &mut o.marquee_width, units, size[0]); } else { - widgets::value_field(ui, &mut o.marquee_width, 0.001..=999.0, "", 62.0); + widgets::value_field(ui, &mut o.marquee_width, crate::tool_memory::style_range(&o.marquee_style), "", 62.0); } if icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() { std::mem::swap(&mut o.marquee_width, &mut o.marquee_height); @@ -1215,7 +1215,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if fixed_size { picked = fixed_size_field(ui, &mut o.marquee_height, units, size[1]).or(picked); } else { - widgets::value_field(ui, &mut o.marquee_height, 0.001..=999.0, "", 62.0); + widgets::value_field(ui, &mut o.marquee_height, crate::tool_memory::style_range(&o.marquee_style), "", 62.0); } if let Some(name) = picked { o.marquee_unit = name.into(); @@ -1661,7 +1661,7 @@ fn body_text_width(ui: &egui::Ui, s: &str) -> f32 { /// Options-bar label: Photoshop writes "Size:" with a colon. /// An options-bar label; one right before a value field scrubs it when dragged (Photoshop). -fn opt_label(ui: &mut egui::Ui, s: &str) { +pub(crate) fn opt_label(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() }; let label = ui.add(egui::Label::new(RichText::new(tl!(&text)).color(t.text_dim)).selectable(false)); @@ -1669,7 +1669,7 @@ fn opt_label(ui: &mut egui::Ui, s: &str) { } /// The largest Fixed Size side, in pixels. -const FIXED_SIZE_MAX_PX: f64 = 300_000.0; +pub(crate) const FIXED_SIZE_MAX_PX: f64 = 300_000.0; /// The resolution Fixed Size's units convert at (the active document's, 72 ppi without one) and /// the document's size in pixels, which a percentage is of. The unit is the caller's. diff --git a/crates/ui-egui/src/slice_ui.rs b/crates/ui-egui/src/slice_ui.rs index 78e19f4be3..b81376b77c 100644 --- a/crates/ui-egui/src/slice_ui.rs +++ b/crates/ui-egui/src/slice_ui.rs @@ -110,6 +110,44 @@ fn rect_from(a: [f64; 2], b: [f64; 2]) -> photocraft_geom::Rect { photocraft_geom::Rect::new(x0, y0, x1, y1) } +/// The new slice drawn from `start` to `cur` under the Slice tool's Style (Normal, Fixed Ratio, +/// Fixed Size), as the marquee's (#8360). A fixed style is placed on the pixel the press is in. +/// Fixed Size has exactly its pixels. Fixed Ratio's longer side follows the drag to within a pixel +/// and the shorter side takes the ratio from it, as close as whole pixels allow: rounding each edge of +/// a fractional drag apart would make 2 : 1 into 5 × 3, and keeping to whole multiples of the +/// ratio would grow a 1366 : 768 slice in steps of 683 px. +fn new_rect(app: &PhotocraftApp, start: [f64; 2], cur: [f64; 2]) -> photocraft_geom::Rect { + let o = &app.ui.tool_options; + let (w, h) = (f64::from(o.slice_width), f64::from(o.slice_height)); + let size = match o.slice_style.as_str() { + "fixedSize" => [w.round().max(1.0), h.round().max(1.0)], + "fixedRatio" if w > 0.0 && h > 0.0 => { + let end = crate::chrome_ui::marquee_end("fixedRatio", w, h, false, start, cur); + let k = (end[0] - start[0]).abs() / w; + // A drag shorter than half a pixel is a click: no size. + let follow = |long: f64, short: f64| { + let l = (k * long).round(); + [l, if l > 0.0 { (l * short / long).round().max(1.0) } else { 0.0 }] + }; + if w >= h { + follow(w, h) + } else { + let [l, s] = follow(h, w); + [s, l] + } + } + _ => return rect_from(start, crate::chrome_ui::marquee_end(&o.slice_style, w, h, false, start, cur)), + }; + // The drag's extent and direction are from the press itself; only the slice is placed on the + // pixel the press is in. + let sign = |d: f64| if d < 0.0 { -1.0 } else { 1.0 }; + // A slice drawn left or up grows from the far side of that pixel, so it holds the pixel: the + // edges a Normal slice gets from the same press (`rect_from`), also on a pixel's edge. + let dir = [sign(cur[0] - start[0]), sign(cur[1] - start[1])]; + let corner = [0, 1].map(|i| if dir[i] < 0.0 { start[i].ceil() } else { start[i].floor() }); + rect_from(corner, [corner[0] + dir[0] * size[0], corner[1] + dir[1] * size[1]]) +} + /// Pointer input for the two slice tools. Returns true when the event was theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) -> bool { let tool = app.ui.tool; @@ -156,8 +194,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) - }, ToolEvent::Up { x, y } => match app.ui.slices.drag.take() { Some(SliceDrag::New { start, .. }) => { - let r = rect_from(start, [x, y]); - if r.width() >= 1 && r.height() >= 1 && (r.width() > 2 || r.height() > 2) { + let r = new_rect(app, start, [x, y]); + // A drag of a pixel or two is a click, which selects; a Fixed Size slice is made + // by a click, at any size. + let fixed_size = app.ui.tool_options.slice_style == "fixedSize"; + if r.width() >= 1 && r.height() >= 1 && (fixed_size || r.width() > 2 || r.height() > 2) { if let Ok(v) = app.run("slice.new", json!({"rect": [r.x0, r.y0, r.width(), r.height()]})) { app.ui.slices.selected = v.get("number").and_then(Value::as_u64).map(|n| n as usize); } @@ -263,7 +304,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } match &app.ui.slices.drag { Some(SliceDrag::New { start, cur }) => { - let sr = to_screen(rect_from(*start, *cur)); + let sr = to_screen(new_rect(app, *start, *cur)); painter.rect_stroke(sr, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()]; painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, color), 4.0, 4.0)); @@ -368,7 +409,33 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo let locked = app.session.file_menu.slices_locked; let t = crate::theme::Tokens::get(ui.ctx()); if tool == Tool::Slice { - ui.label(egui::RichText::new(tl!("Style: Normal")).color(t.text_dim)); + // Style, Width and Height, as the marquee's (#8360). + let o = &mut app.ui.tool_options; + crate::panels::opt_label(ui, tl!("Style")); + let styles = [("normal".to_string(), tl!("Normal")), ("fixedRatio".to_string(), tl!("Fixed Ratio")), ("fixedSize".to_string(), tl!("Fixed Size"))]; + if crate::widgets::dropdown(ui, "slice-style", &mut o.slice_style, &styles, 96.0) { + (o.slice_width, o.slice_height) = if o.slice_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) }; + } + let fixed_size = o.slice_style == "fixedSize"; + let range = crate::tool_memory::style_range(&o.slice_style); + // Fixed Size shows whole pixels, the size the slice gets (`new_rect` rounds the same way), + // however the value was typed, stepped or scrubbed. + let field = |ui: &mut egui::Ui, v: &mut f32| { + if fixed_size { + crate::widgets::value_field_prec(ui, v, range.clone(), "px", 62.0, 0) + } else { + crate::widgets::value_field(ui, v, range.clone(), "", 62.0) + } + }; + ui.add_enabled_ui(o.slice_style != "normal", |ui| { + crate::panels::opt_label(ui, tl!("Width")); + field(ui, &mut o.slice_width); + if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() { + std::mem::swap(&mut o.slice_width, &mut o.slice_height); + } + crate::panels::opt_label(ui, tl!("Height")); + field(ui, &mut o.slice_height); + }); crate::widgets::vline(ui, 22.0); if ui.add_enabled_ui(has_doc && !locked, |ui| crate::widgets::secondary_button(ui, tl!("Slices From Guides"), 0.0)).inner.clicked() { let _ = app.run("slice.fromGuides", json!({})); @@ -476,6 +543,138 @@ mod tests { assert!(app.ui.slices.selected.is_some()); } + /// #8360: the Slice tool's Style, as the marquee's. Fixed Ratio keeps the drag's larger extent + /// and gives the other the ratio; Fixed Size draws a slice of that size where the drag starts, + /// towards the drag, also on a plain click. + #[test] + fn slice_style_fixes_the_ratio_or_the_size() { + let mut app = app(); + app.ui.tool = Tool::Slice; + let m = egui::Modifiers::NONE; + let draw = |app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2]| { + pointer(app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, m); + pointer(app, ToolEvent::Move { x: b[0], y: b[1], pressure: 1.0 }, m); + pointer(app, ToolEvent::Up { x: b[0], y: b[1] }, m); + app.session.active().unwrap().doc.slices.list.last().unwrap().rect + }; + let o = &mut app.ui.tool_options; + (o.slice_style, o.slice_width, o.slice_height) = ("fixedRatio".into(), 2.0, 1.0); + assert_eq!(draw(&mut app, [10.0, 10.0], [30.0, 40.0]), photocraft_geom::Rect::new(10, 10, 70, 40)); + let o = &mut app.ui.tool_options; + (o.slice_style, o.slice_width, o.slice_height) = ("fixedSize".into(), 24.0, 16.0); + assert_eq!(draw(&mut app, [5.0, 5.0], [5.0, 5.0]), photocraft_geom::Rect::new(5, 5, 29, 21), "a click"); + assert_eq!(draw(&mut app, [90.0, 70.0], [80.0, 60.0]), photocraft_geom::Rect::new(66, 54, 90, 70), "up and to the left"); + // The preview while dragging shows the same rectangle the slice gets. + pointer(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Move { x: 41.0, y: 41.0, pressure: 1.0 }, m); + let Some(SliceDrag::New { start, cur }) = app.ui.slices.drag.clone() else { panic!("drawing a slice") }; + assert_eq!(new_rect(&app, start, cur), photocraft_geom::Rect::new(40, 40, 64, 56)); + pointer(&mut app, ToolEvent::Up { x: 41.0, y: 41.0 }, m); + // A press inside a pixel starts the slice on that pixel, still 24 x 16 pixels. + assert_eq!(draw(&mut app, [10.25, 30.75], [12.5, 31.5]), photocraft_geom::Rect::new(10, 30, 34, 46), "a press inside a pixel"); + assert_eq!( + draw(&mut app, [70.5, 70.5], [60.25, 60.25]), + photocraft_geom::Rect::new(47, 55, 71, 71), + "and towards the upper left, holding the pressed pixel" + ); + // A short drag up and to the left inside the pressed pixel goes up and to the left. + assert_eq!(draw(&mut app, [50.75, 50.75], [50.25, 50.25]), photocraft_geom::Rect::new(27, 35, 51, 51), "up-left within the pixel"); + // A Fixed Size that isn't whole pixels (from a settings file) is rounded, not grown a pixel. + let o = &mut app.ui.tool_options; + (o.slice_width, o.slice_height) = (1.5, 2.4); + assert_eq!(draw(&mut app, [5.5, 5.5], [5.5, 5.5]), photocraft_geom::Rect::new(5, 5, 7, 7), "1.5 x 2.4 is 2 x 2"); + // A size of a pixel or two is made too, not taken for a click that selects. + let o = &mut app.ui.tool_options; + (o.slice_width, o.slice_height) = (1.0, 2.0); + assert_eq!(draw(&mut app, [3.0, 3.0], [3.0, 3.0]), photocraft_geom::Rect::new(3, 3, 4, 5), "a 1 x 2 slice"); + } + + /// #8360: Fixed Ratio's longer side follows a fractional drag to the pixel and the shorter takes + /// the ratio from it, as close as whole pixels allow. + #[test] + fn fixed_ratio_slices_keep_their_ratio_from_a_fractional_drag() { + let mut app = app(); + app.ui.tool = Tool::Slice; + let m = egui::Modifiers::NONE; + let mut draw = |w: f32, h: f32, a: [f64; 2], b: [f64; 2]| { + let o = &mut app.ui.tool_options; + (o.slice_style, o.slice_width, o.slice_height) = ("fixedRatio".into(), w, h); + pointer(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: b[0], y: b[1] }, m); + let r = app.session.active().unwrap().doc.slices.list.last().unwrap().rect; + (r.x0, r.y0, r.width(), r.height()) + }; + assert_eq!(draw(2.0, 1.0, [10.5, 10.5], [14.5, 11.0]), (10, 10, 4, 2), "not 5 x 2"); + assert_eq!(draw(2.0, 1.0, [10.5, 10.5], [31.3, 13.0]), (10, 10, 21, 11), "a 20.8 px drag: 21 px, and half of it as near as pixels allow"); + assert_eq!(draw(16.0, 9.0, [0.5, 0.5], [40.3, 10.0]), (0, 0, 40, 23), "a 39.8 px drag"); + assert_eq!(draw(1366.0, 768.0, [0.0, 0.0], [40.0, 1.0]), (0, 0, 40, 22), "not in steps of 683 px"); + assert_eq!(draw(9.0, 16.0, [0.0, 0.0], [1.0, 32.2]), (0, 0, 18, 32), "the longer side is the height"); + assert_eq!(draw(4.0, 2.0, [60.7, 60.7], [50.2, 58.0]), (50, 55, 11, 6), "towards the upper left, holding the pressed pixel"); + assert_eq!(draw(1.5, 1.0, [0.0, 40.0], [30.0, 41.0]), (0, 40, 30, 20), "a fractional ratio"); + // A click inside a pixel doesn't move the pointer: it selects, as with Normal, and makes no + // slice (the drag is measured from the press, not from the pixel's corner). + let n = app.session.active().unwrap().doc.slices.list.len(); + let o = &mut app.ui.tool_options; + (o.slice_width, o.slice_height) = (4.0, 1.0); + pointer(&mut app, ToolEvent::Down { x: 10.75, y: 10.75, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 10.75, y: 10.75 }, m); + assert_eq!(app.session.active().unwrap().doc.slices.list.len(), n, "a click makes no slice"); + } + + /// #8360: remembered Slice tool options are checked when loaded: an unknown Style is Normal + /// again, and Fixed Size is whole pixels. + #[test] + fn remembered_slice_options_are_checked() { + let mut o = crate::state::ToolOptions::default(); + (o.slice_style, o.slice_width, o.slice_height) = ("fixedsize".into(), 0.5, 2.0); + crate::tool_memory::sanitize(&mut o); + assert_eq!(o.slice_style, "normal"); + (o.slice_style, o.slice_width, o.slice_height) = ("fixedSize".into(), 1.5, 63.6); + crate::tool_memory::sanitize(&mut o); + assert_eq!((o.slice_width, o.slice_height), (2.0, 64.0)); + } + + /// #8360: the options bar's Style picks Fixed Size, which starts at 64 × 64 px, and the slice + /// drawn next has that size. + #[test] + fn options_bar_picks_the_slice_style() { + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut app = app(); + app.sync_views(); + app.ui.tool = Tool::Slice; + let mut h = Harness::builder().with_size(vec2(1400.0, 200.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(4); + let fields = |h: &Harness<'static, PhotocraftApp>| { + h.query_all_by_role(Role::SpinButton) + .filter(|n| !n.accesskit_node().is_disabled()) + .filter_map(|n| n.accesskit_node().numeric_value()) + .collect::>() + }; + assert!(fields(&h).is_empty(), "Normal has no Width or Height to set"); + h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some("Normal")).unwrap().click(); + h.run_steps(3); + h.get_by_label("Fixed Size").click(); + h.run_steps(4); + let o = &h.state().ui.tool_options; + assert_eq!((o.slice_style.as_str(), o.slice_width, o.slice_height), ("fixedSize", 64.0, 64.0)); + assert_eq!(fields(&h), [64.0, 64.0]); + let (app, m) = (h.state_mut(), egui::Modifiers::NONE); + pointer(app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, m); + pointer(app, ToolEvent::Up { x: 10.0, y: 10.0 }, m); + assert_eq!(app.session.active().unwrap().doc.slices.list[0].rect, photocraft_geom::Rect::new(10, 10, 74, 74)); + } + #[test] fn dragging_a_piece_moves_the_whole_covered_slice() { // #7770: the slice under a later one is drawn as pieces; dragging one moves all of it. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 372bad8474..c1f6143356 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -732,6 +732,14 @@ pub struct ToolOptions { /// picked by right-clicking them); the size itself stays in pixels (#7910). #[serde(default = "default_marquee_unit")] pub marquee_unit: String, + /// Slice tool options bar: the same Styles as the marquee's, with the ratio or the size in px + /// (#8360). + #[serde(default = "default_marquee_style")] + pub slice_style: String, + #[serde(default = "one")] + pub slice_width: f32, + #[serde(default = "one")] + pub slice_height: f32, /// Move tool: Auto-Select (with "layer" or "group" target) and Show Transform Controls. /// Auto-Select is on with the "group" target by default, as in current Photoshop; /// ⌘/Ctrl-click inverts it. @@ -1003,6 +1011,9 @@ impl Default for ToolOptions { marquee_width: 1.0, marquee_height: 1.0, marquee_unit: default_marquee_unit(), + slice_style: default_marquee_style(), + slice_width: 1.0, + slice_height: 1.0, move_auto_select: true, move_target: default_move_target(), move_show_transform: false, diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 0d9e4f7fd4..7ece83cb26 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -198,6 +198,12 @@ pub fn restore(app: &mut PhotocraftApp) { restore_brushes(app); } +/// The values Width and Height take under a marquee or Slice tool Style: a size in px for Fixed +/// Size, else a ratio. +pub(crate) fn style_range(style: &str) -> std::ops::RangeInclusive { + if style == "fixedSize" { 1.0..=crate::panels::FIXED_SIZE_MAX_PX as f32 } else { 0.001..=999.0 } +} + /// Bring remembered options into the ranges their options-bar fields allow, so a hand-edited or /// damaged file can't start a tool with a value its own controls could never set (a 1e30 px /// feather, a 0-sided polygon). A NaN (JSON can't hold one, but it costs nothing to rule out) @@ -221,10 +227,20 @@ pub(crate) fn sanitize(o: &mut ToolOptions) { fit(&mut o.corner_radius, 0.0, 10000.0, d.corner_radius); fit(&mut o.line_weight, 1.0, 1000.0, d.line_weight); fit(&mut o.curve_fit, 0.5, 10.0, d.curve_fit); - // The marquee's Width and Height are a ratio or a size in px, as its Style says. - let (lo, hi) = if o.marquee_style == "fixedSize" { (1.0, 300_000.0) } else { (0.001, 999.0) }; + // The marquee's and the Slice tool's Width and Height are a ratio or a size in px, as their + // Style says. + let (lo, hi) = style_range(&o.marquee_style).into_inner(); fit(&mut o.marquee_width, lo, hi, d.marquee_width); fit(&mut o.marquee_height, lo, hi, d.marquee_height); + if !matches!(o.slice_style.as_str(), "normal" | "fixedRatio" | "fixedSize") { + o.slice_style = d.slice_style.clone(); + } + let (lo, hi) = style_range(&o.slice_style).into_inner(); + fit(&mut o.slice_width, lo, hi, d.slice_width); + fit(&mut o.slice_height, lo, hi, d.slice_height); + if o.slice_style == "fixedSize" { + (o.slice_width, o.slice_height) = (o.slice_width.round(), o.slice_height.round()); + } fit(&mut o.crop_custom_ratio_width, 0.0, f32::MAX, d.crop_custom_ratio_width); fit(&mut o.crop_custom_ratio_height, 0.0, f32::MAX, d.crop_custom_ratio_height); fit(&mut o.magic_eraser_opacity, 1.0, 100.0, d.magic_eraser_opacity); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index d0399ab4bc..a93e38f2dd 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -206,6 +206,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Smudge => reset!(strength, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), + Tool::Slice => reset!(slice_style, slice_width, slice_height), Tool::Pen => reset!(vector_mode, pen_auto_add_delete), Tool::CurvaturePen => reset!(vector_mode), Tool::FreeformPen => reset!(vector_mode, curve_fit), @@ -222,7 +223,6 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { | Tool::HistoryBrush | Tool::PathSelection | Tool::DirectSelection - | Tool::Slice | Tool::SliceSelect | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint From 5004d96acb2834cf0af5e257f50888bedda9d97b Mon Sep 17 00:00:00 2001 From: Mathieu Rancourt <40959576+MatRanc@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:41:31 -0400 Subject: [PATCH 40/40] Lost window device: quitting goes through while recovery can't be saved (#8620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the window's GPU device is lost, nothing can be drawn (#6464). When saving the unsaved work for recovery keeps failing (a full disk, an unwritable or missing recovery folder), the app keeps retrying. Quitting (the title bar's close button, File › Exit, the window manager's close) then parked the quit behind the unsaved-changes prompt, which can never show, so the blank window could not be closed. discard_ui::intercept now lets a quit through once the device is lost, with a warning in the log. --- crates/ui-egui/src/discard_ui.rs | 43 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/titlebar.rs | 19 ++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 263f3652bf..a6a65f29fe 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -71,6 +71,14 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool { if docs.is_empty() { return false; } + // Once the window's GPU device is lost nothing can be drawn (`gpu_status::check_window`), so + // the prompt would never show. While saving for recovery keeps failing, quitting (the close + // button, File › Exit) must still go through rather than leave a blank window that can't be + // closed. + if id == EXIT && app.window_lost { + log::warn!("quitting a window that lost its GPU device before its unsaved work could be saved for recovery"); + return false; + } let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs, focused: None }; match &app.discard { None => ask(app, prompt), @@ -1042,6 +1050,41 @@ mod tests { assert!(app.discard.is_some()); } + /// After the window's GPU device is lost nothing can be drawn, so a prompt could never be seen + /// or answered. While unsaved work can't be written for recovery (the save keeps retrying), the + /// close button must still quit, instead of opening a prompt that never shows. + #[test] + fn window_close_goes_through_once_the_window_device_is_lost() { + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + app.window_lost = true; + assert!(!press_window_close(&mut app), "the close isn't cancelled"); + assert!(app.discard.is_none(), "no prompt opened"); + } + + /// The title bar's close button and File › Exit run `file.exit` through `menus::invoke`, which + /// parks it behind the prompt before any close request is made: once the window's device is + /// lost they must close instead. + #[test] + fn file_exit_quits_without_a_prompt_once_the_window_device_is_lost() { + let exit = |app: &mut PhotocraftApp| { + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| { + let _ = crate::menus::invoke(app, ui.ctx(), EXIT, json!({})); + }); + out.textures_delta.clear(); + let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|o| o.commands).unwrap_or_default(); + commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close)) + }; + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + assert!(!exit(&mut app), "a window that can draw asks first"); + assert!(app.discard.is_some()); + app.discard = None; + app.window_lost = true; + assert!(exit(&mut app), "the window is asked to close"); + assert!(app.discard.is_none(), "no prompt opened"); + } + #[test] fn unsaved_work_is_any_dirty_document_until_closing_is_confirmed() { let mut app = app_with_docs(2); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index dc659840ba..e81adb9f15 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -729,6 +729,25 @@ mod tests { assert!(cmds.iter().any(|c| matches!(c, ViewportCommand::Close)), "{cmds:?}"); } + /// After the window's GPU device is lost, the prompt asking about unsaved work could never be + /// drawn: the close button quits instead of opening it (see `discard_ui::intercept`). + #[test] + fn close_quits_without_a_prompt_once_the_window_device_is_lost() { + let mut w = Win::new(1100.0); + w.app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + w.app.run("layer.new.layer", json!({})).unwrap(); + w.frame(vec![]); + let close = w.caption(Caption::Close).center(); + let asks = w.click(close); + assert!(!asks.iter().any(|c| matches!(c, ViewportCommand::Close)), "a window that can draw asks first: {asks:?}"); + assert!(w.app.discard.is_some()); + w.app.discard = None; + w.app.window_lost = true; + let quits = w.click(close); + assert!(quits.iter().any(|c| matches!(c, ViewportCommand::Close)), "{quits:?}"); + assert!(w.app.discard.is_none(), "no prompt opened"); + } + #[test] fn minimize_and_maximize_follow_the_window_state() { let mut w = Win::new(1100.0);