From 02fff63cb4b8a439dab8a3bd53376f96367ee991 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:55:47 +0300 Subject: [PATCH 01/77] Brush Settings: combine the Spacing checkbox and percentage header Keep the saved spacing while Off or toggling, and retain percentage limits and tools.setBrush routing. Cover the shared header in all themes and add disabled-input, value-retention and journal regressions. Addresses only the Spacing header subsection of #3825. Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel_tests.rs | 41 +++++++++++++ crates/ui-egui/src/brush_sections.rs | 78 +++++++++++++++++++++++-- docs/brush-parity.md | 4 +- docs/gaps.md | 7 ++- 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..14fa1e40ba 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -547,6 +547,47 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"spacing": 0.427, "spacingEnabled": false}})).unwrap(); + h.run_steps(2); + let journal = h.state().session.journal.len(); + let spacing = h.state().session.tools.brush.spacing; + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert!(h.state().session.tools.brush.spacing_enabled); + assert_eq!(h.state().session.tools.brush.spacing, spacing); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": true})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacing": 0.44_f32})); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": false})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(44.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 3, "disabled input adds no command"); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 4); +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..4b6a1ea6fb 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -53,6 +53,23 @@ fn signed_pct(ui: &mut egui::Ui, label: &str, v: &mut f32, min: f32, max: f32) - changed } +/// The checkbox labels the spacing field; toggling it must retain imported fractional values. +fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { + let mut percent = (b.spacing * 100.0).round(); + let mut changed = false; + ui.horizontal(|ui| { + widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0)).inner.changed(); + }); + }); + changed |= ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::slider(ui, &mut percent, 1.0..=1000.0, None)).inner.changed(); + if changed { + b.spacing = (percent.round() / 100.0).clamp(0.01, 10.0); + } + ui.add_space(4.0); +} + fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive, suffix: &str) -> bool { let mut x = *v; let changed = widgets::slider_row(ui, label, &mut x, range.clone(), suffix, None).changed(); @@ -325,11 +342,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { pct(ui, tl!("Hardness"), &mut b.hardness, 1.0); }); - // Unchecked, the pointer's speed sets the spacing (Photoshop). - widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); - ui.add_space(2.0); - let on = b.spacing_enabled; - ui.add_enabled_ui(on, |ui| signed_pct(ui, tl!("Spacing"), &mut b.spacing, 0.01, 10.0)); + spacing_row(ui, b); None } @@ -645,6 +658,61 @@ mod tests { h } + #[test] + fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { + for kind in theme::ThemeKind::ALL { + for enabled in [false, true] { + let b = BrushSettings { spacing: 0.427, spacing_enabled: enabled, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + assert_eq!(h.query_all_by_label("Spacing").count(), 1, "{kind:?}: the checkbox is the single label"); + let checkbox = h.get_by_label("Spacing").rect(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); + assert_eq!(field.accesskit_node().is_disabled(), !enabled); + assert_eq!(h.state(), &b, "rendering does not round the saved spacing"); + } + } + } + + #[test] + fn spacing_row_retains_disabled_values_and_percentage_editing_limits() { + let mut h = section_harness(0, BrushSettings { spacing: 0.42, spacing_enabled: false, ..Default::default() }); + let before = h.state().clone(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0); + let to = pos2(field.rect().right() - 25.0, from.y); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + drag(&mut h, from, to); + assert_eq!(h.state(), &before, "Off ignores both field input and a track drag"); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert!(h.state().spacing_enabled); + assert_eq!(h.state().spacing, 0.42, "enabling keeps the saved value"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().spacing, 0.43, "integer percentage edits become a fraction"); + drag(&mut h, from, to); + assert!(h.state().spacing > 5.0, "the same track is editable when enabled"); + for (spacing, value, key) in [(0.01, 1.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] { + h.state_mut().spacing = spacing; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state().spacing, spacing, "the percentage stays within 1..1000"); + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..6585b533e4 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Spacing checkbox and percentage share one header; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -19,6 +19,7 @@ tablets against Photoshop side by side. | Section | Status | Evidence | |---|---|---| | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | +| Brush Tip Shape Spacing checkbox and percentage share one header without a duplicated label | done | #3825 (Spacing header only); UI regressions cover all 14 themes, enabled/disabled editing, percentage limits, fractional-value retention and `tools.setBrush` routing. | | Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | | Scattering (both axes, count, count jitter) | done | | | Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | @@ -121,3 +122,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Brush Tip Shape combines the Spacing checkbox and percentage into one header (#3825, partial), retaining the value while disabled. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..a3d116ee9b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: combined Spacing header, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,10 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Brush Tip Shape's Spacing checkbox and + percentage share one header, removing the duplicated label. Disabled controls retain + the saved spacing; toggling does not round fractional imported values. The issue's + remaining layout and control gaps stay open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -264,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: combined Spacing checkbox/percentage header (#3825, partial); disabled-value and percentage-edit behavior retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | From ff0eb52d7a338dfc7585e29b5de6882e58edf953 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 02/77] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..b7a5a4cb85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From b0e30c3ed02b276f51e0c31454f8cf004577e140 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 03/77] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb85..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 91e18fa45a5d70f7760ffe9287448e3071b50682 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 02:19:26 -0200 Subject: [PATCH 04/77] paint: 13 more painting tools wrap across the canvas edges in Pattern Preview Clone Stamp already wraps in Pattern Preview (#4997). Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Mixer Brush, Color Replacement, Background Eraser, Pattern Stamp, History Brush, Healing Brush and Spot Healing now take "wrap": true as well, and the canvas sends it while Pattern Preview is on (#8883). The sequential-dab tools run their dabs on WorkTiles, moved into photocraft_paint::retouch and given a wrap tile: each canvas pixel is kept once and read and written wherever it repeats, so a live stroke and its commit still give the same pixels. The composite tools go through #4997's periodic::Composite via one periodic::stroke helper. Spot Healing centres its sampling window on the stroke, at most one canvas across. With wrap off, every command takes the path it took before. --- crates/engine/src/brush_cmds.rs | 20 +- crates/engine/src/eraser_cmds.rs | 12 +- crates/engine/src/retouch_cmds.rs | 364 ++++++++++-------- .../engine/src/retouch_cmds/pattern_stamp.rs | 14 +- crates/engine/src/retouch_cmds/periodic.rs | 25 ++ crates/engine/src/retouch_cmds/tests.rs | 289 ++++++++++++++ crates/paint/src/bg_erase.rs | 19 +- crates/paint/src/lib.rs | 4 +- crates/paint/src/mixer.rs | 31 +- crates/paint/src/replace.rs | 42 +- crates/paint/src/retouch.rs | 264 +++++++++++++ crates/paint/src/wrap.rs | 166 ++++++++ crates/ui-egui/src/canvas.rs | 8 + crates/ui-egui/src/eraser_ui.rs | 6 +- crates/ui-egui/src/retouch_ui.rs | 56 ++- crates/ui-egui/src/stroke_timing_tests.rs | 54 +++ 16 files changed, 1179 insertions(+), 195 deletions(-) create mode 100644 crates/paint/src/wrap.rs diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index feb238cf40..bd82e4f808 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -9,7 +9,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; -use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; +use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement_wrapped, color_replacement_effect}; use photocraft_paint::tile::PREVIEW_SIDE; use photocraft_paint::{ BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke, @@ -667,10 +667,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Mixer Brush", |doc, _| { let sel = doc.selection.clone(); + // View › Pattern Preview: the stroke paints and picks up where it lies on the canvas. + let wrap = flag(p, "wrap", false).then(|| doc.bounds()).filter(|t| !t.is_empty()); let sample = if sample_all { let ds = photocraft_paint::dabs(&stroke); let ctx = photocraft_paint::BrushContext::new(&stroke.brush); let area = ds.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))); + let area = wrap.map_or(area, |t| photocraft_paint::wrap::fold_area(area, t)); let buf = photocraft_compose::render(doc, area); let mut surf = Surface::new(photocraft_color::PixelFormat::RGBA32F); let flat: Vec = buf.px.iter().flatten().copied().collect(); @@ -682,7 +685,7 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { None }; let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; - Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) + Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock, wrap)) })?; state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke); s.tools.mixer = state; @@ -752,8 +755,9 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { let dmg = s.edit("Color Replacement", |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; + let wrap = flag(p, "wrap", false).then(|| doc.bounds()); let surf = paint_surface(doc, id, &json!({}))?; - Ok(apply_color_replacement(surf, &stroke, &rs, sel.as_ref(), lock)) + Ok(apply_color_replacement_wrapped(surf, &stroke, &rs, sel.as_ref(), lock, wrap)) })?; Ok(damage_json(s, dmg)) } @@ -762,8 +766,10 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { /// recolouring per dab as the commit, so the preview at the last point is the committed result. pub fn live_color_replacement(s: &Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", &json!({}), stroke, Some(id), 0, |pre| { - Box::new(color_replacement_effect(pre.clone(), rs)) + // Layer pixels, as the commit paints, wrapped as it is. + let params = json!({"wrap": flag(p, "wrap", false)}); + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", ¶ms, stroke, Some(id), 0, |pre, wrap| { + Box::new(color_replacement_effect(pre.clone(), rs, wrap)) })? .with_tail_preview() } @@ -1203,7 +1209,7 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, has_paintable, mixer_brush, true @@ -1213,7 +1219,7 @@ pub fn specs() -> Vec { spec!( "paint.colorReplacement", "Color Replacement", - r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, crate::commands::has_paintable, color_replacement, true diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 129753ea86..653cd79993 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::Stroke; -use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser, background_eraser_effect, background_eraser_stroke}; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser_wrapped, background_eraser_effect, background_eraser_stroke}; use photocraft_paint::replace::{Limits, Sampling}; use serde_json::{Value, json}; @@ -152,10 +152,12 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { let (stroke, bs, id) = background_eraser_setup(s, p)?; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); + // View › Pattern Preview: the stroke carries on across the canvas edges. + let wrap = flag(p, "wrap", false).then(|| doc.bounds()); crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; - Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) + Ok(apply_background_eraser_wrapped(surf, &stroke, &bs, selection.as_ref(), wrap)) })?; Ok(damage(s, dmg, true)) } @@ -170,7 +172,9 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result Box::new(effect), // No alpha: the commit erases nothing either. None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), @@ -196,7 +200,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index eae429f8c0..4480f9b8b8 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -15,7 +15,8 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; +use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; +use photocraft_paint::wrap::wrap_coord; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -120,6 +121,10 @@ fn num(p: &Value, k: &str, d: f32) -> f32 { fn flag(p: &Value, k: &str, d: bool) -> bool { p.get(k).and_then(Value::as_bool).unwrap_or(d) } +/// The canvas a stroke with `"wrap": true` repeats across (View › Pattern Preview). +fn wrap_tile(pre: &Document, p: &Value) -> Option { + flag(p, "wrap", false).then(|| pre.bounds()).filter(|t| !t.is_empty()) +} fn string<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str { p.get(k).and_then(Value::as_str).unwrap_or(d) } @@ -382,26 +387,19 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = - run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { - if let Some(l) = live { - *surf = l.surface; - return Ok(l.bounds); - } - if flag(p, "wrap", false) { - let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); - renderer.push(&stroke.points); - renderer.finish(); - let before = surf.clone(); - let bounds = renderer.bounds(); - return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } - .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); - } - let (bounds, cov) = coverage(CMD, &stroke)?; - // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. - let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; - Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) - })?; + let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } + if flag(p, "wrap", false) { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); + } + let (bounds, cov) = coverage(CMD, &stroke)?; + // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. + let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; + Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) + })?; let mut r = clone_result(dmg, off, aligned, p, &stroke); if from_live { r["fromLive"] = json!(true); @@ -520,11 +518,7 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { let bounds = self.renderer.bounds().union(&self.tail); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { - periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) - } else { - bounds - } + if flag(&self.params, "wrap", false) { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } } /// Every point rendered so far, in order. @@ -567,7 +561,7 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + if flag(&self.params, "wrap", false) { return self.push_periodic(); } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); @@ -596,8 +590,12 @@ impl LiveRetouch { let mut tail = self.renderer.tail_preview(); self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect()); dirty = dirty.union(&self.tail); - let LivePaint::Clone { which, map, cmd } = &self.paint else { - return Err(bad(&self.cmd, "periodic painting requires a clone source")); + let (pre, id, pre_surf, live_paint, live_cmd) = (&self.pre, self.id, &self.pre_surf, &self.paint, self.cmd.as_str()); + let fmt = pre_surf.format(); + let paint = |r| match live_paint { + LivePaint::Clone { which, map, cmd } => clone_sample(pre, id, pre_surf, *which, r, map, true, cmd), + LivePaint::History(src) => periodic::sample(pre, id, src, SampleLayers::Current, r, live_cmd), + LivePaint::Pattern { tile, place } => Ok(pattern_stamp::paint_region(tile, place, &fmt, r)), }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; periodic::Composite { @@ -609,7 +607,7 @@ impl LiveRetouch { lock: self.lock, mode: self.mode, } - .draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd)) + .draw(dirty, &self.brush, &self.points, paint) } /// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`. @@ -678,15 +676,30 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { + // Pattern Preview: source and destination repeat across the canvas edges, so a stroke over + // a seam heals as one patch and goes on wherever it repeats. + let wrap = flag(p, "wrap", false); let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); - let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; + let src = clone_sample(pre, id, surf, which, g, &map, wrap, CMD)?; // The destination the texture is fitted to: the layer itself, or what the user sees when // sampling several layers (so healing onto an empty layer matches the composite). - let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) }; + let dst = if wrap { + periodic::sample(pre, id, surf, which, g, CMD)? + } else if which == SampleLayers::Current { + Region::read(surf, g) + } else { + composite_region(pre, id, which, g, fmt) + }; let mask: Vec = cov.iter().map(|c| *c > 0.0).collect(); let healed = heal_region(&fmt, &src, &dst, &mask); + if wrap { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| { + let part = healed.crop(r); + if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) } + }); + } Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode)) })?; Ok(clone_result(dmg, off, aligned, p, &stroke)) @@ -714,13 +727,17 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; - let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { + let dmg = run_stroke(s, "History Brush", id, p, |pre, surf, sel, lock| { if let Some(l) = live { *surf = l.surface; return Ok(l.bounds); } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; + if flag(p, "wrap", false) { + // Pattern Preview: the history state repeats with the canvas, as the stroke does. + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); + } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -798,24 +815,63 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { - let (bounds, cov) = coverage("paint.spotHealing", stroke)?; +/// +/// Wrapped around the canvas `wrap` (View › Pattern Preview), the window is centred on the stroke +/// whatever the canvas edges and at most one canvas across, so it holds no second copy of the +/// stroke; its pixels repeat across the edges, and the healed pixels go where they lie on the +/// canvas. +#[allow(clippy::too_many_arguments)] +fn spot_heal_surface( + surf: &mut Surface, + pre: &Document, + stroke: &Stroke, + kind: SpotType, + all: bool, + sel: Option<&Surface>, + lock: bool, + wrap: Option, +) -> Result { + const CMD: &str = "paint.spotHealing"; + let (bounds, cov) = coverage(CMD, stroke)?; let size = stroke.brush.size; - let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); + let region_rect = match wrap { + Some(tile) => { + let (tw, th) = (i64::from(tile.width()), i64::from(tile.height())); + let x0 = (i64::from(bounds.x0) + i64::from(bounds.x1) - tw) / 2; + let y0 = (i64::from(bounds.y0) + i64::from(bounds.y1) - th) / 2; + let around = Rect::new(x0 as i32, y0 as i32, (x0 + tw) as i32, (y0 + th) as i32); + crate::fill_cmds::sampling_window(bounds, around) + } + None => crate::fill_cmds::sampling_window(bounds, pre.bounds()), + }; if region_rect.is_empty() { return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; let fmt = surf.format(); - let img = if all { composite_region(pre, None, SampleLayers::All, region_rect, fmt) } else { Region::read(surf, region_rect) }; + let img = match wrap { + Some(_) => periodic::sample(pre, None, surf, if all { SampleLayers::All } else { SampleLayers::Current }, region_rect, CMD)?, + None if all => composite_region(pre, None, SampleLayers::All, region_rect, fmt), + None => Region::read(surf, region_rect), + }; let (w, h, ch) = (img.width(), img.height(), img.ch); let bw = bounds.width() as usize; let mut stroke_cov = vec![0.0f32; w * h]; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw) as i32, bounds.y0 + (i / bw) as i32); + // A wrapped stroke longer than the window (one canvas) folds onto it: the strongest + // coverage of each pixel heals it once. + let (x, y) = match wrap { + Some(t) => { + let wrap_into = |v: i32, lo: i32, period: u32| wrap_coord(v, lo, lo.saturating_add(period as i32)); + (wrap_into(x, region_rect.x0, t.width()), wrap_into(y, region_rect.y0, t.height())) + } + None => (x, y), + }; if region_rect.contains(x, y) { - stroke_cov[img.index(x, y)] = *c; + let px = &mut stroke_cov[img.index(x, y)]; + *px = px.max(*c); } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); @@ -843,8 +899,19 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: let src = Region { rect: region_rect, ch, data: filled }; let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); - let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); - Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) + let cov_of = |r: Rect, (dx, dy): (i32, i32)| -> Vec { + (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x + dx, y + dy)]).collect() + }; + let Some(tile) = wrap else { + return Ok(apply_coverage(surf, dmg, &cov_of(dmg, (0, 0)), stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)); + }; + let mut done = Rect::EMPTY; + for (part, off) in photocraft_paint::wrap::pieces(dmg, tile) { + let mut paint = healed.crop(part.translate(off.0, off.1)); + paint.rect = part; + done = done.union(&apply_coverage(surf, part, &cov_of(part, off), stroke.brush.opacity, sel, lock, &paint, BlendMode::Normal)); + } + Ok(done) } fn spot_healing(s: &mut Session, p: &Value) -> Result { @@ -858,7 +925,8 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; + let dmg = + run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock, wrap_tile(pre, p)))?; Ok(json!({ "damage": damage_json(dmg) })) } @@ -1139,8 +1207,9 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { } let fmt = surf.format(); let mut effect = tool.effect(fmt, stroke.brush.spacing); - let mut sampler = all.then(|| AllLayers::new(pre, fmt)); - Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler { + let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); + let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); + Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), None => effect(work, fp), })) @@ -1152,81 +1221,6 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(r) } -/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]). -const WORK_TILE: i32 = 128; - -/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in -/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a -/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the -/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels. -struct WorkTiles { - ch: usize, - tiles: std::collections::HashMap<(i32, i32), Vec>, -} - -impl WorkTiles { - fn new(ch: usize) -> Self { - Self { ch, tiles: std::collections::HashMap::new() } - } - - fn tile_rect((tx, ty): (i32, i32)) -> Rect { - Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) - } - - /// The tiles `r` overlaps. - fn coords(r: Rect) -> impl Iterator { - let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); - let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); - let empty = r.is_empty(); - (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) - } - - /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. - fn read(&self, pre: &Surface, r: Rect) -> Region { - let mut out = Region::new(r, self.ch); - let n = self.ch; - let mut buf = Vec::new(); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - let tile = self.tiles.get(&c); - if tile.is_none() { - pre.read_region_into(part, &mut buf); - } - for y in part.y0..part.y1 { - let src = match tile { - Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), - None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), - }; - let dst = out.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - out - } - - /// Store `region` as the working pixels of its rectangle. - fn write(&mut self, pre: &Surface, region: &Region) { - let (r, n) = (region.rect, self.ch); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - for y in part.y0..part.y1 { - let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; - let src = region.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - } -} - /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. @@ -1249,6 +1243,8 @@ pub struct LiveDab { sel: Option, lock: bool, bounds: Rect, + /// The canvas a wrapped stroke repeats across (View › Pattern Preview, `"wrap": true`). + wrap: Option, /// Show the dabs finishing the stroke would add, so the preview at the last point is the /// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the /// tail runs on a scratch copy at every push, so an effect that carries paint from dab to @@ -1278,13 +1274,14 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, _| effect.effect(pre.format(), spacing))?; live.tool = Some((tool, brush)); Ok(live) } - /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) - /// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target. + /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke + /// and the canvas a wrapped stroke repeats across) at each dab of `stroke`, reading `halo` + /// pixels around it. `p` gives the target and `wrap`. pub(crate) fn with_effect( s: &Session, cmd: &str, @@ -1292,7 +1289,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -1304,7 +1301,7 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let base = (st.doc.id, st.revision); @@ -1323,7 +1320,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -1335,12 +1332,13 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { + let wrap = wrap_tile(&doc, p); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); - let effect = effect(&pre_surf); + let effect = effect(&pre_surf, wrap); // `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale). let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); let mut live = Self { @@ -1350,7 +1348,7 @@ impl LiveDab { done: 0, effect, neighborhood, - work: WorkTiles::new(pre_surf.channels()), + work: WorkTiles::new(pre_surf.channels()).wrapped(wrap), pre_surf, id, params: p.clone(), @@ -1358,6 +1356,7 @@ impl LiveDab { sel, lock, bounds: Rect::EMPTY, + wrap, preview_tail: false, tail: Rect::EMPTY, tool: None, @@ -1391,15 +1390,34 @@ impl LiveDab { if r.is_empty() { continue; } + let fp = ctx.footprint(d, self.done + k); let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); - self.work.write(&self.pre_surf, ®ion.crop(r)); - dmg = dmg.union(&r); + (self.effect)(&mut region, &fp); + self.work.write_dab(&self.pre_surf, ®ion, &fp); + dmg = dmg.union(&self.on_canvas(r)); } self.done += new.len(); dmg } + /// Where the stroke's rectangle `r` lands on the document: `r`, or in a wrapped stroke the + /// part of the canvas it covers. + fn on_canvas(&self, r: Rect) -> Rect { + self.wrap.map_or(r, |t| photocraft_paint::wrap::fold_area(r, t)) + } + + /// [`Self::show`] working pixels laid out in the stroke's own coordinates: a wrapped stroke + /// shows each part where it lies on the canvas. + fn show_unwrapped(&mut self, work: &Region) -> Result<()> { + let Some(tile) = self.wrap else { return self.show(work.rect, work) }; + for (part, (dx, dy)) in photocraft_paint::wrap::pieces(work.rect, tile) { + let mut piece = work.crop(part.translate(dx, dy)); + piece.rect = part; + self.show(part, &piece)?; + } + Ok(()) + } + /// Mix the working pixels of `r` back over the original into the shown document. fn show(&mut self, r: Rect, work: &Region) -> Result<()> { let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; @@ -1433,7 +1451,7 @@ impl LiveDab { // Back to the stroke without the previous tail. let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let shown = dmg.union(&old); - let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r); + let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| self.on_canvas(*r)); if shown.is_empty() && tail_rect.is_empty() { return Ok(Rect::EMPTY); } @@ -1442,9 +1460,9 @@ impl LiveDab { let work = self.work.read(&self.pre_surf, shown); self.show(shown, &work)?; } - if let Some((r, work)) = tail { - self.show(r, &work)?; - self.tail = r; + if let Some((_, work)) = tail { + self.show_unwrapped(&work)?; + self.tail = tail_rect; } Ok(shown.union(&tail_rect)) } @@ -1490,26 +1508,56 @@ struct AllLayers<'a> { pre: &'a Document, fmt: PixelFormat, visible: Option, + /// A stroke wrapped around the canvas (View › Pattern Preview): the composite at full + /// precision under everything the stroke reads, and the dabs' edits of it, kept once per + /// pixel of the canvas as the layer's working pixels are. + wrapped: Option<(Surface, WorkTiles)>, } impl<'a> AllLayers<'a> { fn new(pre: &'a Document, fmt: PixelFormat) -> Self { - Self { pre, fmt, visible: None } + Self { pre, fmt, visible: None, wrapped: None } + } + + /// Sample for `stroke` wrapped around `wrap`, whose dabs read `halo` pixels around them. + fn wrapped(mut self, wrap: Option, stroke: &Stroke, halo: i32) -> Self { + let Some(tile) = wrap else { return self }; + let ctx = photocraft_paint::BrushContext::new(&stroke.brush); + let reach = photocraft_paint::dabs(stroke).iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))).inflate(halo); + let area = photocraft_paint::wrap::fold_area(reach, tile); + let mut visible = Surface::new(PixelFormat { sample: SampleType::F32, ..self.fmt }); + if !area.is_empty() { + visible.write_region(area, &composite_region(self.pre, None, SampleLayers::All, area, self.fmt).data); + } + self.wrapped = Some((visible, WorkTiles::new(self.fmt.channels()).wrapped(Some(tile)))); + self } fn dab(&mut self, work: &mut Region, fp: &Footprint, effect: impl FnOnce(&mut Region, &Footprint)) { + if let Some((visible, edits)) = self.wrapped.as_mut() { + let mut shown = edits.read(visible, work.rect); + effect(&mut shown, fp); + lay(work, &shown, fp); + edits.write_dab(visible, &shown, fp); + return; + } let (pre, fmt) = (self.pre, self.fmt); let visible = self.visible.get_or_insert_with(|| composite_region(pre, None, SampleLayers::All, work.rect, fmt)); if visible.rect != work.rect || visible.ch != work.ch { return effect(work, fp); } effect(visible, fp); - let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - if fp.at(x, y) > 0.0 { - work.px_mut(x, y).copy_from_slice(visible.px(x, y)); - } + lay(work, visible, fp); + } +} + +/// Lay what a dab did to the visible pixels onto the layer's working pixels under it. +fn lay(work: &mut Region, visible: &Region, fp: &Footprint) { + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + if fp.at(x, y) > 0.0 { + work.px_mut(x, y).copy_from_slice(visible.px(x, y)); } } } @@ -1547,7 +1595,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]? → {"damage","phase","aligned","pattern"}"# + r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","phase","aligned","pattern"}"# ), enabled: pattern_stamp::enabled, run: pattern_stamp::run, @@ -1559,7 +1607,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"# + r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal","wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","offset","aligned","nextSource"}"# ), enabled: has_pixel_layer, run: healing_brush, @@ -1570,7 +1618,9 @@ pub fn specs() -> Vec { label: "Spot Healing Brush", menu: &[], shortcut: None, - params: brush_params!(r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false"#), + params: brush_params!( + r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: spot_healing, journal: true, @@ -1612,7 +1662,9 @@ pub fn specs() -> Vec { label: "Dodge", menu: &[], shortcut: None, - params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), + params: brush_params!( + r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.dodge"), journal: true, @@ -1622,7 +1674,9 @@ pub fn specs() -> Vec { label: "Burn", menu: &[], shortcut: None, - params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), + params: brush_params!( + r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.burn"), journal: true, @@ -1632,7 +1686,9 @@ pub fn specs() -> Vec { label: "Sponge", menu: &[], shortcut: None, - params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#), + params: brush_params!( + r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sponge"), journal: true, @@ -1642,7 +1698,9 @@ pub fn specs() -> Vec { label: "Blur", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.blur"), journal: true, @@ -1652,7 +1710,9 @@ pub fn specs() -> Vec { label: "Sharpen", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sharpen"), journal: true, @@ -1662,7 +1722,9 @@ pub fn specs() -> Vec { label: "Smudge", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.smudge"), journal: true, @@ -1673,7 +1735,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"# + r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# ), enabled: has_pixel_layer, run: history_brush, diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index f13afc112e..7ccb293d4f 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -8,12 +8,12 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::{LayerId, Pattern}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke_wrapped, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, periodic, reject_huge_stroke, wrap_tile}; use crate::{Result, Session}; const STREAM_PHASE_X: u64 = 101; @@ -88,10 +88,16 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { let pre = doc.clone(); let (surf, lock) = crate::channel_cmds::target_surface(doc, st.id, p)?; let sel = pre.selection.as_ref(); + // View › Pattern Preview: the stroke carries on across the canvas edges, the pattern + // following it as it goes. + let wrap = wrap_tile(&pre, p); if st.impressionist { // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. let effect = st.impressionist_effect(surf.format()); - Ok(apply_dab_stroke(surf, &st.stroke, sel, lock, 0, effect)) + Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) + } else if wrap.is_some() { + let (place, fmt) = (st.placement(), surf.format()); + periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } @@ -119,7 +125,7 @@ pub fn live_impressionist(s: &Session, p: &Value) -> Result { } let (stroke, id) = (st.stroke.clone(), st.id); let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); - super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre, _| st.impressionist_effect(pre.format()))?.with_tail_preview() } /// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints diff --git a/crates/engine/src/retouch_cmds/periodic.rs b/crates/engine/src/retouch_cmds/periodic.rs index f31542fb5a..27f4c9c86c 100644 --- a/crates/engine/src/retouch_cmds/periodic.rs +++ b/crates/engine/src/retouch_cmds/periodic.rs @@ -23,6 +23,31 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec { ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect() } +/// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a +/// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and +/// they go on through the stroke's coverage wherever it repeats on the document. +pub(super) fn stroke( + pre: &Document, + target: &mut Surface, + stroke: &Stroke, + selection: Option<&Surface>, + lock: bool, + mode: BlendMode, + mut paint: impl FnMut(&Surface, Rect) -> Result, +) -> Result { + let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); + renderer.push(&stroke.points); + renderer.finish(); + let before = target.clone(); + let bounds = renderer.bounds(); + Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode }.draw( + bounds, + &stroke.brush, + &stroke.points, + |r| paint(&before, r), + ) +} + /// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels. /// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and /// selection are applied once after all dabs, including repeated visits through other tiles. diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 524e12f080..e88129cb67 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -2407,3 +2407,292 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { } } } + +// --------------------------------------------------------------------------------------------- +// View › Pattern Preview: every retouching stroke wraps across the canvas edges (#8883) +// --------------------------------------------------------------------------------------------- + +/// The canvas the Pattern Preview tests wrap around. +const WRAP: (u32, u32) = (48, 36); + +/// `texture` repeated every canvas, so each copy of a pixel holds the same colour. +fn tiled_texture(x: i32, y: i32) -> [f32; 4] { + texture(x.rem_euclid(WRAP.0 as i32), y.rem_euclid(WRAP.1 as i32)) +} + +/// A stroke from just inside the corner where the middle copy of a three-by-three tiling meets +/// the next copies, out across both of its edges. +fn corner_stroke(extra: &Value) -> Value { + let mut p = json!({"points": [[84, 64], [106, 79]], "size": 12, "hardness": 100}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + p +} + +/// The texture on the layer. +fn textured(s: &mut Session) { + paint_layer(s, tiled_texture); +} + +/// The texture under an empty layer, which is the target (Sample All Layers). +fn textured_below(s: &mut Session) { + paint_layer(s, tiled_texture); + s.execute("layer.new.layer", json!({})).unwrap(); +} + +/// The texture in history state 1, painted over since. +fn textured_in_history(s: &mut Session) { + paint_layer(s, tiled_texture); + paint_layer(s, |_, _| [0.15, 0.2, 0.7, 1.0]); +} + +type Setup = fn(&mut Session); + +/// Every stroke tool that wraps, with options and a document for it. `true` marks the tools +/// that composite a paint image through the stroke's coverage (`periodic::Composite`), which +/// matches the ordinary composite only within rounding. +fn wrap_cases() -> Vec<(&'static str, Value, Setup, bool)> { + vec![ + ("paint.dodge", json!({"exposure": 60}), textured, false), + ("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false}), textured, false), + ("paint.sponge", json!({"mode": "saturate"}), textured, false), + ("paint.blur", json!({"strength": 80}), textured, false), + ("paint.sharpen", json!({"strength": 60}), textured, false), + ("paint.smudge", json!({"strength": 70}), textured, false), + ("paint.blur", json!({"strength": 80, "sampleAllLayers": true}), textured_below, false), + ("paint.smudge", json!({"strength": 70, "sampleAllLayers": true}), textured_below, false), + ("paint.mixerBrush", json!({"wet": 100, "mix": 80, "load": 50}), textured, false), + ("paint.mixerBrush", json!({"wet": 100, "mix": 80, "sampleAllLayers": true}), textured_below, false), + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), textured, false), + ("paint.backgroundEraser", json!({"tolerance": 40}), textured, false), + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true}), textured, false), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured, true), + ("paint.historyBrush", json!({"state": 1}), textured_in_history, true), + ("paint.healingBrush", json!({"offset": [-20, -14]}), textured, true), + ("paint.cloneStamp", json!({"offset": [-20, -14]}), textured, true), + ] +} + +/// The native-channel difference `periodic::Composite` may leave against the ordinary composite. +fn composite_tolerance(depth: u64) -> f32 { + match depth { + 8 => 1.1 / 255.0, + 16 => 1.1 / 65535.0, + _ => 0.00001, + } +} + +#[test] +fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three() { + // A stroke with `"wrap": true` gives each pixel of the canvas what the same stroke gives the + // copy of that pixel it goes over, on a document holding the canvas tiled three by three. + let (w, h) = (WRAP.0 as usize, WRAP.1 as usize); + for depth in DEPTHS { + for (cmd, extra, setup, composite) in wrap_cases() { + let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb"); + setup(&mut canvas); + let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb"); + setup(&mut tiled); + let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra)); + wrapped["wrap"] = json!(true); + canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}")); + let before = layer_pixels(&tiled); + tiled.execute(cmd, plain).unwrap(); + let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled)); + let n = got.len() / (w * h); + let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n; + let tolerance = if composite { composite_tolerance(depth) } else { 0.0 }; + let (mut moved, mut edges) = (0, [false; 4]); + for y in 0..h { + for x in 0..w { + let copies = (0..3).flat_map(|j| (0..3).map(move |i| (x + i * w, y + j * h))); + let changed: Vec<_> = copies.filter(|&(u, v)| want[at(u, v)] != before[at(u, v)]).collect(); + let (pixel, expected) = match changed.as_slice() { + [] => (&got[(y * w + x) * n..][..n], &before[at(x, y)]), + [(u, v)] => (&got[(y * w + x) * n..][..n], &want[at(*u, *v)]), + _ => panic!("{cmd}: ({x}, {y}) has two copies under the stroke"), + }; + let error = pixel.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: ({x}, {y}) is off by {error}"); + if !changed.is_empty() { + moved += 1; + edges[0] |= x < 8; + edges[1] |= x >= w - 8; + edges[2] |= y < 8; + edges[3] |= y >= h - 8; + } + } + } + assert!(moved > 150 && edges.iter().all(|&e| e), "{cmd} {extra} at {depth}-bit: the stroke crossed both edges ({moved} px, {edges:?})"); + } + } +} + +#[test] +fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() { + let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60}); + let mut cases: Vec<(&str, Value, Setup, bool)> = wrap_cases(); + cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); + cases.push(("paint.spotHealing", json!({"type": "proximityMatch"}), textured, false)); + for depth in [8, 32] { + for (cmd, extra, setup, composite) in &cases { + let run = |wrap: bool| { + // Big enough that Spot Healing's window stays clear of the edges either way. + let mut s = session(300, 230, depth, "rgb"); + setup(&mut s); + let mut q = p.clone(); + for (k, v) in extra.as_object().unwrap() { + q[k] = v.clone(); + } + q["wrap"] = json!(wrap); + let r = s.execute(cmd, q).unwrap(); + (layer_pixels(&s), r["damage"].clone()) + }; + let ((plain, d0), (wrapped, d1)) = (run(false), run(true)); + let tolerance = if *composite { composite_tolerance(depth) } else { 0.0 }; + let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: off by {error}"); + if !*composite { + assert_eq!(d0, d1, "{cmd} {extra} at {depth}-bit: the same damage"); + } + } + } +} + +#[test] +fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_nothing_else() { + // The window is centred on the stroke whatever the edges and one canvas across, so the + // corner heals from what surrounds it in the copies, the same as when the stroke stays inside. + for kind in ["contentAware", "createTexture", "proximityMatch"] { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, |x, y| if (x - 2).abs() < 3 && (y - 2).abs() < 3 { [0.0, 0.0, 0.0, 1.0] } else { tiled_texture(x, y) }); + let before = layer_pixels(&s); + // The dark blemish straddles the corner (0, 0); the stroke is drawn on the copy to its + // lower right, around (48, 36). + s.execute("paint.spotHealing", json!({"points": [[48, 36], [50, 38]], "size": 14, "hardness": 100, "type": kind, "wrap": true})).unwrap(); + let after = layer_pixels(&s); + let n = after.len() / (WRAP.0 * WRAP.1) as usize; + let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); + for (x, y) in [(1, 1), (46, 1), (1, 34), (46, 34)] { + assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); + } + for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { + assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); + } + } +} + +#[test] +fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { + let mut cases = wrap_cases(); + cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); + for (cmd, extra, setup, _) in cases { + let mut s = session(16, 12, 8, "rgb"); + setup(&mut s); + let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + let d = r["damage"].as_array().map(|d| d.iter().filter_map(Value::as_i64).collect::>()).unwrap_or_default(); + assert!(matches!(d.as_slice(), [x, y, w, h] if *x >= 0 && *y >= 0 && x + w <= 16 && y + h <= 12), "{cmd}: damage {d:?} is on the canvas"); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); + } +} + +#[test] +fn wrapped_live_strokes_show_what_their_commits_paint() { + // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the + // pixels they drew; the strokes that preview their finishing dabs show their commit's. + let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; + for depth in DEPTHS { + for (cmd, extra) in dab_cases() { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut s, tiled_texture); + let mut extra = extra.clone(); + extra["wrap"] = json!(true); + let rendered = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); + let want = layer_pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + offer_live(&mut s, cmd, &points, 50, &extra); + let taken = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{cmd} at {depth}-bit"); + assert_eq!(taken["damage"], rendered["damage"], "{cmd} at {depth}-bit"); + assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit: the live pixels are the command's"); + } + let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + type Begin = fn(&Session, &Value) -> Result; + let shown: [(&str, Value, Begin); 3] = [ + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), crate::brush_cmds::live_color_replacement), + ("paint.backgroundEraser", json!({"tolerance": 40}), crate::eraser_cmds::live_background_eraser), + // A seed, as the canvas sends: without one the jitter hashes all the points. + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7}), live_pattern_impressionist), + ]; + for (cmd, extra, begin) in shown { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut s, tiled_texture); + let mut p = dab_opts(&points[..1], 50, &extra); + p["wrap"] = json!(true); + let mut live = begin(&s, &p).unwrap(); + live.push(&pts[1..]).unwrap(); + let shown = live.doc.clone(); + let pre = s.active().unwrap().doc.clone(); + p["points"] = json!(points); + s.execute(cmd, p).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), st.doc.layer(id).unwrap().surface().unwrap()); + let before = pre.layer(id).unwrap().surface().unwrap(); + let worst = (0..WRAP.1 as i32) + .flat_map(|y| (0..WRAP.0 as i32).map(move |x| (x, y))) + .map(|(x, y)| (0..4).map(|c| (a.rgba(x, y)[c] - b.rgba(x, y)[c]).abs()).fold(0.0f32, f32::max)) + .fold(0.0f32, f32::max); + assert!(worst <= 2.0 / 255.0 + 1e-6, "{cmd} at {depth}-bit: live and commit differ by {worst}"); + let right = WRAP.0 as i32 - 3; + assert!((0..WRAP.1 as i32).any(|y| b.rgba(right, y) != before.rgba(right, y)), "{cmd} at {depth}-bit: the stroke came back at the right edge"); + } + } +} + +#[test] +fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { + // History Brush commits the pixels its live stroke drew across the edges; the aligned Pattern + // Stamp's preview is its commit. + let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; + let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + for depth in DEPTHS { + for (cmd, extra, setup) in [ + ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), + ] { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + setup(&mut s); + let mut p = dab_opts(&points[..1], 50, &extra); + p["wrap"] = json!(true); + let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap(); + live.push(&pts[1..]).unwrap(); + p["points"] = json!(points); + let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 48, 36)); + let plain = s.execute(cmd, p.clone()).unwrap(); + let want = layer_pixels(&s); + let error = shown.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= composite_tolerance(depth), "{cmd} at {depth}-bit: the preview is off by {error}"); + if cmd == "paint.historyBrush" { + // A stroke drawn on the document as it is after the undo. + s.execute("edit.undo", json!({})).unwrap(); + let mut first = p.clone(); + first["points"] = json!(points[..1]); + let mut live = LiveRetouch::begin(&s, cmd, &first).unwrap(); + live.push(&pts[1..]).unwrap(); + s.offer_live_retouch(live).unwrap(); + let taken = s.execute(cmd, p).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{depth}-bit"); + assert_eq!(taken["damage"], plain["damage"], "{depth}-bit"); + assert!(layer_pixels(&s) == want, "{depth}-bit: the committed live pixels are the command's"); + } + } + } +} diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 323a90036a..e41308e76a 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::Stroke; use crate::replace::{Limits, Sampling}; -use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke_wrapped}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,8 +49,14 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { - let Some(effect) = background_eraser_effect(target, bs) else { return Rect::EMPTY }; - apply_dab_stroke(target, &background_eraser_stroke(stroke), selection, false, 0, effect) + apply_background_eraser_wrapped(target, stroke, bs, selection, None) +} + +/// [`apply_background_eraser`] wrapped around `wrap` (View › Pattern Preview): the stroke samples +/// and erases where its pixels lie on that canvas, so it carries on across the edges. +pub fn apply_background_eraser_wrapped(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>, wrap: Option) -> Rect { + let Some(effect) = background_eraser_effect(target, bs, wrap) else { return Rect::EMPTY }; + apply_dab_stroke_wrapped(target, &background_eraser_stroke(stroke), selection, false, 0, wrap, effect) } /// The stroke [`apply_background_eraser`] runs its dabs along: full opacity (the tool has none), @@ -64,8 +70,9 @@ pub fn background_eraser_stroke(stroke: &Stroke) -> Stroke { /// One Background Eraser dab's edit of a working copy of `pre` (the target before the stroke): /// shared by [`apply_background_eraser`] and live previews that run the dabs as they come, so both -/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). -pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option> { +/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). In a +/// stroke wrapped around `wrap`, `pre` is read where the working pixels lie on that canvas. +pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings, wrap: Option) -> Option> { let fmt = pre.format(); let a = alpha_index(&fmt)?; let nc = a; @@ -97,7 +104,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option, @@ -101,7 +105,9 @@ pub fn apply_mixer_stroke( state: &mut MixerState, selection: Option<&Surface>, lock_transparency: bool, + wrap: Option, ) -> Rect { + let wrap = wrap.filter(|t| !t.is_empty()); let fmt = target.format(); let n = fmt.channels(); let a = alpha_index(&fmt); @@ -121,7 +127,7 @@ pub fn apply_mixer_stroke( st.brush.opacity = 1.0; let mut acc = vec![0.0f32; n]; let mut paint = vec![0.0f32; n]; - let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { + let dmg = apply_dab_stroke_wrapped(target, &st, selection, lock_transparency, 0, wrap, |work, fp| { // Transfer › Wetness / Mix Jitter scale the settings per dab. let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); // Sample the canvas under the dab. @@ -136,6 +142,7 @@ pub fn apply_mixer_stroke( } let px: Vec = match sample { Some(s) => { + let (x, y) = wrap.map_or((x, y), |t| (wrap_coord(x, t.x0, t.x1), wrap_coord(y, t.y0, t.y1))); let v = s.pixel(x, y); if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } } @@ -202,7 +209,16 @@ mod tests { let mut s = Surface::new(PixelFormat::RGBA8); let mut st = MixerState::default(); st.load([1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false); + apply_mixer_stroke( + &mut s, + None, + &stroke(10.0), + &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, + &mut st, + None, + false, + None, + ); let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]); assert!(start > 0.95, "{start}"); if expect_dry { @@ -231,6 +247,7 @@ mod tests { &mut st, None, false, + None, ); // Blue is dragged into the white half. let c = s.rgba(115, 10); @@ -252,7 +269,7 @@ mod tests { // On red, the clean wet brush smears red and lays down none of the blue it was loaded with. let mut s = Surface::new(PixelFormat::RGBA8); s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false); + apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false, None); let c = s.rgba(100, 10); assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); } @@ -288,7 +305,7 @@ mod tests { fn dry_empty_brush_paints_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); let mut st = MixerState::default(); - let dmg = apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, ..Default::default() }, &mut st, None, false); + let dmg = apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, ..Default::default() }, &mut st, None, false, None); assert!(!dmg.is_empty()); assert_eq!(s.rgba(50, 10)[3], 0.0); } @@ -306,7 +323,7 @@ mod tests { for p in &mut k.points { p.pressure = pressure; } - apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false); + apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false, None); st.pickup }; assert!(run(0.0).is_none()); diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 20619cd48a..957037a0bd 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -8,7 +8,8 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{Footprint, Region, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, apply_dab_stroke_wrapped}; +use crate::wrap::wrap_coord; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -79,14 +80,29 @@ fn dist(a: &[f32; 4], b: [f32; 3]) -> f32 { /// Replace colours along a stroke. Returns the damaged rectangle. pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &ReplaceSettings, selection: Option<&Surface>, lock_transparency: bool) -> Rect { - let effect = color_replacement_effect(target.clone(), rs.clone()); - apply_dab_stroke(target, stroke, selection, lock_transparency, 0, effect) + apply_color_replacement_wrapped(target, stroke, rs, selection, lock_transparency, None) +} + +/// [`apply_color_replacement`] wrapped around `wrap` (View › Pattern Preview): the stroke samples +/// and recolours where its pixels lie on that canvas, so it carries on across the edges. +pub fn apply_color_replacement_wrapped( + target: &mut Surface, + stroke: &Stroke, + rs: &ReplaceSettings, + selection: Option<&Surface>, + lock_transparency: bool, + wrap: Option, +) -> Rect { + let effect = color_replacement_effect(target.clone(), rs.clone(), wrap); + apply_dab_stroke_wrapped(target, stroke, selection, lock_transparency, 0, wrap, effect) } /// One Color Replacement dab's edit of a working copy, matching against `pre` (the target before /// the stroke). Shared by [`apply_color_replacement`] and live previews that run the dabs as they -/// come, so both recolour the same pixels. -pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut(&mut Region, &Footprint) { +/// come, so both recolour the same pixels. In a stroke wrapped around `wrap` (View › Pattern +/// Preview), `pre` is read where the dab's pixels lie on that canvas. +pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option) -> impl FnMut(&mut Region, &Footprint) { + let wrap = wrap.filter(|t| !t.is_empty()); let fmt = pre.format(); let n = fmt.channels(); let mode = match rs.mode { @@ -111,13 +127,14 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut }; move |work: &mut Region, fp: &Footprint| { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); + let (sx, sy) = wrap.map_or((cx, cy), |t| (wrap_coord(cx, t.x0, t.x1), wrap_coord(cy, t.y0, t.y1))); let sampled = match rs.sampling { Sampling::Continuous => { - let c = pre.rgba(cx, cy); + let c = pre.rgba(sx, sy); [c[0], c[1], c[2]] } Sampling::Once => *once.get_or_insert_with(|| { - let c = pre.rgba(cx, cy); + let c = pre.rgba(sx, sy); [c[0], c[1], c[2]] }), Sampling::BackgroundSwatch => [rs.background[0], rs.background[1], rs.background[2]], @@ -127,8 +144,15 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut return; } let (w, h) = (r.width() as usize, r.height() as usize); - let mut pre_px = vec![[0.0f32; 4]; w * h]; - pre.read_rgba_into(r, &mut pre_px); + let read = |q: Rect| { + let mut px = vec![[0.0f32; 4]; q.width() as usize * q.height() as usize]; + pre.read_rgba_into(q, &mut px); + px + }; + let pre_px = match wrap { + Some(t) => crate::wrap::read(r, t, 1, read), + None => read(r), + }; // Per-pixel match weight. let mut weight = vec![0.0f32; w * h]; for y in r.y0..r.y1 { diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 06bafe6cfb..09c38fc78a 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -14,10 +14,13 @@ //! All pixel data here is the surface's *native* channels as normalised `f32` (any depth, any colour //! model); colour-space-specific work happens in the callbacks. +use std::collections::HashMap; + use photocraft_color::{BlendMode, PixelFormat}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use crate::wrap::{fits, fold_area, wrap_coord}; use crate::{Dab, Stroke, dab_coverage, dabs}; /// A rectangle of native-channel pixels (`rect.width() × rect.height() × ch` floats, row-major). @@ -75,6 +78,14 @@ impl Region { pub fn write(&self, s: &mut Surface) { s.write_region(self.rect, &self.data); } + /// Read `rect` from a surface that repeats `wrap` apart (View › Pattern Preview): `rect` may + /// cross its edges. `None` (or an empty tile) is [`Region::read`]. + pub fn read_wrapped(s: &Surface, rect: Rect, wrap: Option) -> Self { + match wrap.filter(|t| !t.is_empty()) { + Some(tile) => Self { rect, ch: s.channels(), data: crate::wrap::read(rect, tile, s.channels(), |r| s.read_region(r)) }, + None => Self::read(s, rect), + } + } } /// One dab's footprint: coverage (hardness falloff × dab alpha) over its bounding rectangle. @@ -266,6 +277,174 @@ pub fn apply_dab_stroke( bounds } +/// [`apply_dab_stroke`] wrapped around `wrap` (View › Pattern Preview): each dab reads and edits +/// the pixels where it repeats on that tile, so a stroke crossing an edge carries on at the +/// opposite one. The dabs run one by one on a [`WorkTiles`] working copy, as live previews run +/// them. `None` (or an empty tile) is [`apply_dab_stroke`]. Returns the damaged rectangle. +pub fn apply_dab_stroke_wrapped( + target: &mut Surface, + stroke: &Stroke, + selection: Option<&Surface>, + lock_transparency: bool, + halo: i32, + wrap: Option, + mut effect: impl FnMut(&mut Region, &Footprint), +) -> Rect { + let Some(tile) = wrap.filter(|t| !t.is_empty()) else { + return apply_dab_stroke(target, stroke, selection, lock_transparency, halo, effect); + }; + let ctx = crate::BrushContext::new(&stroke.brush); + let mut work = WorkTiles::new(target.channels()).wrapped(Some(tile)); + let mut damage = Rect::EMPTY; + for (i, d) in dabs(stroke).iter().enumerate() { + let fp = ctx.footprint(d, i); + if fp.rect.is_empty() { + continue; + } + let mut region = work.read(target, fp.rect.inflate(halo)); + effect(&mut region, &fp); + work.write_dab(target, ®ion, &fp); + damage = damage.union(&fold_area(fp.rect, tile)); + } + let written: Vec = work.areas().map(|r| r.intersect(&tile)).filter(|r| !r.is_empty()).collect(); + for r in written { + let edited = work.read(target, r); + mix_back(target, &Region::read(target, r), &edited, r, stroke.brush.opacity, selection, lock_transparency); + } + damage +} + +/// Side of the tiles a [`WorkTiles`] working copy keeps its pixels in. +const WORK_TILE: i32 = 128; + +/// The working copy of a sequential-dab stroke run dab by dab (live previews, +/// [`apply_dab_stroke_wrapped`]): the target's native channels as `f32`, in tiles copied from the +/// pre-stroke surface the first time a dab writes to them. Unlike a `Surface`, it isn't rounded to +/// the document's depth between dabs, so each dab sees what [`apply_dab_stroke`]'s working copy +/// holds. Wrapped around a tile ([`Self::wrapped`]), it keeps each pixel once, where it lies on +/// the tile, and reads and writes it wherever it repeats. +pub struct WorkTiles { + ch: usize, + tiles: HashMap<(i32, i32), Vec>, + wrap: Option, +} + +impl WorkTiles { + pub fn new(ch: usize) -> Self { + Self { ch, tiles: HashMap::new(), wrap: None } + } + + /// Keep the pixels of a stroke wrapped around `tile` (View › Pattern Preview). `None` or an + /// empty tile doesn't wrap. + pub fn wrapped(mut self, tile: Option) -> Self { + self.wrap = tile.filter(|t| !t.is_empty()); + self + } + + fn tile_rect((tx, ty): (i32, i32)) -> Rect { + Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) + } + + /// The tiles `r` overlaps. + fn coords(r: Rect) -> impl Iterator { + let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); + let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); + let empty = r.is_empty(); + (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) + } + + /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. + /// Wrapped, `r` may cross the tile's edges or be bigger than it. + pub fn read(&self, pre: &Surface, r: Rect) -> Region { + match self.wrap { + Some(tile) => Region { rect: r, ch: self.ch, data: crate::wrap::read(r, tile, self.ch, |part| self.read_at(pre, part).data) }, + None => self.read_at(pre, r), + } + } + + fn read_at(&self, pre: &Surface, r: Rect) -> Region { + let mut out = Region::new(r, self.ch); + let n = self.ch; + let mut buf = Vec::new(); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + let tile = self.tiles.get(&c); + if tile.is_none() { + pre.read_region_into(part, &mut buf); + } + for y in part.y0..part.y1 { + let src = match tile { + Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), + None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), + }; + let dst = out.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + out + } + + /// Store a dab's edit: the pixels of `region` (which covers the dab) over the dab's rectangle + /// `fp.rect`. Wrapped, they land where they lie on the tile; a dab bigger than the tile covers + /// some pixels more than once, and each keeps the copy the dab covers most. + pub fn write_dab(&mut self, pre: &Surface, region: &Region, fp: &Footprint) { + let r = fp.rect.intersect(®ion.rect); + let Some(tile) = self.wrap else { + return self.write_at(pre, ®ion.crop(r)); + }; + if fits(r, tile) { + for (part, (dx, dy)) in crate::wrap::pieces(r, tile) { + let mut piece = region.crop(part.translate(dx, dy)); + piece.rect = part; + self.write_at(pre, &piece); + } + return; + } + let area = fold_area(r, tile); + let mut folded = self.read_at(pre, area); + let mut most = vec![0.0f32; area.width() as usize * area.height() as usize]; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (c, tx, ty) = (fp.at(x, y), wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); + if let Some(m) = most.get_mut(folded.index(tx, ty)) + && c > *m + { + *m = c; + folded.px_mut(tx, ty).copy_from_slice(region.px(x, y)); + } + } + } + self.write_at(pre, &folded); + } + + /// Store `region` as the working pixels of its rectangle. + fn write_at(&mut self, pre: &Surface, region: &Region) { + let (r, n) = (region.rect, self.ch); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + for y in part.y0..part.y1 { + let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; + let src = region.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + } + + /// The rectangles of the tiles dabs have written to. + pub fn areas(&self) -> impl Iterator + '_ { + self.tiles.keys().map(|&c| Self::tile_rect(c)) + } +} + /// Mix a sequential-dab stroke's working pixels `work` back over the original `orig` at /// `opacity × selection` inside `rect`, and write that area to `target`: the last step of /// [`apply_dab_stroke`], shared with live previews that run the dabs as they come. @@ -637,4 +816,89 @@ mod tests { } } } + + /// The canvas the Pattern Preview strokes below wrap around. + const TILE: Rect = Rect::new(0, 0, 40, 30); + + /// `area` of a surface showing the canvas repeated: every pixel of the canvas has its own colour. + fn tiled(fmt: PixelFormat, area: Rect) -> Surface { + let mut s = Surface::new(fmt); + let px = (area.y0..area.y1).flat_map(|y| (area.x0..area.x1).map(move |x| (wrap_coord(x, 0, 40), wrap_coord(y, 0, 30)))); + let data: Vec = px.flat_map(|(x, y)| [x as f32 / 40.0, y as f32 / 30.0, ((x * 7 + y * 3) % 13) as f32 / 13.0, 1.0]).collect(); + s.write_region(area, &data); + s + } + + fn smudge(size: f32) -> impl FnMut(&mut Region, &Footprint) { + let mut sm = Smudge::new(0.8, None, size).with_alpha(Some(3)); + move |work, fp| sm.dab(work, fp) + } + + #[test] + fn a_wrapped_sequential_stroke_paints_the_canvas_as_its_tiled_copies_would() { + // View › Pattern Preview: a smudge across a corner of the canvas copy it is drawn on gives + // the canvas exactly what it gives the canvas tiled three by three, where it runs into the + // next copies and carries their colours along. (The same points both times: moved points + // would place the dabs a rounding apart.) + let fmt = PixelFormat::RGBA32F; + let far = stroke(&[(68.0, 48.0), (92.0, 70.0)], 12.0, 0.5); + let halo = Smudge::sampling_radius(12.0); + let mut canvas = tiled(fmt, TILE); + let dmg = apply_dab_stroke_wrapped(&mut canvas, &far, None, false, halo, Some(TILE), smudge(12.0)); + let three = Rect::new(0, 0, 120, 90); + let (before, mut big) = (tiled(fmt, three), tiled(fmt, three)); + apply_dab_stroke(&mut big, &far, None, false, halo, smudge(12.0)); + let mut changed = 0; + for y in 0..30 { + for x in 0..40 { + let copies = [(40, 30), (80, 30), (40, 60), (80, 60)].map(|(dx, dy)| (x + dx, y + dy)); + let moved: Vec<_> = copies.into_iter().filter(|&(u, v)| big.pixel(u, v) != before.pixel(u, v)).collect(); + let want = match moved.as_slice() { + [] => before.pixel(x, y), + [(u, v)] => big.pixel(*u, *v), + _ => panic!("({x}, {y}): two copies under the stroke"), + }; + assert_eq!(canvas.pixel(x, y), want, "({x}, {y})"); + changed += usize::from(!moved.is_empty()); + } + } + assert!(changed > 300, "the smudge crossed both edges ({changed} px)"); + assert!(dmg.x0 >= 0 && dmg.y0 >= 0 && dmg.x1 <= 40 && dmg.y1 <= 30, "{dmg:?}"); + assert!((-12..0).all(|x| canvas.pixel(x, 20)[3] == 0.0) && (40..52).all(|x| canvas.pixel(x, 20)[3] == 0.0), "nothing off the canvas"); + } + + #[test] + fn a_wrapped_sequential_stroke_inside_the_canvas_paints_as_before() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { + let st = stroke(&[(16.0, 12.0), (24.0, 18.0)], 6.0, 0.8); + let run = |wrap| { + let mut s = tiled(fmt, TILE); + let dmg = apply_dab_stroke_wrapped(&mut s, &st, None, false, Smudge::sampling_radius(6.0), wrap, smudge(6.0)); + (s, dmg) + }; + let ((plain, d0), (wrapped, d1)) = (run(None), run(Some(TILE))); + assert_eq!(d0, d1, "{fmt:?}"); + assert!((0..30).all(|y| (0..40).all(|x| plain.pixel(x, y) == wrapped.pixel(x, y))), "{fmt:?}"); + assert!(plain.pixel(20, 15) != tiled(fmt, TILE).pixel(20, 15), "{fmt:?}: the stroke smudged"); + } + } + + #[test] + fn a_wrapped_dab_bigger_than_the_canvas_edits_all_of_it_and_nothing_else() { + let small = Rect::new(0, 0, 10, 8); + let mut s = Surface::new(PixelFormat::RGBA32F); + s.fill_rect(small, &[1.0, 0.0, 0.0, 1.0]); + let darken = |work: &mut Region, fp: &Footprint| { + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + work.px_mut(x, y)[0] *= 1.0 - 0.5 * fp.at(x, y); + } + } + }; + let dmg = apply_dab_stroke_wrapped(&mut s, &stroke(&[(5.0, 4.0), (9.0, 6.0)], 30.0, 0.0), None, false, 0, Some(small), darken); + assert_eq!(dmg, small); + assert!((0..8).all(|y| (0..10).all(|x| s.pixel(x, y)[0] < 1.0)), "every pixel under the dab's copies darkens"); + assert!((-5..15).all(|x| s.pixel(x, -1)[3] == 0.0 && s.pixel(x, 8)[3] == 0.0), "nothing off the canvas"); + } } diff --git a/crates/paint/src/wrap.rs b/crates/paint/src/wrap.rs new file mode 100644 index 0000000000..efad49d3ca --- /dev/null +++ b/crates/paint/src/wrap.rs @@ -0,0 +1,166 @@ +//! View › Pattern Preview geometry: the document repeats its canvas (the *tile*) in every +//! direction, so a stroke past one edge carries on at the opposite one. Coordinates outside the +//! tile name the pixel a whole number of tile sizes away that lies on it. + +use photocraft_geom::Rect; + +/// `v` moved a whole number of periods into `lo..hi` (`v` itself when that is empty). +#[inline] +pub fn wrap_coord(v: i32, lo: i32, hi: i32) -> i32 { + let period = i64::from(hi) - i64::from(lo); + if period <= 0 { + return v; + } + (i64::from(lo) + (i64::from(v) - i64::from(lo)).rem_euclid(period)) as i32 +} + +/// Whether `rect` fits in `tile`, so it covers no pixel of the tile twice. +pub fn fits(rect: Rect, tile: Rect) -> bool { + rect.width() <= tile.width() && rect.height() <= tile.height() +} + +/// The part of `tile` that `rect` covers once moved onto it: per axis, the rectangle's span moved +/// a whole number of periods, or the whole tile where the span is longer or crosses an edge. +pub fn fold_area(rect: Rect, tile: Rect) -> Rect { + if rect.is_empty() || tile.is_empty() { + return Rect::EMPTY; + } + let span = |a0: i32, a1: i32, lo: i32, hi: i32| { + let start = wrap_coord(a0, lo, hi); + let len = i64::from(a1) - i64::from(a0); + if i64::from(start) + len > i64::from(hi) { (lo, hi) } else { (start, start + len as i32) } + }; + let (x0, x1) = span(rect.x0, rect.x1, tile.x0, tile.x1); + let (y0, y1) = span(rect.y0, rect.y1, tile.y0, tile.y1); + Rect::new(x0, y0, x1, y1) +} + +/// One axis of `a0..a1` cut where it crosses the edges of `lo..hi`: each run's start on the tile, +/// its start in `a0..a1`, and its length. +fn runs(a0: i32, a1: i32, lo: i32, hi: i32) -> Vec<(i32, i32, i32)> { + let period = i64::from(hi) - i64::from(lo); + let mut out = Vec::new(); + let mut a = i64::from(a0); + while a < i64::from(a1) { + let k = (a - i64::from(lo)).div_euclid(period) * period; + let end = (i64::from(a1) - k).min(i64::from(hi)); + let start = a - k; + out.push((start as i32, a as i32, (end - start) as i32)); + a = end + k; + } + out +} + +/// `r` cut where it crosses the edges of `tile`: each part moved onto the tile, with the offset +/// that moves it back (`part.translate(dx, dy)` is that part of `r`). A rectangle bigger than the +/// tile gives parts that overlap on it. +pub fn pieces(r: Rect, tile: Rect) -> Vec<(Rect, (i32, i32))> { + if r.is_empty() || tile.is_empty() { + return Vec::new(); + } + let xs = runs(r.x0, r.x1, tile.x0, tile.x1); + runs(r.y0, r.y1, tile.y0, tile.y1) + .into_iter() + .flat_map(|(ty, uy, h)| xs.iter().map(move |&(tx, ux, w)| (Rect::new(tx, ty, tx + w, ty + h), (ux - tx, uy - ty)))) + .collect() +} + +/// The values over `r` of an image repeating `tile` apart, given `read`, which returns a +/// rectangle of the tile (`n` values per pixel, row-major). A rectangle crossing at most one edge +/// per axis is read in its parts; a bigger one reads the tile area it covers once. +pub fn read(r: Rect, tile: Rect, n: usize, mut read: impl FnMut(Rect) -> Vec) -> Vec { + let w = r.width() as usize; + let mut out = vec![T::default(); w * r.height() as usize * n]; + if r.is_empty() || tile.is_empty() { + return out; + } + let (xs, ys) = (runs(r.x0, r.x1, tile.x0, tile.x1), runs(r.y0, r.y1, tile.y0, tile.y1)); + let mut copy = |src: &[T], at: usize, x: i32, y: i32, len: i32| { + let o = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; + let len = len as usize * n; + if let (Some(d), Some(s)) = (out.get_mut(o..o + len), src.get(at..at + len)) { + d.copy_from_slice(s); + } + }; + if xs.len() * ys.len() <= 4 { + for &(ty, uy, h) in &ys { + for &(tx, ux, len) in &xs { + let part = read(Rect::new(tx, ty, tx + len, ty + h)); + for row in 0..h { + copy(&part, row as usize * len as usize * n, ux, uy + row, len); + } + } + } + return out; + } + let area = fold_area(r, tile); + let src = read(area); + let aw = area.width() as usize; + for &(ty, uy, h) in &ys { + for row in 0..h { + for &(tx, ux, len) in &xs { + copy(&src, ((ty + row - area.y0) as usize * aw + (tx - area.x0) as usize) * n, ux, uy + row, len); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinates_wrap_onto_the_tile_from_any_distance() { + assert_eq!([-17, -1, 0, 15, 16, 33, 1_000_000].map(|v| wrap_coord(v, 0, 16)), [15, 15, 0, 15, 0, 1, 0]); + assert_eq!(wrap_coord(-3, 10, 20), 17); + } + + #[test] + fn pieces_cover_the_rectangle_once_and_land_on_the_tile() { + let tile = Rect::new(0, 0, 10, 8); + for r in [Rect::new(7, 6, 13, 11), Rect::new(-25, 3, 4, 5), Rect::new(2, 2, 5, 5), Rect::new(-3, -3, 30, 20)] { + let mut seen = std::collections::HashMap::new(); + for (part, (dx, dy)) in pieces(r, tile) { + assert!(tile.contains(part.x0, part.y0) && part.x1 <= tile.x1 && part.y1 <= tile.y1, "{part:?} on the tile"); + for y in part.y0..part.y1 { + for x in part.x0..part.x1 { + *seen.entry((x + dx, y + dy)).or_insert(0) += 1; + assert_eq!((wrap_coord(x + dx, 0, 10), wrap_coord(y + dy, 0, 8)), (x, y)); + } + } + } + assert_eq!(seen.len(), (r.width() * r.height()) as usize, "{r:?}"); + assert!(seen.values().all(|&c| c == 1) && seen.keys().all(|&(x, y)| r.contains(x, y)), "{r:?}"); + } + } + + #[test] + fn a_read_repeats_the_tile_whatever_the_rectangle() { + let tile = Rect::new(4, 2, 14, 10); + let value = |x: i32, y: i32| (wrap_coord(x, 4, 14) * 100 + wrap_coord(y, 2, 10)) as f32; + let mut reads = 0; + // Two values per pixel; `read` only ever asks for rectangles of the tile. + let mut from_tile = |q: Rect| { + reads += 1; + assert!(q.x0 >= tile.x0 && q.y0 >= tile.y0 && q.x1 <= tile.x1 && q.y1 <= tile.y1, "{q:?}"); + (q.y0..q.y1).flat_map(|y| (q.x0..q.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect::>() + }; + for r in [Rect::new(10, 7, 17, 12), Rect::new(5, 3, 9, 6), Rect::new(-31, -5, 40, 30), Rect::new(0, 0, 0, 5)] { + let got = read(r, tile, 2, &mut from_tile); + let want: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect(); + assert_eq!(got, want, "{r:?}"); + } + assert!(reads <= 4 + 1 + 1, "small rectangles read in parts, big ones once ({reads})"); + } + + #[test] + fn the_fold_area_is_where_a_rectangle_lands() { + let tile = Rect::new(0, 0, 10, 8); + assert_eq!(fold_area(Rect::new(12, 9, 15, 12), tile), Rect::new(2, 1, 5, 4)); + assert_eq!(fold_area(Rect::new(8, 2, 12, 4), tile), Rect::new(0, 2, 10, 4)); + assert_eq!(fold_area(Rect::new(-50, -50, 50, 50), tile), tile); + assert_eq!(fold_area(Rect::EMPTY, tile), Rect::EMPTY); + assert!(fits(Rect::new(3, 3, 13, 11), tile) && !fits(Rect::new(0, 0, 11, 2), tile)); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7..83614c9244 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -700,6 +700,13 @@ fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec]) -> serde_js p } +/// View › Pattern Preview: a painting stroke goes on across the canvas edges (#8883). +pub(crate) fn pattern_wrap(app: &PhotocraftApp, p: &mut serde_json::Value) { + if app.ui.view.pattern_preview { + p["wrap"] = json!(true); + } +} + /// Tools whose strokes the engine renders while they are drawn (`LiveStroke`). pub(crate) fn strokes_live(tool: Tool) -> bool { matches!( @@ -881,6 +888,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { json!({}) } else { crate::retouch_ui::clone_params(app)? }; + pattern_wrap(app, &mut p); p["points"] = json!(d.points); p["target"] = paint_target(app); EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index f92c3427e9..c13dca17f6 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -49,14 +49,16 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - /// the commit, with the jitter seed from the first point (`retouch_ui::stroke_seed`). pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { let o = &app.ui.tool_options; - json!({ + let mut p = json!({ "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, "tolerance": o.bg_tolerance, "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), - }) + }); + crate::canvas::pattern_wrap(app, &mut p); + p } /// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 5ed59869de..e62371bef5 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -32,6 +32,8 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); + } else { + crate::canvas::pattern_wrap(app, &mut p); } // The Clone Source panel's active slot (set by ⌥-click) drives the stroke: the engine keeps // the aligned pairing and applies the slot's scale/rotation/flip. @@ -49,7 +51,7 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { /// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit. pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; - Some(match tool { + let (cmd, mut p) = match tool { Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), @@ -59,7 +61,9 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st ), Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), _ => return None, - }) + }; + crate::canvas::pattern_wrap(app, &mut p); + Some((cmd, p)) } /// `paint.colorReplacement` params from the options bar (without `points`): shared by the live @@ -67,7 +71,7 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st /// stroke paints the same colour; Background Swatch sampling matches the background colour. pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { let o = &app.ui.tool_options; - json!({ + let mut p = json!({ "color": app.session.tools.foreground, "mode": o.cr_mode, "sampling": o.cr_sampling, @@ -76,7 +80,9 @@ pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { "antiAlias": o.cr_anti_alias, // A mask or channel target is refused: the tool recolours layer pixels only. "target": crate::canvas::paint_target(app), - }) + }); + crate::canvas::pattern_wrap(app, &mut p); + p } /// `paint.patternStamp` params from the options bar and the Patterns panel (without `points` and @@ -99,6 +105,7 @@ pub(crate) fn pattern_stamp_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { p["pattern"] = json!(id); } + crate::canvas::pattern_wrap(app, &mut p); p } @@ -158,6 +165,10 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m _ => return false, }; p["points"] = pts; + // Spot Healing, Mixer Brush and History Brush; the others' params have it already. + if tool != Tool::Remove { + crate::canvas::pattern_wrap(app, &mut p); + } // The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints. p["target"] = crate::canvas::paint_target(app); let result = app.run(cmd, p); @@ -730,6 +741,43 @@ mod tests { } } + #[test] + fn every_retouching_stroke_wraps_in_pattern_preview() { + // View › Pattern Preview (#8883): a stroke across the right edge asks its command to go + // on at the opposite one, and only then. + let tools = [ + (Tool::Blur, "paint.blur"), + (Tool::Sharpen, "paint.sharpen"), + (Tool::Smudge, "paint.smudge"), + (Tool::Dodge, "paint.dodge"), + (Tool::Burn, "paint.burn"), + (Tool::Sponge, "paint.sponge"), + (Tool::MixerBrush, "paint.mixerBrush"), + (Tool::ColorReplacement, "paint.colorReplacement"), + (Tool::PatternStamp, "paint.patternStamp"), + (Tool::Healing, "paint.healingBrush"), + (Tool::HistoryBrush, "paint.historyBrush"), + (Tool::SpotHealing, "paint.spotHealing"), + (Tool::BackgroundEraser, "paint.backgroundEraser"), + ]; + for (tool, cmd) in tools { + for pattern_preview in [false, true] { + let mut app = app(); + stripes(&mut app, 8, "pixels"); + app.ui.view.pattern_preview = pattern_preview; + app.ui.clone_source = Some([20.0, 30.0]); + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 86.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 112.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().unwrap_or_else(|| panic!("{tool:?}: no {cmd}")); + assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), pattern_preview, "{tool:?}: {p}"); + } + } + } + #[test] fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { for pattern_preview in [false, true] { diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index ac88a6c899..cd50206d7d 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -500,6 +500,60 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { } } +/// View › Pattern Preview (#8883): a Blur, Smudge or Dodge stroke dragged past the right edge +/// carries on at the left one, and the release still commits the pixels its live stroke drew. +#[test] +fn a_sequential_dab_stroke_past_the_edge_in_pattern_preview_commits_its_live_pixels() { + for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] { + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}})); + run("paint.stroke", json!({"points": [[0, 50], [200, 50]], "size": 30, "hardness": 0.5, "color": "#c04020"})); + run("paint.stroke", json!({"points": [[0, 35], [200, 65]], "size": 8, "hardness": 1.0, "color": "#2060c0"})); + }; + let mut h = harness(); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + let before = h.state().session.active().unwrap().doc.clone(); + { + let app = h.state_mut(); + app.ui.tool = tool; + app.ui.view.pattern_preview = true; + app.ui.views[0].zoom = 2.0; + app.ui.views[0].center = [100.0, 50.0]; + } + h.run_steps(1); + move_to(&mut h, 150.0, 50.0); + button(&mut h, 150.0, 50.0, true); + for (x, y) in [(175.0, 52.0), (200.0, 50.0), (225.0, 47.0), (245.0, 50.0)] { + move_to(&mut h, x, y); + } + assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke"); + h.state_mut().retouch_from_live = false; + button(&mut h, 245.0, 50.0, false); + h.run_steps(2); + let app = h.state(); + assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again"); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke"); + assert_eq!(p["wrap"], json!(true), "{cmd}: {p}"); + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + fresh.execute(cmd, p).unwrap(); + let st = app.session.active().unwrap(); + let (a, b, pre) = ( + photocraft_compose::render(&st.doc, st.doc.bounds()), + photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()), + photocraft_compose::render(&before, st.doc.bounds()), + ); + assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ"); + let left = |buf: &photocraft_compose::Buffer| (30..70).flat_map(|y| (0..30).map(move |x| (x, y))).map(|(x, y)| buf.px[y * 200 + x]).collect::>(); + assert_ne!(left(&a), left(&pre), "{cmd}: the stroke came back at the left edge"); + } +} + /// Pressure changes Smudge's painted footprint, but not the square of colour it carries. /// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo. #[test] From 6cbcd89f0c63d4afd46554d42647df322a9c670e Mon Sep 17 00:00:00 2001 From: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:50:47 +0000 Subject: [PATCH 05/77] Pen: carry on a shape's or vector mask's open path, and show the continue pointer (#9356) A Pen press on an endpoint of an open path carried it on only for the Work Path or a saved path (#6593). In Shape mode the press started a new shape layer, and on a targeted vector mask a new subpath, so a pen shape could not be continued once drawing stopped. Now the selected shape layer's path (Shape mode) and the targeted vector mask resume like the Work Path, and the commit updates the same layer in one History step (shape.edit / layer.vectorMask.edit). Nothing showed that the endpoints can be clicked, either: the Pen now shows Photoshop's continue-path pointer (the cross with a slash) over an endpoint it would carry on from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NgLQVizswqgNnzBv1taRD --- crates/ui-egui/src/canvas.rs | 9 +++ crates/ui-egui/src/pen_variants_tests.rs | 62 ++++++++++++++++++++ crates/ui-egui/src/tool_cursor.rs | 10 ++++ crates/ui-egui/src/tool_cursor/tests.rs | 13 +++++ crates/ui-egui/src/vector_ui.rs | 72 ++++++++++++++++++------ 5 files changed, 150 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7..b0fd9e5c48 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4835,6 +4835,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { crate::tool_cursor::close_path(&painter, p) } + // Over an endpoint of an open path, the continue-path pointer (#9356). + Tool::Pen + if { + let [x, y] = xf.to_doc(p); + crate::vector_ui::continues_path_at(app, tool, x, y) + } => + { + crate::tool_cursor::continue_path(&painter, p) + } // The pen tools: their own icon with its nib on the point, or the crosshair for // precise Other Cursors (#4639). Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => { diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index cee8e37bbd..54037c40fa 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -273,3 +273,65 @@ fn undo_in_a_resumed_draft_stops_at_the_paths_own_anchors() { assert!(app.ui.direct_selection.anchors.is_empty()); assert_eq!(work_path(&app), before, "the path is as it was"); } + +/// #9356: in Shape mode a Pen press on an endpoint of the selected shape layer's open path carries +/// that path on, from either end, and the commit updates the same layer in one History step +/// instead of making a new shape layer. Elsewhere the press starts a new shape as before. +#[test] +fn shape_mode_carries_on_the_selected_shapes_open_path() { + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len(); + let shape = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Shape(sh) => { + sh.path.subpaths.iter().map(|s| s.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect::>()).collect::>() + } + other => panic!("not a shape layer: {other:?}"), + } + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + let (n, before) = (layers(&app), steps(&app)); + assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 81.0, 21.0), "the continue pointer over the endpoint"); + assert!(!crate::vector_ui::continues_path_at(&app, Tool::Pen, 50.0, 20.0), "not mid-segment"); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n, "no new shape layer"); + assert_eq!(steps(&app), before + 1, "one History step"); + assert_eq!(shape(&app), [vec![[20.0, 20.0], [80.0, 20.0], [80.0, 80.0]]]); + // From the first anchor, reversed. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(shape(&app), [vec![[80.0, 80.0], [80.0, 20.0], [20.0, 20.0], [20.0, 80.0]]]); + // Anywhere else: a new shape layer. + click(&mut app, 150.0, 150.0); + click(&mut app, 200.0, 150.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n + 1); +} + +/// #9356: with a vector mask targeted, the Pen carries on the mask's open subpath in place. +#[test] +fn a_targeted_vector_mask_carries_on_its_open_path() { + let mut app = app(Tool::Pen); + let id = app.session.active().unwrap().active_layer.unwrap(); + let open = json!({"subpaths": [{"closed": false, "knots": [{"anchor": [20.0, 20.0]}, {"anchor": [80.0, 20.0]}]}]}); + app.run("layer.vectorMask.add", json!({"layer": id.0, "path": open})).unwrap(); + app.ui.vector_mask_target = true; + let mask = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + let p = &st.doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path; + p.subpaths.iter().map(|s| s.knots.len()).collect::>() + }; + assert_eq!(mask(&app), [2]); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(mask(&app), [3], "the same subpath, extended"); +} diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 5062c839da..28e4d45f95 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -88,6 +88,16 @@ pub(crate) fn close_path(painter: &Painter, at: Pos2) -> CursorIcon { outline(painter, at, vec![close_ring()], true) } +/// The Pen's pointer over an endpoint a click would carry the path on from (#9356): the centre +/// cross with a short slash below right, as Photoshop's pen shows one there. +pub(crate) fn continue_path(painter: &Painter, at: Pos2) -> CursorIcon { + outline(painter, at, vec![continue_slash()], true) +} + +fn continue_slash() -> Vec { + [(6.5, 12.5), (11.5, 5.5), (12.5, 6.5), (7.5, 13.5)].into_iter().map(|(x, y)| vec2(x, y)).collect() +} + fn close_ring() -> Vec { (0..16).map(|i| (i as f32 / 16.0 * std::f32::consts::TAU, vec2(9.0, 9.0))).map(|(a, c)| c + vec2(a.cos(), a.sin()) * 3.5).collect() } diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index a57e7f0ae2..fa241f6e5c 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -599,3 +599,16 @@ fn type_path_cursors_are_small_and_centred() { } } } + +/// #9356: the continue-path pointer is the centre cross with a slash below right. +#[test] +fn continue_path_bitmap_has_the_centre_cross_and_a_slash_beside_it() { + for scale in [1.0, 2.0] { + let image = rasterize(&Shape::Outline { rings: vec![continue_slash()].into(), centre: true }, scale).unwrap(); + let [x, y] = image.hotspot; + let at = |dx: f32, dy: f32| alpha(&image, x + (dx * scale) as u16, y + (dy * scale) as u16); + assert!(at(0.0, 0.0) > 230, "the centre cross"); + assert!(at(9.5, 9.5) > 0, "the slash"); + assert_eq!(at(0.0, 9.0), 0, "apart from the cross"); + } +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 81426ccb5e..ce63a26dca 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -52,6 +52,10 @@ pub struct Resumed { pub path: String, pub index: usize, pub was: photocraft_doc::vector::Subpath, + /// The layer whose shape path (Shape mode) or targeted vector mask the subpath is in (#9356); + /// `None` for the Paths panel's path `path`. + #[serde(default)] + pub layer: Option, } impl PenPath { @@ -237,6 +241,12 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { } } +/// Would a Pen click at `(x, y)` carry on an open path from its endpoint there (#6593)? The canvas +/// shows Photoshop's continue-path pointer there (#9356), as the endpoints look like any anchor. +pub fn continues_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { + tool == Tool::Pen && app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) && resume_at(app, x, y, 6.0 / app.point_zoom().max(0.01) as f64).is_some() +} + /// Pen press: close on the first anchor, reshape the last anchor's outgoing handle, /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. @@ -546,7 +556,12 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { return; } let path = pen_to_json(pen, closed); - let r = if app.ui.tool_options.vector_mode == "shape" { + let resumed = pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r, p))); + let r = if let Some((Resumed { layer: Some(id), .. }, p)) = &resumed { + // A shape layer's path or a vector mask carried on (#9356): the same layer, one step. + let shape = app.session.active().and_then(|st| st.doc.layer(photocraft_doc::LayerId(*id))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + app.run(if shape { "shape.edit" } else { "layer.vectorMask.edit" }, json!({"layer": id, "path": p})) + } else if app.ui.tool_options.vector_mode == "shape" { let fill = if closed { fill_param(app) } else { Value::Null }; let stroke = if closed { stroke_param(app) @@ -557,10 +572,9 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { stroke }; app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke})) - } else if let Some((name, p)) = - pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r.path.clone(), p))) - { + } else if let Some((r, p)) = resumed { // The resumed subpath, carried on, takes its old place: one step, no second subpath. + let name = r.path.clone(); app.run("path.set", json!({"name": name, "path": p})).inspect(|_| app.ui.selected_path = Some(name)) } else if let Some((id, existing)) = targeted_vector_mask(app) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. @@ -576,7 +590,8 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // A resumed draft whose subpath changed meanwhile joins the path it came from, as a new // subpath, even with nothing selected in the Paths panel. let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); - let resumed_from = pen.resumes.as_ref().filter(|r| app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); + let resumed_from = + pen.resumes.as_ref().filter(|r| r.layer.is_none() && app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); let target = resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists)); let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); @@ -601,19 +616,44 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { if name == "work" { doc.work_path.as_ref() } else { doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) } } -/// The open subpath of the path a new Pen path would join (the one selected in the Paths panel, -/// else the work path) that has its first or last anchor at `(x, y)`, as a Pen path to carry on -/// drawing from there (#6593). Its knots run towards that anchor: reversed, handles swapped, when -/// it is the first. Shape mode and a targeted vector mask take new paths, so they resume nothing. -fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { - if app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some() { - return None; +/// The path of `layer` that the Pen draws into: a shape layer's own path, else its vector mask. +fn layer_path(doc: &Document, layer: u64) -> Option<&Path> { + let l = doc.layer(photocraft_doc::LayerId(layer))?; + match &l.content { + LayerContent::Shape(sh) => Some(&sh.path), + _ => l.vector_mask.as_ref().map(|m| &m.path), } +} + +/// The path a resumed subpath is in, if it is still in `r`'s document. +fn resumed_path<'a>(doc: &'a Document, r: &Resumed) -> Option<&'a Path> { + (doc.id.0 == r.doc).then_some(())?; + match r.layer { + Some(id) => layer_path(doc, id), + None => doc_path(doc, &r.path), + } +} + +/// The open subpath of the path a new Pen path would go into that has its first or last anchor at +/// `(x, y)`, as a Pen path to carry on drawing from there (#6593). That path is the selected shape +/// layer's in Shape mode, the targeted vector mask's (#9356), else the one selected in the Paths +/// panel or the work path. Its knots run towards that anchor: reversed, handles swapped, when it is +/// the first. +fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { let st = app.session.active()?; let doc = st.doc.id.0; - let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let (name, layer, path) = if app.ui.tool_options.vector_mode == "shape" { + let (id, _) = active_shape_path(app)?; + ("layer".to_string(), Some(id), layer_path(&st.doc, id)?) + } else if let Some((id, _)) = targeted_vector_mask(app) { + ("layer".to_string(), Some(id), layer_path(&st.doc, id)?) + } else { + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let path = doc_path(&st.doc, &name)?; + (name, None, path) + }; let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; - doc_path(&st.doc, &name)?.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { + path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { let reversed = if s.knots.last().is_some_and(near) { false } else if s.knots.first().is_some_and(near) { @@ -629,7 +669,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { } } let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); - let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone() }; + let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone(), layer }; Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) }) } @@ -638,7 +678,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { /// anchors and closing, which keeps its other settings (its path operation). `None` when the /// subpath is no longer the one drawing resumed (another document, or the path changed). fn with_resumed_subpath(doc: &Document, r: &Resumed, drawn: &Value) -> Option { - let path = doc_path(doc, &r.path).filter(|_| doc.id.0 == r.doc)?; + let path = resumed_path(doc, r)?; if path.subpaths.get(r.index) != Some(&r.was) { return None; } From 26c218f722746e174b8fabe8e0eea4dfce6a64f0 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 11:36:15 -0200 Subject: [PATCH 06/77] retouch: wrapped Dodge and Burn tone each pixel once; Spot Healing skips a window with nothing to heal from Dodge and Burn remember each pixel's coverage so one stroke tones it once. With wrap on, that memory was keyed by the unwrapped position. A stroke longer than the canvas came back to a pixel under a new key and toned it again from its toned value: mid grey went to 0.816 instead of 0.651 under Dodge at 60%. Coverage is now kept per canvas pixel. A dab bigger than the canvas is folded first, and each pixel takes the most coverage any of its copies gets (the copy WorkTiles keeps). Spot Healing returns early when the stroke covers its whole sampling window, as a wrapped dab bigger than the canvas does. The fill had nothing to sample from. It left the pixels alone but reported the whole canvas as damage. Wrap off, Dodge in bench_live_dab_release (6000x4000, 8-bit, medians of 4 interleaved runs) stays within 0.8% of before at every brush size. --- crates/engine/src/retouch_cmds.rs | 75 +++++++++++++++++++------ crates/engine/src/retouch_cmds/tests.rs | 45 +++++++++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 4480f9b8b8..b2eb58059f 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -16,7 +16,7 @@ use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; -use photocraft_paint::wrap::wrap_coord; +use photocraft_paint::wrap::{fits, fold_area, pieces, wrap_coord}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -875,7 +875,8 @@ fn spot_heal_surface( } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); - if !hole.iter().any(|h| *h) { + // Nothing to heal, or (a wrapped dab covering its whole window) nothing to heal from. + if !hole.iter().any(|h| *h) || hole.iter().all(|h| *h) { return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels @@ -971,8 +972,9 @@ fn color_dab(fmt: &PixelFormat, work: &mut Region, fp: &Footprint, strength: f32 /// stroke's coverage there (`toned` is the full-exposure colour). Dabs build up coverage as in a /// brush stroke, capped at full coverage, so Exposure caps a stroke the way Opacity caps a paint /// stroke: overlapping dabs and passes within one stroke don't compound the tone curve, and a -/// new stroke builds on the last. -fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { +/// new stroke builds on the last. Wrapped around `wrap` (View › Pattern Preview), the coverage is +/// kept per canvas pixel, so a stroke coming back to a pixel one canvas away tones it once. +fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { /// Side of the cells that remember, per touched pixel, the coverage applied so far and the /// original pixel (copied from the working pixels the first time a dab reaches it). Dense /// cells rather than a map per pixel: a map lookup per covered pixel made long strokes with @@ -985,11 +987,8 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let mut cells: std::collections::HashMap<(i32, i32), Cell> = std::collections::HashMap::new(); let a = alpha_index(&fmt); let n = fmt.channels(); - Box::new(move |work, fp| { - let r = fp.rect.intersect(&work.rect); - if r.is_empty() { - return; - } + // Tone the pixels of `px` at `r` moved by `(dx, dy)`, remembered at `r`. + let mut tone = move |px: &mut Region, fp: &Footprint, r: Rect, (dx, dy): (i32, i32)| { let mut enc = [0.0f32; 8]; for cy in r.y0.div_euclid(CELL)..=(r.y1 - 1).div_euclid(CELL) { for cx in r.x0.div_euclid(CELL)..=(r.x1 - 1).div_euclid(CELL) { @@ -1003,7 +1002,7 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let cell = cells.entry((cx, cy)).or_insert_with(|| Cell { cov: vec![-1.0; len], orig: vec![0.0; len * n] }); for y in part.y0..part.y1 { for x in part.x0..part.x1 { - let k = fp.at(x, y).clamp(0.0, 1.0); + let k = fp.at(x + dx, y + dy).clamp(0.0, 1.0); if k <= 0.0 { continue; } @@ -1011,7 +1010,7 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let (Some(prev), Some(src)) = (cell.cov.get_mut(i), cell.orig.get_mut(i * n..i * n + n)) else { continue }; if *prev < 0.0 { // First touch: the working pixel is still the original. - src.copy_from_slice(work.px(x, y)); + src.copy_from_slice(px.px(x + dx, y + dy)); *prev = 0.0; } // Dabs build up the stroke's coverage as a brush stroke's do (flow), up @@ -1025,16 +1024,55 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let t = toned([o[0], o[1], o[2]]); let m = [o[0] + (t[0] - o[0]) * k, o[1] + (t[1] - o[1]) * k, o[2] + (t[2] - o[2]) * k]; from_rgba_into(&fmt, [m[0], m[1], m[2], o[3]], &mut enc); - let px = work.px_mut(x, y); + let out = px.px_mut(x + dx, y + dy); for c in 0..n { if Some(c) != a { - px[c] = enc[c]; + out[c] = enc[c]; } } } } } } + }; + Box::new(move |work, fp| { + let r = fp.rect.intersect(&work.rect); + if r.is_empty() { + return; + } + match wrap { + None => tone(work, fp, r, (0, 0)), + Some(tile) if fits(r, tile) => { + for (part, off) in pieces(r, tile) { + tone(work, fp, part, off); + } + } + // A dab bigger than the canvas covers its pixels more than once: tone each at the + // most coverage any copy gets (the copy `WorkTiles` keeps), and give every copy that. + Some(tile) => { + let area = fold_area(r, tile); + let mut on = Region::new(area, work.ch); + let mut most = Footprint { dab: fp.dab, index: fp.index, rect: area, cov: vec![0.0; area.width() as usize * area.height() as usize] }; + let at = |x, y| (wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (tx, ty) = at(x, y); + let i = on.index(tx, ty); + on.px_mut(tx, ty).copy_from_slice(work.px(x, y)); + if let Some(m) = most.cov.get_mut(i) { + *m = m.max(fp.at(x, y)); + } + } + } + tone(&mut on, &most, area, (0, 0)); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (tx, ty) = at(x, y); + work.px_mut(x, y).copy_from_slice(on.px(tx, ty)); + } + } + } + } }) } @@ -1140,11 +1178,12 @@ impl DabTool { } } - /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. - fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { + /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`, on a stroke + /// wrapped around `wrap`. + fn effect(&self, fmt: PixelFormat, spacing: f32, wrap: Option) -> DabEffect { let sp = spacing; match self.clone() { - Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), + Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, wrap, move |c| dodge_burn(c, exposure, range, burn, protect)), // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))), Self::Focus { sharpen, strength, protect, sigma } => { @@ -1206,8 +1245,8 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(l.bounds); } let fmt = surf.format(); - let mut effect = tool.effect(fmt, stroke.brush.spacing); let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); + let mut effect = tool.effect(fmt, stroke.brush.spacing, wrap); let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), @@ -1274,7 +1313,7 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, _| effect.effect(pre.format(), spacing))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, wrap| effect.effect(pre.format(), spacing, wrap))?; live.tool = Some((tool, brush)); Ok(live) } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e88129cb67..cbe535097b 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -2603,6 +2603,51 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { } } +#[test] +fn spot_healing_with_nothing_left_to_sample_leaves_the_layer_alone() { + // Wrapped, the dab covers all of a canvas smaller than it, so its window has no pixel left + // to heal from. + for depth in DEPTHS { + for kind in ["contentAware", "createTexture", "proximityMatch"] { + let mut s = session(48, 36, depth, "rgb"); + textured(&mut s); + let before = layer_pixels(&s); + let r = s.execute("paint.spotHealing", json!({"points": [[48, 36]], "size": 64, "hardness": 100, "type": kind, "wrap": true})).unwrap(); + assert_eq!(layer_pixels(&s), before, "{kind} at {depth}-bit"); + assert_eq!(r["damage"][2].as_i64().unwrap() * r["damage"][3].as_i64().unwrap(), 0, "{kind} at {depth}-bit: no damage, got {}", r["damage"]); + } + } +} + +#[test] +fn a_wrapped_dodge_or_burn_stroke_tones_each_pixel_once() { + // A stroke round the canvas, or a dab bigger than it, comes back to pixels one canvas away; + // the stroke still tones each of them once, as a single dab does. + for depth in DEPTHS { + for cmd in ["paint.dodge", "paint.burn"] { + let run = |w: u32, h: u32, mut p: Value| { + let mut s = session(w, h, depth, "rgb"); + paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]); + p["exposure"] = json!(60); + s.execute(cmd, p).unwrap(); + layer_pixels(&s) + }; + let once = run(WRAP.0, WRAP.1, json!({"points": [[24, 18]], "size": 10, "hardness": 100})); + let n = once.len() / (WRAP.0 * WRAP.1) as usize; + let toned = &once[(18 * WRAP.0 as usize + 24) * n..][..n]; + assert_ne!(toned, &once[..n], "{cmd} at {depth}-bit: the dab tones"); + let round = run(WRAP.0, WRAP.1, json!({"points": [[2, 18], [122, 18]], "size": 10, "hardness": 100, "wrap": true})); + for x in 0..WRAP.0 as usize { + assert_eq!(&round[(18 * WRAP.0 as usize + x) * n..][..n], toned, "{cmd} at {depth}-bit: ({x}, 18)"); + } + let big = run(16, 12, json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 100, "wrap": true})); + for (i, px) in big.chunks(n).enumerate() { + assert_eq!(px, toned, "{cmd} at {depth}-bit: pixel {i} under the big dab"); + } + } + } +} + #[test] fn wrapped_live_strokes_show_what_their_commits_paint() { // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the From 2780df1f30dd0b687c5e9dfd702b2c7da24ca91e Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:47:26 +0100 Subject: [PATCH 07/77] Camera Raw sidecars: apply the crop, stop reporting RawFileName A raw opened with a Lightroom or Camera Raw sidecar reported "3 not supported: CropBottom, CropRight, RawFileName" (#9985): the crop keys fell through to the unsupported list (CropTop/CropLeft were only spared because they were 0), and the raw's file name was treated as a setting. The XMP reader now resolves the crop from the public crs: schema: crs:HasCrop="True" with CropTop/CropLeft/CropBottom/CropRight, fractions of the developed frame in the raw's stored orientation. A missing or False HasCrop means no crop (the keys are stale and are not reported). Fractions are clamped to 0..1; an inverted, empty or unreadable crop, and a straightened one (CropAngle not 0, whose rectangle is measured on the rotated frame), is not applied and its keys are named as not supported. RawFileName is bookkeeping. Camera Raw's settings have no crop stage, so the crop is applied the way the sidecar's other raw-stage settings are: Open re-develops the raw (RawTuning::crop) and crops the upright frame after mapping the fractions through the raw's Orientation, before the Camera Raw step, so the post-crop vignette centres on the crop. This works with the dialog and with Open in Camera Raw off; the dialog previews the whole frame. PhotoCraft's own sidecar keeps the crop when it is rewritten, and Load Settings in the Camera Raw Filter names a crop as not supported. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/camera_raw_xmp.rs | 246 +++++++++++++++++- crates/io/src/raw.rs | 33 ++- crates/io/tests/it/raw.rs | 30 +++ .../dialogs/camera_raw_ui/identity_tests.rs | 1 + .../ui-egui/src/dialogs/camera_raw_ui/open.rs | 26 +- .../src/dialogs/camera_raw_ui/sidecar.rs | 10 +- .../src/dialogs/camera_raw_ui/tests.rs | 44 +++- docs/control-protocol.md | 5 +- docs/file-format-parity.md | 5 +- 9 files changed, 376 insertions(+), 24 deletions(-) diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index 65df97e898..76fd8bb0ca 100644 --- a/crates/io/src/camera_raw_xmp.rs +++ b/crates/io/src/camera_raw_xmp.rs @@ -12,6 +12,13 @@ //! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature //! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no //! counterpart and are ignored. +//! +//! Crop (#9985): `crs:HasCrop="True"` with `crs:CropTop` / `CropLeft` / `CropBottom` / +//! `CropRight`, fractions of the developed frame in the raw's stored orientation (before its +//! Orientation tag turns it upright), is read into [`LoadReport::crop`]; the raw open crops the +//! document with it ([`crate::raw::RawTuning::crop`]). The settings have no crop (Camera Raw +//! Filter has none). Without `HasCrop`, or with `HasCrop="False"`, the crop keys are stale and +//! mean nothing. A straightened crop (`CropAngle` other than 0) isn't applied. use photocraft_algo::camera_raw::{CameraRaw, validate_curve}; @@ -126,7 +133,6 @@ const METADATA: &[&str] = &[ "Version", "ProcessVersion", "HasSettings", - "HasCrop", "WhiteBalance", "ToneCurveName2012", "CameraProfile", @@ -147,16 +153,114 @@ const METADATA: &[&str] = &[ "SortName", "Group", "Description", + // The raw file the sidecar belongs to (Lightroom and Camera Raw name it). + "RawFileName", ]; +/// The crop's keys besides `HasCrop`, and their values without a crop (the whole frame, level). +pub const CROP_KEYS: [&str; 5] = ["CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle"]; +const NO_CROP: [f32; 5] = [0.0, 0.0, 1.0, 1.0, 0.0]; + +/// A Camera Raw crop: fractions of the developed frame in the raw's stored orientation, before +/// its Orientation tag turns it upright, with `0 <= left < right <= 1` and +/// `0 <= top < bottom <= 1`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub top: f32, + pub left: f32, + pub bottom: f32, + pub right: f32, +} + +impl Crop { + /// The crop of the frame turned upright by TIFF orientation `o` (1–8; anything else is 1), + /// as fractions of the upright frame: the corners map as the pixels do. + pub fn oriented(self, o: u16) -> Crop { + let map = |u: f32, v: f32| match o { + 2 => (1.0 - u, v), + 3 => (1.0 - u, 1.0 - v), + 4 => (u, 1.0 - v), + 5 => (v, u), + 6 => (1.0 - v, u), + 7 => (1.0 - v, 1.0 - u), + 8 => (v, 1.0 - u), + _ => (u, v), + }; + let (ax, ay) = map(self.left, self.top); + let (bx, by) = map(self.right, self.bottom); + Crop { top: ay.min(by), left: ax.min(bx), bottom: ay.max(by), right: ax.max(bx) } + } + + /// The crop on a `w × h` frame in pixels, `(x0, y0, x1, y1)` with `x1` and `y1` exclusive: + /// at least one pixel, inside the frame. `None` for an empty frame. + pub fn pixels(self, w: usize, h: usize) -> Option<(usize, usize, usize, usize)> { + let edge = |f: f32, n: usize| ((f64::from(f).clamp(0.0, 1.0) * n as f64).round() as usize).min(n); + let span = |a: f32, b: f32, n: usize| { + let lo = edge(a, n).min(n.checked_sub(1)?); + Some((lo, edge(b, n).max(lo + 1))) + }; + let (x0, x1) = span(self.left, self.right, w)?; + let (y0, y1) = span(self.top, self.bottom, h)?; + Some((x0, y0, x1, y1)) + } +} + /// What [`load`] applied. #[derive(Clone, Debug, Default, PartialEq)] pub struct LoadReport { - /// How many settings were applied. + /// How many settings were applied (a crop counts as one). pub applied: usize, /// `crs:` settings without a PhotoCraft control (set to something other than their neutral - /// value), or with an unusable value, in file order. + /// value), or with an unusable value, in file order; then the keys of a crop that isn't + /// applied (straightened, unreadable or empty). pub ignored: Vec, + /// The file's crop, when it has one that isn't the whole frame. The settings hold no crop, + /// so the caller applies it (a raw open crops the document) or reports it. + pub crop: Option, +} + +/// The crop keys as read, resolved by [`crop`] once the whole file is read. +#[derive(Default)] +struct CropFields { + has: Option, + values: [Option; 5], +} + +/// The file's crop from its keys: applied when `HasCrop` is `True` and the values make a level, +/// non-empty rectangle (fractions are clamped to 0..1); otherwise its keys are reported. +fn crop(fields: &CropFields, report: &mut LoadReport) { + match fields.has.as_deref().map(str::trim) { + // No crop: the other keys are what Camera Raw left from an earlier or reset crop. + None => return, + Some(v) if v.eq_ignore_ascii_case("false") => return, + Some(v) if v.eq_ignore_ascii_case("true") => {} + Some(_) => { + report.ignored.push("HasCrop".into()); + return; + } + } + let mut v = NO_CROP; + let mut usable = true; + for (slot, text) in v.iter_mut().zip(&fields.values) { + if let Some(text) = text { + match number(text) { + Some(n) => *slot = n, + None => usable = false, + } + } + } + let [top, left, bottom, right] = [v[0], v[1], v[2], v[3]].map(|f| f.clamp(0.0, 1.0)); + // A straightened crop's rectangle is measured on the rotated frame: not applied. + usable &= v[4] == 0.0 && top < bottom && left < right; + if !usable { + let present = CROP_KEYS.iter().zip(&fields.values).filter(|(_, t)| t.is_some()); + report.ignored.extend(present.map(|(k, _)| k.to_string())); + return; + } + if [top, left, bottom, right] != [0.0, 0.0, 1.0, 1.0] { + report.crop = Some(Crop { top, left, bottom, right }); + report.applied += 1; + } } fn number(s: &str) -> Option { @@ -197,8 +301,14 @@ fn neutral(name: &str, value: &str) -> bool { } } -fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) { - if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { +fn apply(params: &mut CameraRaw, report: &mut LoadReport, crop: &mut CropFields, name: &str, value: &str) { + if name == "HasCrop" { + crop.has = Some(value.to_string()); + } else if let Some(i) = CROP_KEYS.iter().position(|k| *k == name) { + if let Some(slot) = crop.values.get_mut(i) { + *slot = Some(value.to_string()); + } + } else if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { match number(value) { Some(v) => { *field(params) = v.clamp(*lo, *hi); @@ -232,10 +342,11 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result let mut next = params.clone(); let mut report = LoadReport::default(); let mut found = false; + let mut crop_fields = CropFields::default(); for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") { for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) { found = true; - apply(&mut next, &mut report, a.name(), a.value()); + apply(&mut next, &mut report, &mut crop_fields, a.name(), a.value()); } for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) { found = true; @@ -254,13 +365,14 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result report.ignored.push(name.to_string()); } } else { - apply(&mut next, &mut report, name, c.text().unwrap_or("")); + apply(&mut next, &mut report, &mut crop_fields, name, c.text().unwrap_or("")); } } } if !found { return Err("no Camera Raw settings in the file".into()); } + crop(&crop_fields, &mut report); *params = next; Ok(report) } @@ -295,6 +407,11 @@ pub fn is_photocraft_xmp(bytes: &[u8]) -> bool { /// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. pub fn save(params: &CameraRaw) -> String { + save_with_crop(params, None) +} + +/// [`save`] with a raw file's crop, so rewriting a sidecar keeps the crop it was opened with. +pub fn save_with_crop(params: &CameraRaw, crop: Option) -> String { let mut p = params.clone(); let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" }; let mut out = String::from( @@ -315,6 +432,12 @@ pub fn save(params: &CameraRaw) -> String { } let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n); out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style))); + if let Some(c) = crop { + out.push_str(&format!( + " crs:HasCrop=\"True\"\n crs:CropTop=\"{:.6}\"\n crs:CropLeft=\"{:.6}\"\n crs:CropBottom=\"{:.6}\"\n crs:CropRight=\"{:.6}\"\n crs:CropAngle=\"0\"\n", + c.top, c.left, c.bottom, c.right + )); + } out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n"); for (name, field) in CURVES { let points = field(&mut p); @@ -433,7 +556,7 @@ mod tests { abc"#; let r = load(&mut p, xmp).unwrap(); assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0)); - assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] }); + assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()], crop: None }); } #[test] @@ -479,4 +602,111 @@ mod tests { assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other)); } } + + /// A settings file with the `crs:` attributes `attrs`. + fn with(attrs: &str) -> String { + format!( + "" + ) + } + + fn report(attrs: &str) -> LoadReport { + load(&mut CameraRaw::default(), with(attrs).as_bytes()).unwrap() + } + + /// #9985: Lightroom's sidecar named the crop's bottom and right edges and the raw's file name + /// as unsupported; the crop is read now, and the file name is bookkeeping. + #[test] + fn reads_the_crop_and_not_the_file_name() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="0""#); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(r.crop, Some(Crop { top: 0.1, left: 0.2, bottom: 0.9, right: 0.75 })); + assert_eq!(r.applied, 1); + // Element form, and the keys spread over two descriptions, read the same. + let xmp = format!( + "True0.5" + ); + let r = load(&mut CameraRaw::default(), xmp.as_bytes()).unwrap(); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.0, bottom: 0.5, right: 0.5 }), "missing edges are the frame's"); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + } + + #[test] + fn no_crop_without_has_crop_true() { + let keys = r#"crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="3""#; + for has in ["", r#"crs:HasCrop="False""#, r#"crs:HasCrop="false""#] { + let r = report(&format!("{has} {keys}")); + assert_eq!((r.crop, r.ignored.clone(), r.applied), (None, Vec::new(), 0), "{has:?}: {r:?}"); + } + // The whole frame is no crop, and nothing to report. + let r = report(r#"crs:HasCrop="True" crs:CropTop="0" crs:CropLeft="0" crs:CropBottom="1" crs:CropRight="1" crs:CropAngle="0""#); + assert_eq!((r.crop, r.ignored.len()), (None, 0)); + let r = report(r#"crs:HasCrop="yes" crs:CropBottom="0.5""#); + assert_eq!((r.crop, r.ignored), (None, vec!["HasCrop".to_string()])); + } + + /// Hostile and straightened crops never panic: out-of-range fractions are clamped to the + /// frame; an inverted, empty, unreadable or straightened crop isn't applied and its keys are + /// named. + #[test] + fn unusable_crops_are_reported_not_applied() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="-0.5" crs:CropLeft="0.25" crs:CropBottom="7" crs:CropRight="1.0000001""#); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.25, bottom: 1.0, right: 1.0 })); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + let all = || CROP_KEYS.iter().map(|k| k.to_string()).collect::>(); + for bad in [ + r#"crs:CropTop="0.8" crs:CropLeft="0.1" crs:CropBottom="0.2" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.5" crs:CropLeft="0.1" crs:CropBottom="0.5" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="2" crs:CropLeft="0.1" crs:CropBottom="3" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="NaN" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="inf" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="1.38""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="NaN""#, + ] { + let r = report(&format!(r#"crs:HasCrop="True" {bad}"#)); + assert_eq!((r.crop, r.ignored, r.applied), (None, all(), 0), "{bad}"); + } + let r = report(r#"crs:HasCrop="True" crs:CropAngle="-2.5""#); + assert_eq!(r.ignored, vec!["CropAngle".to_string()], "only the keys the file has"); + } + + /// Corners map as `photocraft-raw`'s orientation maps pixels (`develop::source_xy`). + #[test] + fn crops_follow_the_orientation() { + let c = Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }; + let want = [ + (1, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + (2, Crop { top: 0.1, left: 0.5, bottom: 0.6, right: 0.8 }), + (3, Crop { top: 0.4, left: 0.5, bottom: 0.9, right: 0.8 }), + (4, Crop { top: 0.4, left: 0.2, bottom: 0.9, right: 0.5 }), + (5, Crop { top: 0.2, left: 0.1, bottom: 0.5, right: 0.6 }), + (6, Crop { top: 0.2, left: 0.4, bottom: 0.5, right: 0.9 }), + (7, Crop { top: 0.5, left: 0.4, bottom: 0.8, right: 0.9 }), + (8, Crop { top: 0.5, left: 0.1, bottom: 0.8, right: 0.6 }), + (0, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + ]; + for (o, w) in want { + let got = c.oriented(o); + let close = [got.top - w.top, got.left - w.left, got.bottom - w.bottom, got.right - w.right].iter().all(|d| d.abs() < 1e-6); + assert!(close, "orientation {o}: {got:?}"); + } + assert_eq!(c.pixels(10, 20), Some((2, 2, 5, 12))); + assert_eq!(Crop { top: 0.5, left: 0.5, bottom: 0.5001, right: 0.5001 }.pixels(10, 10), Some((5, 5, 6, 6)), "at least a pixel"); + assert_eq!(Crop { top: 0.999, left: 0.999, bottom: 1.0, right: 1.0 }.pixels(10, 10), Some((9, 9, 10, 10)), "inside the frame"); + assert_eq!(c.pixels(0, 10), None); + } + + #[test] + fn save_with_crop_round_trips() { + let p = CameraRaw { contrast: 20.0, ..CameraRaw::default() }; + let crop = Crop { top: 0.125, left: 0.25, bottom: 0.875, right: 0.5 }; + let text = save_with_crop(&p, Some(crop)); + assert!(is_photocraft_xmp(text.as_bytes())); + let mut back = CameraRaw::default(); + let r = load(&mut back, text.as_bytes()).unwrap(); + assert_eq!((back, r.crop), (p.clone(), Some(crop))); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(load(&mut CameraRaw::default(), save(&p).as_bytes()).unwrap().crop, None); + } } diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index a1ed4aaa01..a1d1c73067 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -50,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> /// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, /// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the -/// developed RGB pixels, and the demosaic method. +/// developed RGB pixels, the demosaic method, and the crop. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -71,6 +71,9 @@ pub struct RawTuning { pub texture: f32, /// −100…100, as `CameraRaw::dehaze`. pub dehaze: f32, + /// The crop of the raw's sidecar ([`crate::camera_raw_xmp::Crop`], in the stored + /// orientation): the developed frame is cropped to it once it is upright. + pub crop: Option, } /// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure, @@ -106,7 +109,15 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< } None => false, }; - let dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let mut dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let cropped = match tuning.crop { + Some(crop) => { + let o = if opts.orient { dev.info.orientation } else { 1 }; + crop_developed(&mut dev, crop.oriented(o))?; + format!(", cropped to {}×{}", dev.width, dev.height) + } + None => String::new(), + }; let wb = if wb_applied { format!("as-shot white balance, temperature {:+.0}, tint {:+.0}", tuning.temperature, tuning.tint) } else { @@ -117,10 +128,26 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< .filter(|(_, v)| *v != 0.0) .map(|(k, v)| format!(", {k} {v:+.0}")) .collect::(); - let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id()); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id()); Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied)) } +/// Crops the upright developed frame to `crop` (fractions of it), as Camera Raw crops a raw. +fn crop_developed(dev: &mut photocraft_raw::Developed, crop: crate::camera_raw_xmp::Crop) -> Result<(), IoError> { + let (w, h) = (dev.width as usize, dev.height as usize); + let bad = || IoError::Raw(RawError::Malformed(format!("the developed {w}x{h} frame holds fewer samples than its size"))); + let Some((x0, y0, x1, y1)) = crop.pixels(w, h) else { return Ok(()) }; + let mut rgb = Vec::with_capacity((x1 - x0) * (y1 - y0) * 3); + for y in y0..y1 { + let row = y.checked_mul(w).ok_or_else(bad)?; + rgb.extend_from_slice(dev.rgb.get((row + x0) * 3..(row + x1) * 3).ok_or_else(bad)?); + } + dev.rgb = rgb; + dev.width = u32::try_from(x1 - x0).map_err(|_| bad())?; + dev.height = u32::try_from(y1 - y0).map_err(|_| bad())?; + Ok(()) +} + /// The tone curve `bytes` develop with by default: the fitted look's for a file without colour /// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw /// this crate decodes). For previews of the open-time raw-stage sliders diff --git a/crates/io/tests/it/raw.rs b/crates/io/tests/it/raw.rs index 52250c24d7..12a3d362e4 100644 --- a/crates/io/tests/it/raw.rs +++ b/crates/io/tests/it/raw.rs @@ -420,6 +420,36 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() { assert_eq!(r.document.depth, SampleType::U16); } +/// #9985: a sidecar's crop is measured on the raw as stored, before its Orientation turns it +/// upright: the developed frame is cropped to the same sensor pixels, whichever way it is turned. +#[test] +fn camera_raw_tuning_crops_in_the_stored_orientation() { + use photocraft_io::camera_raw_xmp::Crop; + use photocraft_io::raw::{RawTuning, import_raw_tuned}; + let (w, h) = (40, 24); + // Stored pixels x 4..24, y 6..18; then the upright frame's size and the same pixels in it. + let crop = Crop { top: 0.25, left: 0.1, bottom: 0.75, right: 0.6 }; + for (orientation, size, [x0, y0, x1, y1]) in + [(1, (40, 24), [4, 6, 24, 18]), (3, (40, 24), [16, 6, 36, 18]), (6, (24, 40), [6, 4, 18, 24]), (8, (24, 40), [6, 16, 18, 36])] + { + let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)); + spec.as_shot_neutral = Some([0.6, 1.0, 0.8]); + spec.orientation = orientation; + let bytes = spec.build(); + let (full, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning::default()).unwrap(); + let (cut, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning { crop: Some(crop), ..Default::default() }).unwrap(); + assert_eq!((full.document.size.width, full.document.size.height), size, "orientation {orientation}"); + assert_eq!((cut.document.size.width, cut.document.size.height), (x1 - x0, y1 - y0), "orientation {orientation}"); + assert!(cut.warnings.iter().any(|n| n.contains(&format!("cropped to {}×{}", x1 - x0, y1 - y0))), "{:?}", cut.warnings); + let full = photocraft_io::document_to_image(&full.document, &mut Vec::new()).unwrap(); + let cut = photocraft_io::document_to_image(&cut.document, &mut Vec::new()).unwrap(); + // 16-bit RGB: 6 bytes a pixel. + let row = |y: u32| &full.data()[((y * size.0 + x0) * 6) as usize..((y * size.0 + x1) * 6) as usize]; + let expected: Vec = (y0..y1).flat_map(|y| row(y).to_vec()).collect(); + assert_eq!(cut.data(), expected, "orientation {orientation}: the crop is the same pixels of the uncropped develop"); + } +} + #[test] fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() { use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned}; diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs index a66e172158..9ac37ee835 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs @@ -54,6 +54,7 @@ fn identity_app() -> (PhotocraftApp, RawOpen, Control) { bytes: Some(bytes), developed_with: Default::default(), demosaic: Default::default(), + crop: None, }; let mut app = PhotocraftApp::new(session, crate::Services::default()); app.sync_views(); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 87e1035a85..8748bfe7ef 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -16,9 +16,9 @@ pub(super) enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open /// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture, -/// Dehaze or the demosaic method changed (those act on the sensor data, see -/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw -/// step, then marks the document unmodified. Cancel closes it. +/// Dehaze or the demosaic method changed, or the sidecar has a crop (those act on the sensor +/// data, see [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one +/// Camera Raw step, then marks the document unmodified. Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -30,6 +30,9 @@ pub struct RawOpen { pub developed_with: photocraft_io::raw::Demosaic, /// The method chosen in the dialog: Open re-develops when it differs from `developed_with`. pub demosaic: photocraft_io::raw::Demosaic, + /// The crop of the file's sidecar (in the raw's stored orientation): Open re-develops and + /// crops to it. The settings have no crop, so the dialog previews the whole frame. + pub crop: Option, } /// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are @@ -68,12 +71,13 @@ pub fn queue_open_dialog( bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, + crop: None, }); Ok(()) } /// Opens the queued open-time dialog on its document's (only) layer. -pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Result<(), String> { +pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, mut raw: RawOpen) -> Result<(), String> { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; @@ -83,6 +87,7 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe let mut from_sidecar = false; if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) { params = loaded; + raw.crop = report.crop; from_sidecar = true; app.ui.status = if report.ignored.is_empty() { crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)]) @@ -179,6 +184,9 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: clarity: params.clarity, texture: params.texture, dehaze: params.dehaze, + // The sidecar's crop: re-developing crops the document, before the Camera Raw step, so + // the post-crop vignette centres on the crop as in Camera Raw. + crop: raw.crop, }; // The open itself: the default settings, developed with the method it was opened with. let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() }; @@ -349,9 +357,13 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, } let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return }; let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); - let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method }; + let mut raw = + RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method, crop: None }; let mut params = CameraRaw::default(); - let sidecar = path.and_then(read_sidecar).map(|(loaded, _, _)| loaded); + let sidecar = path.and_then(read_sidecar).map(|(loaded, report, _)| { + raw.crop = report.crop; + loaded + }); if let Some(loaded) = &sidecar { params = loaded.clone(); } else if app.session.prefs().raw_defaults.apply_auto_tone @@ -359,7 +371,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, { photocraft_algo::camera_raw::auto_tone(&px, &mut params); } - let r = if params.is_identity() { + let r = if params.is_identity() && raw.crop.is_none() { let defaults = app.session.prefs().raw_defaults.clone(); finish_open_raw(app, index, params, None, &raw, &defaults) } else { diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs index dfb03a13b9..f370a6b68b 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs @@ -68,7 +68,8 @@ pub(super) fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam None if params.is_identity() => return, _ => {} } - let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); + // The crop it opened with stays in the file, though the dialog has no control for it. + let bytes = photocraft_io::camera_raw_xmp::save_with_crop(params, raw.crop).into_bytes(); let Some(write) = app.services.write.as_mut() else { return }; if let Err(e) = write(&sidecar, &bytes) { crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)])); @@ -124,7 +125,12 @@ pub(super) fn load_settings(app: &mut PhotocraftApp) -> Result { let file = crate::file_open::display_name(&name); let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?; let mut next = d.params.clone(); - let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + let mut report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + // A crop only applies as a raw file opens; the filter has none, so it is not supported. + if report.crop.take().is_some() { + report.applied = report.applied.saturating_sub(1); + report.ignored.extend(photocraft_io::camera_raw_xmp::CROP_KEYS.iter().take(4).map(|k| k.to_string())); + } next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?; if d.params.point_curve != next.point_curve { d.curve_state = Default::default(); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs index bcae932f5e..5da2e61d71 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -558,7 +558,8 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { path: None, bytes: None, developed_with: Default::default(), - demosaic: Default::default() + demosaic: Default::default(), + crop: None } ) .is_err() @@ -801,6 +802,47 @@ fn a_rating_only_sidecar_is_left_alone() { let _ = std::fs::remove_dir_all(&dir); } +/// #9985: the crop of a Lightroom sidecar crops the opened raw (with the dialog's Open and +/// without the dialog), and neither its edges nor the raw's file name are named as unsupported. +/// A straightened crop isn't applied and is named. PhotoCraft's own sidecar keeps the crop. +#[test] +fn a_sidecar_crop_crops_the_opened_raw() { + let lightroom = r#""#; + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("crop", Some(lightroom)); + let ctx = egui::Context::default(); + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("IMG_0100.xmp") && !app.ui.status.contains("not supported"), "{}", app.ui.status); + assert_eq!(size(&app), Some((48, 32)), "the dialog previews the whole frame"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16)), "Open crops the 48x32 frame to x 6..30, y 8..24"); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "the crop is part of opening: unmodified"); + assert!(written.lock().unwrap().is_empty(), "Lightroom's sidecar is kept"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); + app.open_file(&raw_path, &dng).unwrap(); + assert_eq!(size(&app), Some((24, 16)), "without the dialog too"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": true})).unwrap(); + // Straightened: the rectangle is measured on the turned frame, so nothing is cropped. + std::fs::write(sidecar_path(&raw_path), lightroom.replace(r#"crs:CropAngle="0""#, r#"crs:CropAngle="1.38""#)).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("CropAngle") && !app.ui.status.contains("RawFileName"), "{}", app.ui.status); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((48, 32))); + // PhotoCraft's own sidecar: a changed setting rewrites it, with the crop it was opened with. + let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 }; + std::fs::write(sidecar_path(&raw_path), photocraft_io::camera_raw_xmp::save_with_crop(&CameraRaw::default(), Some(crop))).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16))); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1); + let mut back = CameraRaw::default(); + let r = photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap(); + assert_eq!((back.contrast, r.crop), (15.0, Some(crop))); + let _ = std::fs::remove_dir_all(&dir); +} + /// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing /// back: nothing changed. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c286693c2..8f648c8c34 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -529,7 +529,10 @@ Open also applies the other Camera Raw Defaults (Sharpen For as part of the Came colour space, bit depth and Open as Smart Object, so the step works on the pixels the dialog previewed; Apply auto tone fills the dialog), reported as `defaults` in the commit's reply; the resolution is set when the file opens. With "Open in Camera Raw" off the sidecar and the defaults -apply without the dialog. +apply without the dialog. A sidecar's crop (`crs:HasCrop="True"` with `CropTop` / `CropLeft` / +`CropBottom` / `CropRight`, fractions of the raw as stored, before its Orientation) crops the +document as Open re-develops it, before the Camera Raw step; the dialog previews the whole frame. +A straightened crop (`CropAngle` not 0) is named as not supported and not applied. **Opening a raw file.** An interactive open (File › Open, Open Recent, drag and drop, the command line) of a camera raw developed from its sensor data shows this dialog first, titled diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f49fa461de..63746addaf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -74,7 +74,7 @@ supports many more, including CR3). | Olympus ORF | uncompressed only | | | Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | | | Others (CRW, 3FR, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | -| .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 | +| .xmp sidecars, highlight modes, raw preferences | partial (sidecars and preferences; no highlight modes) | 2026-10-28: a sidecar's crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the developed frame as stored, mapped through its Orientation) crops the opened raw: Open re-develops and crops before the Camera Raw step, so the post-crop vignette centres on the crop; the dialog previews the whole frame. A straightened crop (`CropAngle` not 0) isn't applied and is named, as are unreadable or empty ones; `HasCrop` missing or `False` means no crop; `RawFileName` is bookkeeping (#9985; `crates/io/src/camera_raw_xmp.rs` tests, `crates/io/tests/it/raw.rs` `camera_raw_tuning_crops_in_the_stored_orientation`, `camera_raw_ui/tests.rs` `a_sidecar_crop_crops_the_opened_raw`). 2026-10-13: sidecars fill the open-time dialog and Open writes them back; Camera Raw Defaults wired. FILE-215-11 | | Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) | 2026-10-11: on #2135's embedded-JPEG rendering work, open-time Camera Raw tuning @@ -144,6 +144,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Camera Raw `.xmp` sidecars: the crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the raw as stored, mapped through its Orientation) crops the opened raw, with the dialog's Open and without the dialog; out-of-range fractions are clamped, and inverted, unreadable or straightened (`CropAngle` not 0) crops are named as not supported instead of applied; PhotoCraft's own sidecar keeps the crop when it is rewritten; `crs:RawFileName` is no longer named as unsupported (#9985) | | 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged | | 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. | | 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | From ea4ec7b80e7a0627b66ba824d75d29e5d65b5beb Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 18:04:57 -0200 Subject: [PATCH 08/77] retouch: a wrapped stroke over its work budget names its own command periodic::Composite said paint.cloneStamp for every tool; Healing Brush, History Brush and Pattern Stamp now go through it too. The command is passed in, and the live stroke passes its own. --- crates/engine/src/cmd/retouch_cmds/clone.rs | 3 ++- crates/engine/src/cmd/retouch_cmds/heal.rs | 4 ++-- .../src/cmd/retouch_cmds/pattern_stamp.rs | 2 +- crates/engine/src/cmd/retouch_cmds/periodic.rs | 8 ++++++-- crates/engine/src/cmd/retouch_cmds/tests.rs | 18 ++++++++++++++++++ 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index e0643d9fec..41f168244f 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -136,7 +136,7 @@ pub(super) fn clone_stamp(s: &mut Session, p: &Value) -> Result { return Ok(l.bounds); } if flag(p, "wrap", false) { - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); } let (bounds, cov) = coverage(CMD, &stroke)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. @@ -349,6 +349,7 @@ impl LiveRetouch { selection: self.sel.as_ref(), lock: self.lock, mode: self.mode, + cmd: live_cmd, } .draw(dirty, &self.brush, &self.points, paint) } diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index 791f65fc62..c68789e2c3 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -73,7 +73,7 @@ pub(super) fn healing_brush(s: &mut Session, p: &Value) -> Result { let mask: Vec = cov.iter().map(|c| *c > 0.0).collect(); let healed = heal_region(&fmt, &src, &dst, &mask); if wrap { - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| { let part = healed.crop(r); if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) } }); @@ -114,7 +114,7 @@ pub(super) fn history_brush(s: &mut Session, p: &Value) -> Result { let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; if flag(p, "wrap", false) { // Pattern Preview: the history state repeats with the canvas, as the stroke does. - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); diff --git a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs index 7ccb293d4f..021e02a08a 100644 --- a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs @@ -97,7 +97,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) } else if wrap.is_some() { let (place, fmt) = (st.placement(), surf.format()); - periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) + periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, CMD, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } diff --git a/crates/engine/src/cmd/retouch_cmds/periodic.rs b/crates/engine/src/cmd/retouch_cmds/periodic.rs index 57527e1629..63c3b40f07 100644 --- a/crates/engine/src/cmd/retouch_cmds/periodic.rs +++ b/crates/engine/src/cmd/retouch_cmds/periodic.rs @@ -26,6 +26,7 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec { /// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a /// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and /// they go on through the stroke's coverage wherever it repeats on the document. +#[allow(clippy::too_many_arguments)] pub(super) fn stroke( pre: &Document, target: &mut Surface, @@ -33,6 +34,7 @@ pub(super) fn stroke( selection: Option<&Surface>, lock: bool, mode: BlendMode, + cmd: &str, mut paint: impl FnMut(&Surface, Rect) -> Result, ) -> Result { let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); @@ -40,7 +42,7 @@ pub(super) fn stroke( renderer.finish(); let before = target.clone(); let bounds = renderer.bounds(); - Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode }.draw( + Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode, cmd }.draw( bounds, &stroke.brush, &stroke.points, @@ -59,6 +61,8 @@ pub(super) struct Composite<'a> { pub selection: Option<&'a Surface>, pub lock: bool, pub mode: BlendMode, + /// The command an over-budget stroke's error names. + pub cmd: &'a str, } impl Composite<'_> { @@ -163,7 +167,7 @@ impl Composite<'_> { ); *work = work.saturating_add(r.width() as u64 * r.height() as u64); if *work > 64 * 1024 * 1024 { - return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget")); + return Err(bad(self.cmd, "wrapped stroke exceeds its coverage budget")); } ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths)); let mut sampled = None; diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index fe2ea3beab..eb031a1a1f 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2766,3 +2766,21 @@ fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { } } } + +#[test] +fn a_wrapped_composite_stroke_over_its_work_budget_names_its_own_command() { + // Big, tightly spaced dabs pass the stroke footprint check but not the wrapped-work budget. + for (cmd, extra, setup) in [ + ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), + ] { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + setup(&mut s); + let mut p = json!({"points": [[0, 0], [700, 0]], "size": 1000, "hardness": 100, "spacing": 1, "wrap": true}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let err = s.execute(cmd, p).unwrap_err().to_string(); + assert!(err.contains(cmd) && err.contains("coverage budget"), "{cmd}: {err}"); + } +} From 854f6ec9009317dcfd61ddcb9c82e9bcc0464e13 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Mon, 2 Nov 2026 00:40:54 -0200 Subject: [PATCH 09/77] retouch tests: a wrapped stroke has to change the pixels it covers The corner check passed on the pre-stroke texture, and the oversized-dab check passed on a no-op. Spot Healing stays excepted in the second one: it has nothing left to sample there. --- crates/engine/src/cmd/retouch_cmds/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 38fb98fdf3..6946df3ff3 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2699,8 +2699,9 @@ fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_noth let after = layer_pixels(&s); let n = after.len() / (WRAP.0 * WRAP.1) as usize; let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); - for (x, y) in [(1, 1), (46, 1), (1, 34), (46, 34)] { - assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); + assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); + for (x, y) in [(46, 1), (1, 34), (46, 34)] { + assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); } for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); @@ -2715,6 +2716,7 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { for (cmd, extra, setup, _) in cases { let mut s = session(16, 12, 8, "rgb"); setup(&mut s); + let before = layer_pixels(&s); let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); for (k, v) in extra.as_object().unwrap() { p[k] = v.clone(); @@ -2725,6 +2727,10 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { let st = s.active().unwrap(); let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); + // Spot Healing has nothing left to sample here (the next test). + if cmd != "paint.spotHealing" { + assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); + } } } From a7568b70b747786e027f3133b17434c18e01beed Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Mon, 2 Nov 2026 01:27:12 -0200 Subject: [PATCH 10/77] retouch tests: a wrapped corner has to heal as well as change --- crates/engine/src/cmd/retouch_cmds/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 6946df3ff3..ede2cb9348 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2701,6 +2701,7 @@ fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_noth let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); for (x, y) in [(46, 1), (1, 34), (46, 34)] { + assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); } for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { From 5b6f3c18373d90f6af4000437db8cd579d4b1c61 Mon Sep 17 00:00:00 2001 From: mzuniga37 <275811311+mzuniga37@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:16:02 -0600 Subject: [PATCH 11/77] Count tool: adding a marker turns View > Show > Count back on With Show > Count off, a click added the marker but left it invisible, so the click looked like it did nothing. It now switches the flag on, without adding a history state. Alt+click and hidden groups are unchanged. Fixes #11770 --- crates/ui-egui/src/tools.rs | 2 + crates/ui-egui/src/tools/analysis_ui.rs | 5 +- .../src/tools/count_show_extras_tests.rs | 70 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tools/count_show_extras_tests.rs diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 9878bf41a4..ccddd07b6d 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -56,5 +56,7 @@ pub mod vector_ui; pub mod warp_preview; pub mod zoom_tool; +#[cfg(test)] +mod count_show_extras_tests; #[cfg(test)] mod crop_options_labels_tests; diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index 6cad2b2158..5229fa1b5b 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -294,7 +294,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } Some((g, i)) => app.ui.analysis.drag = Some(Drag::Count(g, i, [x, y])), None if !mods.alt => { - let _ = app.run("count.add", json!({"x": x, "y": y})); + // A marker that stays invisible looks like the click did nothing, so adding one + // brings View > Show > Count back. It is view state, not a history step. + let added = app.run("count.add", json!({"x": x, "y": y})).is_ok(); + app.ui.view.show.count |= added; } None => {} } diff --git a/crates/ui-egui/src/tools/count_show_extras_tests.rs b/crates/ui-egui/src/tools/count_show_extras_tests.rs new file mode 100644 index 0000000000..773ce74546 --- /dev/null +++ b/crates/ui-egui/src/tools/count_show_extras_tests.rs @@ -0,0 +1,70 @@ +//! #11770: adding a count marker while View > Show > Count is off switches it back on. + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::Count; + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x, y }, m); +} + +fn points(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.measurement.count_groups.iter().map(|g| g.points.len()).sum() +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +#[test] +fn a_click_with_show_count_off_adds_the_marker_and_turns_it_on() { + let mut app = app(); + app.ui.view.show.count = false; + let before = past(&app); + click(&mut app, 40.0, 30.0, Modifiers::NONE); + assert_eq!(points(&app), 1); + assert!(app.ui.view.show.count, "the new marker is visible"); + assert_eq!(past(&app), before + 1, "only the marker's own history state"); +} + +#[test] +fn a_click_with_show_count_on_behaves_as_before() { + let mut app = app(); + app.ui.view.show.count = true; + click(&mut app, 40.0, 30.0, Modifiers::NONE); + assert_eq!(points(&app), 1); + assert!(app.ui.view.show.count); +} + +#[test] +fn a_click_into_a_hidden_group_leaves_the_group_hidden() { + let mut app = app(); + click(&mut app, 10.0, 10.0, Modifiers::NONE); + app.run("count.setGroup", json!({"group": 0, "visible": false})).unwrap(); + app.ui.view.show.count = false; + click(&mut app, 90.0, 60.0, Modifiers::NONE); + assert_eq!(points(&app), 2); + assert!(app.ui.view.show.count); + assert!(!app.session.active().unwrap().doc.measurement.count_groups[0].visible); +} + +#[test] +fn removing_a_marker_does_not_turn_show_count_on() { + let mut app = app(); + click(&mut app, 10.0, 10.0, Modifiers::NONE); + app.ui.view.show.count = false; + click(&mut app, 10.0, 10.0, Modifiers::ALT); + assert_eq!(points(&app), 0); + assert!(!app.ui.view.show.count); +} From 0e0d99d0b2b31cdc7809d2ffaf50927f09b6b853 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:29:32 -0500 Subject: [PATCH 12/77] fix(engine): Magic Wand Subtract and Intersect grow from the seed through the selection (#11779) Alt-click (Subtract) and Shift+Alt-click (Intersect) with the Magic Wand took the seed's whole region, wherever the seed was, so a click outside the selection still cut into it. With Contiguous on, the region now grows from the seed through selected pixels only: a seed outside the selection subtracts nothing and intersects to nothing, and a seed inside gives the same results as before. Contiguous off is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/selection.rs | 27 +++++++- crates/engine/src/cmd/selection_cmds.rs | 10 ++- .../selection_cmds/wand_seed_outside_tests.rs | 68 +++++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index f740687c1a..8ceba64047 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -270,6 +270,22 @@ impl Region { /// byte mark per pixel, then anti-aliases just the edges inside the touched bounding box. /// Matches [`magic_wand`] for 8-bit sources. `None` if the seed is outside `area`. pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Option { + wand_region_within(img, area, seed, tolerance, contiguous, anti_alias, None) +} + +/// [`wand_region`] whose contiguous fill only passes through pixels at least half selected in +/// `within` (the current selection's coverage over `area`, row-major): Subtract and Intersect +/// grow the region from the seed through the selection only (#11779). A seed outside it finds +/// nothing. A global match (`contiguous` off) ignores `within`. +pub fn wand_region_within( + img: &[[u8; 4]], + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + within: Option<&[f32]>, +) -> Option { if !area.contains(seed.0, seed.1) { return None; } @@ -277,6 +293,11 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 if w.checked_mul(h).is_none_or(|n| n > img.len()) { return None; } + let within = within.filter(|_| contiguous).filter(|m| m.len() >= w * h); + let inside = |i: usize| within.is_none_or(|m| m.get(i).is_some_and(|v| *v >= 0.5)); + if !inside((seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize) { + return None; + } let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize]; let tol = (tolerance + 1e-3).floor().max(0.0) as i32; // Copying a selection can leave RGB under zero alpha. Those invisible colours must not @@ -293,10 +314,10 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 continue; } let (mut l, mut r) = (x, x); - while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) { + while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) && inside(row + l - 1) { l -= 1; } - while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) { + while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) && inside(row + r + 1) { r += 1; } marks[row + l..=row + r].fill(255); @@ -308,7 +329,7 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 let nrow = ny * w; let mut in_run = false; for nx in l..=r { - let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]); + let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]) && inside(nrow + nx); if ok && !in_run { stack.push((nx, ny)); } diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 13737e70bd..c1c665e111 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -17,6 +17,8 @@ mod modify_window; mod color_range_performance; #[cfg(test)] mod color_range_window_tests; +#[cfg(test)] +mod wand_seed_outside_tests; fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) @@ -139,9 +141,13 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result { Some(plane) => plane, None => sample_rgba8(s, b(p, "sampleAllLayers", false))?, }; - let region = sel::wand_region(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true)); - drop(img); let m = mode(p); + // Subtract and Intersect grow the region from the seed through the selection only (#11779). + let within = matches!(m, SelectionMode::Subtract | SelectionMode::Intersect) + .then(|| s.active().and_then(|st| st.doc.selection.as_ref()).map(|sel| sel::mask_from_surface(Some(sel), area))) + .flatten(); + let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), within.as_deref()); + drop(img); let selected = s.edit("Magic Wand", |doc, _| { doc.selection = sel::combine_region(doc.selection.as_ref(), region.as_ref(), m); Ok(doc.selection.is_some()) diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs new file mode 100644 index 0000000000..13165d591f --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -0,0 +1,68 @@ +//! #11779: with Contiguous on, the Magic Wand's Subtract and Intersect grow the region from the +//! seed through the current selection only. A seed outside the selection subtracts nothing and +//! intersects to nothing. + +use serde_json::json; + +use crate::Session; + +/// 800 × 600, white, with a grey ramp of ten 40 px blocks from x 100 (100, 105, …, 145). +fn ramp() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap(); + for k in 0..10 { + let v = 100 + 5 * k; + s.execute("select.rect", json!({"x": 100 + 40 * k, "y": 350, "width": 40, "height": 100})).unwrap(); + s.execute("edit.fill", json!({"color": format!("#{v:02x}{v:02x}{v:02x}")})).unwrap(); + } + s.execute("select.deselect", json!({})).unwrap(); + s +} + +fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); +} + +/// The columns of row 400 that are at least half selected: (first, end), or `None`. +fn selected(s: &Session) -> Option<(i32, i32)> { + let doc = &s.active().unwrap().doc; + let row = photocraft_geom::Rect::new(0, 400, 800, 401); + let m = photocraft_algo::selection::mask_from_surface(doc.selection.as_ref(), row); + let first = m.iter().position(|v| *v >= 0.5)?; + let last = m.iter().rposition(|v| *v >= 0.5)?; + Some((first as i32, last as i32 + 1)) +} + +/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them. +fn ramp_selected() -> Session { + let mut s = ramp(); + wand(&mut s, 110, 32.0, "replace"); + assert_eq!(selected(&s), Some((100, 380))); + s +} + +#[test] +fn a_seed_outside_the_selection_subtracts_nothing() { + let mut s = ramp_selected(); + // Grey 145 (x 460–500) is outside the selection, though its own region reaches into it. + wand(&mut s, 480, 32.0, "subtract"); + assert_eq!(selected(&s), Some((100, 380)), "unchanged"); +} + +#[test] +fn a_seed_outside_the_selection_intersects_to_nothing() { + let mut s = ramp_selected(); + wand(&mut s, 480, 32.0, "intersect"); + assert_eq!(selected(&s), None); +} + +#[test] +fn a_seed_inside_the_selection_works_as_before() { + // Grey 125 with tolerance 10 reaches greys 115–135; inside the selection that is x 220–380. + let mut s = ramp_selected(); + wand(&mut s, 320, 10.0, "subtract"); + assert_eq!(selected(&s), Some((100, 220))); + let mut s = ramp_selected(); + wand(&mut s, 320, 10.0, "intersect"); + assert_eq!(selected(&s), Some((220, 380))); +} From 372d864d3e5b9f5ad265cd862cbfddb9eafe55b0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 15:22:16 +0100 Subject: [PATCH 13/77] Brush Settings: name the Spacing percentage field for accessibility (#5224) After merging main, the newer egui_kittest accessibility check failed the PR's spacing tests: the percentage field next to the Spacing checkbox had no accessible name. Name it "Spacing" and query the checkbox by role in the tests, since the field now shares the label. Co-Authored-By: augrclk Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/brush_panel/tests.rs | 6 +++--- crates/ui-egui/src/panels/brush_sections.rs | 5 ++++- crates/ui-egui/src/panels/brush_sections/tests.rs | 7 ++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs index ca2ab47d58..a11fe034c3 100644 --- a/crates/ui-egui/src/panels/brush_panel/tests.rs +++ b/crates/ui-egui/src/panels/brush_panel/tests.rs @@ -1886,7 +1886,7 @@ fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { h.run_steps(2); let journal = h.state().session.journal.len(); let spacing = h.state().session.tools.brush.spacing; - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert!(h.state().session.tools.brush.spacing_enabled); @@ -1900,7 +1900,7 @@ fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { assert_eq!(h.state().session.tools.brush.spacing, 0.44); assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacing": 0.44_f32})); - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": false})); @@ -1911,7 +1911,7 @@ fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { h.run_steps(2); assert_eq!(h.state().session.tools.brush.spacing, 0.44); assert_eq!(h.state().session.journal.len(), journal + 3, "disabled input adds no command"); - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert_eq!(h.state().session.tools.brush.spacing, 0.44); diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index c74a75b452..976766abe9 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -61,7 +61,10 @@ fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { ui.horizontal(|ui| { widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed = ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0)).inner.changed(); + changed = ui + .add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0).accessible_name(tl!("Spacing"))) + .inner + .changed(); }); }); changed |= ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::slider(ui, &mut percent, 1.0..=1000.0, None)).inner.changed(); diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index 823c477992..66cb16d43f 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -344,8 +344,9 @@ fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { let mut h = section_harness(0, b.clone()); crate::PhotocraftApp::setup_context(&h.ctx, kind); h.run_steps(3); - assert_eq!(h.query_all_by_label("Spacing").count(), 1, "{kind:?}: the checkbox is the single label"); - let checkbox = h.get_by_label("Spacing").rect(); + assert_eq!(h.query_all_by_role_and_label(Role::Label, "Spacing").count(), 0, "{kind:?}: the checkbox is the single label"); + assert_eq!(h.query_all_by_role_and_label(Role::CheckBox, "Spacing").count(), 1); + let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing").rect(); let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); assert_eq!(field.accesskit_node().is_disabled(), !enabled); @@ -368,7 +369,7 @@ fn spacing_row_retains_disabled_values_and_percentage_editing_limits() { h.run_steps(2); drag(&mut h, from, to); assert_eq!(h.state(), &before, "Off ignores both field input and a track drag"); - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert!(h.state().spacing_enabled); From be428bf90ef0bdfc96dc5222a81241990be090dd Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:59:39 -0500 Subject: [PATCH 14/77] Brush: Clear on the Background paints as Normal and the Mode reads Normal Fixes #11897. Co-Authored-By: Claude Opus 5.5 --- .../src/brush_clear_background_tests.rs | 59 +++++++++++++++++++ crates/engine/src/cmd/brush_cmds.rs | 18 +++++- crates/engine/src/lib.rs | 2 + .../ui-egui/src/canvas/paint_mouse/tests.rs | 21 +++++++ crates/ui-egui/src/canvas/tool_input.rs | 10 ++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/brush_clear_background_tests.rs diff --git a/crates/engine/src/brush_clear_background_tests.rs b/crates/engine/src/brush_clear_background_tests.rs new file mode 100644 index 0000000000..e786af9dd7 --- /dev/null +++ b/crates/engine/src/brush_clear_background_tests.rs @@ -0,0 +1,59 @@ +//! #11897: the Brush's Clear mode on the Background paints as Normal with the foreground colour, +//! not the background colour; on a pixel layer Clear still erases, and the Eraser on the +//! Background still paints the background colour. + +use crate::Session; +use serde_json::{Value, json}; + +const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + s.tools.foreground = BLACK; + s.tools.background = GREEN; + s +} + +fn pixel(s: &mut Session, x: i32, y: i32) -> [f32; 4] { + let v = s.execute("document.pixel", json!({"x": x, "y": y})).unwrap(); + let a: Vec = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap() as f32).collect(); + [a[0], a[1], a[2], a[3]] +} + +fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 0.02) +} + +/// A hard stroke across (10..30, 10), as the canvas sends it with Clear chosen. +fn clear_stroke() -> Value { + json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true, "mode": "clear"}) +} + +#[test] +fn clear_on_the_background_paints_the_foreground() { + let mut s = session(); + s.execute("paint.stroke", clear_stroke()).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, BLACK), "painted {p:?}, not the background colour"); +} + +#[test] +fn clear_on_a_pixel_layer_still_erases() { + let mut s = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("paint.stroke", clear_stroke()).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, WHITE), "erased down to the white Background: {p:?}"); +} + +#[test] +fn the_eraser_on_the_background_still_paints_the_background_colour() { + let mut s = session(); + s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true})).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, GREEN), "{p:?}"); +} diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index e09ff2098c..ecdeeab4da 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -424,10 +424,24 @@ fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { b } +/// The Brush's Clear on the Background paints as Normal with its own colour, as in Photoshop +/// (#11897): the Background has no transparency to clear. Masks, channels, other layers (even +/// with locked transparency) and the Pencil keep Clear. +fn clear_on_background(s: &Session, p: &Value, mut brush: BrushSettings) -> Result { + if !clear_mode(p) || is_mask_target(p) || crate::channel_cmds::is_channel_target(p) || crate::color_channels::target(s, p)?.is_some() { + return Ok(brush); + } + let id = layer_id(s, p)?; + if s.active().is_some_and(|st| st.doc.is_background(id)) { + brush.erase = false; + } + Ok(brush) +} + /// `paint.stroke`: the Brush (and Eraser) tool. pub fn paint_stroke(s: &mut Session, p: &Value) -> Result { let pts = parse_points(p, "paint.stroke")?; - let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p); + let brush = clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p))?; let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Brush Tool" }; if flag(p, "fill", false) { return fill_region(s, p, label, brush, pts); @@ -605,7 +619,7 @@ impl LiveStroke { "paint.pencil" => true, other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")), }; - let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) }; + let brush = if pencil { pencil_brush(s, p)? } else { clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, cmd)?, p))? }; let (seed, fg) = (brush.seed, brush.color); let (layer, mut brush, zoom) = stroke_target(s, p, brush)?; let st = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f28147217..f9273cebad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -355,4 +355,6 @@ mod tests; #[cfg(test)] mod fx_mode_tests; +#[cfg(test)] +mod brush_clear_background_tests; pub mod command_hints; diff --git a/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index c31e137e82..cdc95c6158 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -1023,3 +1023,24 @@ fn a_right_click_moved_and_released_in_one_frame_still_opens_its_menu() { assert_eq!(h.state().ui.brush_picker, None); assert_eq!(h.state().ui.canvas_tool_menu, None); } + +/// #11897: Clear chosen on a pixel layer, then the Background activated: a Brush stroke there +/// paints the foreground, and the Mode reads Normal afterwards. +#[test] +fn chosen_clear_paints_normally_on_the_background_and_the_mode_reads_normal() { + let mut h = harness(None); + h.state_mut().ui.tool_options.brush_clear = true; + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.run_steps(2); + let (a, b) = drag(&mut h, PointerButton::Primary); + assert!(!h.state().ui.tool_options.brush_clear, "the Mode reads Normal"); + let last = strokes(&h).last().cloned().unwrap(); + assert!(last.get("mode").is_none() && !last["erase"].as_bool().unwrap(), "{last}"); + let app = h.state(); + let v = app.ui.views[0].clone(); + let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom; + let st = app.session.active().unwrap(); + let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); + assert!(px[2] > 0.9 && px[0] < 0.1, "the foreground blue, not the background colour: {px:?}"); +} diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index f0a6b57506..949b84591f 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -360,6 +360,15 @@ fn handle_eyedropper_event(app: &mut PhotocraftApp, tool: Tool, ev: ToolEvent, m } /// Tool state machine. Shared by mouse input and automation. +/// The Brush's chosen Clear paints as Normal on the Background, and its Mode then reads Normal +/// (#11897). A held `~` keeps today's behaviour. +fn clear_off_on_background(app: &mut PhotocraftApp, tool: Tool) { + let on_background = app.session.active().is_some_and(|st| st.active_layer.is_some_and(|id| st.doc.is_background(id))); + if tool == Tool::Brush && app.ui.tool_options.brush_clear && !app.held_erase && on_background && paint_target(app) == serde_json::json!("pixels") { + app.ui.tool_options.brush_clear = false; + } +} + pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { if consumes_cancelled_vector_event(app, ev) { return; @@ -524,6 +533,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) _ => {} } crate::paint_mouse::sync_tool_brush(app); + clear_off_on_background(app, tool); // Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush. let clear = crate::paint_mouse::clears(app, tool); let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear; From b509031d8820a6cd16eb671952e402cf6088c384 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:02:20 +0000 Subject: [PATCH 15/77] test(ui): reproduce Zoom menu centre drift (#12085) --- .../src/canvas/canvas_tool_menu/tests.rs | 3 ++ .../zoom_menu_anchor_tests.rs | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index e5c46b42ae..1d7c67ff31 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -2,6 +2,9 @@ use super::*; use egui::{Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable}; +#[path = "zoom_menu_anchor_tests.rs"] +mod zoom_menu_anchor_tests; + fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs new file mode 100644 index 0000000000..72a7d0c24b --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -0,0 +1,35 @@ +use super::*; + +#[test] +fn zoom_menu_steps_keep_the_view_centre_and_history() { + for animated in [false, true] { + for offset in [vec2(-160.0, -100.0), vec2(140.0, 80.0)] { + let mut app = zoom_app(2400, 1800); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false}})).unwrap(); + let mut h = harness(app); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [928.0, 686.0]; + v.request_zoom_animation(false); + h.step(); + let center = h.state().ui.views[0].center; + let st = h.state().session.active().unwrap(); + let history = (st.history.past_len(), st.history.can_redo(), st.revision); + for (label, zoom) in [("Zoom In", 2.0), ("Zoom Out", 1.0), ("Zoom Out", 2.0 / 3.0)] { + let p = crate::canvas::ViewXform::active(h.state()).unwrap().rect.center() + offset; + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label(label).click(); + h.run_steps(3); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, zoom, "{label}: one ladder stop"); + assert_eq!(v.center, center, "{label}: the right-click must not move the view centre"); + let xf = crate::canvas::ViewXform::active(h.state()).unwrap(); + assert!(xf.to_screen(center[0], center[1]).distance(xf.rect.center()) < 1.0); + assert!(h.state().ui.canvas_tool_menu.is_none()); + let st = h.state().session.active().unwrap(); + assert_eq!((st.history.past_len(), st.history.can_redo(), st.revision), history); + } + } + } +} From 593c7d487b5df2164b73d369a699f424f7cfb2c2 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:09:14 +0000 Subject: [PATCH 16/77] fix(ui): keep Zoom menu steps centred on the view --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 7 +- .../src/canvas/canvas_tool_menu/tests.rs | 16 ++-- .../zoom_menu_anchor_tests.rs | 77 +++++++++++++++++++ docs/control-protocol.md | 2 +- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index b2e377e0ec..044f040c72 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -916,8 +916,8 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; true } -/// A view menu row. Zoom In and Zoom Out step about the right-clicked point, as a Zoom tool click -/// does; the other rows run their View menu command. +/// A view menu row. The selected Zoom tool's steps keep the view centre unless clicked-point +/// centring is enabled. Other view-tool cases retain their pressed-point anchoring. fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { let i = app.session.active_index().ok_or("no document")?; if command == FILL_SCREEN { @@ -935,6 +935,9 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co } let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; + if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && !center_on_point { + return Ok(()); + } let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; let d = [d[0] as f32, d[1] as f32]; // The view scales about the centre, so the point keeps its screen position when its offset diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 1d7c67ff31..5228ffd6c9 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -550,11 +550,12 @@ fn zoom_and_hand_right_click_opens_the_view_menu() { } #[test] -fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { +fn view_menu_zoom_in_keeps_the_view_centre() { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; let mut h = harness(app); let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -564,8 +565,7 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { assert!(h.state().ui.canvas_tool_menu.is_none()); let after = h.state().ui.views[0].zoom; assert_eq!(after, crate::zoom_levels::step(zoom, 1, [400, 300]), "one Zoom In step"); - let s = screen_of(h.state(), doc); - assert!((s - p).length() < 1.0, "the right-clicked point stays put: {s:?} vs {p:?}"); + assert_eq!(h.state().ui.views[0].center, center); assert_eq!(h.state().session.journal.last().map(|(id, _)| id.as_str()), Some("view.zoomIn")); // Zoom Out returns to where it was. right_click(&mut h, p, Modifiers::NONE); @@ -576,12 +576,13 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { } #[test] -fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() { +fn animated_zoom_off_view_menu_keeps_the_centre_without_a_transition() { for command in ["view.zoomIn", "view.zoomOut", FILL_SCREEN] { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; app.run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false}})).unwrap(); let mut h = harness(app); + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -594,17 +595,18 @@ fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() { assert_eq!((xf.zoom, xf.center), (v.zoom, v.center), "{command}"); assert!(v.zoom_anchor.is_none() && v.zoom_transition.is_none(), "{command}"); if command != FILL_SCREEN { - assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: preserve the clicked point"); + assert_eq!(v.center, center, "{command}: preserve the view centre"); } } } #[test] -fn view_menu_zoom_keeps_its_anchor_during_the_animation() { +fn view_menu_zoom_keeps_its_centre_during_the_animation() { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; let mut h = harness(app); let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -616,7 +618,7 @@ fn view_menu_zoom_keeps_its_anchor_during_the_animation() { h.step(); let displayed = crate::canvas::ViewXform::active(h.state()).unwrap(); assert!(displayed.zoom > zoom && displayed.zoom < h.state().ui.views[0].zoom); - assert!(displayed.to_screen(doc[0], doc[1]).distance(p) < 1.0); + assert!(displayed.to_screen(center[0], center[1]).distance(displayed.rect.center()) < 1.0); h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3); h.step(); assert!(!h.state().ui.views[0].zoom_animating); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index 72a7d0c24b..f0baa53b8a 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -33,3 +33,80 @@ fn zoom_menu_steps_keep_the_view_centre_and_history() { } } } + +#[test] +fn clicked_point_preference_and_hand_menu_keep_their_existing_anchors() { + for (tool, center_on_point) in [(Tool::Zoom, true), (Tool::Hand, true), (Tool::Hand, false)] { + for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = tool; + app.run("prefs.set", json!({"values": {"tools.zoomClickedPointToCenter": center_on_point, "tools.overscroll": true}})).unwrap(); + let mut h = harness(app); + let before = h.state().ui.views[0].clone(); + let p = screen_of(h.state(), [300.0, 100.0]); + right_click(&mut h, p, Modifiers::NONE); + let doc = h.state().ui.canvas_tool_menu.as_ref().unwrap().anchor.unwrap().map(|n| n as f32); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, command); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, crate::zoom_levels::step(before.zoom, direction, [400, 300])); + let expected = if center_on_point { doc } else { std::array::from_fn(|i| doc[i] - (doc[i] - before.center[i]) * before.zoom / v.zoom) }; + assert_eq!(v.center, expected, "{tool:?}: {command}"); + assert!(v.zoom_animating); + assert_eq!(v.zoom_anchor.is_some(), !center_on_point); + } + } +} + +#[test] +fn zoom_click_and_alt_click_still_anchor_at_the_pointer() { + for animated in [false, true] { + for (modifiers, direction) in [(Modifiers::NONE, 1), (Modifiers::ALT, -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let mut h = harness(app); + let zoom = h.state().ui.views[0].zoom; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.input_mut().time = Some(h.ctx.input(|i| i.time) + 0.3); + h.step(); + assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300])); + assert!(screen_of(h.state(), doc).distance(p) < 1.0); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0); + } + } +} + +#[test] +fn held_zoom_keys_still_open_no_view_menu() { + for tool in [Tool::Brush, Tool::Hand] { + for modifiers in [Modifiers::COMMAND, Modifiers::ALT] { + let mut app = zoom_app(400, 300); + app.ui.tool = tool; + let mut h = harness(app); + let modifiers = + Modifiers { mac_cmd: cfg!(target_os = "macos") && modifiers.command, ctrl: !cfg!(target_os = "macos") && modifiers.command, ..modifiers }; + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers }); + h.step(); + assert!(matches!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn | crate::hold_keys::Temporary::ZoomOut))); + let before = h.state().ui.views[0].clone(); + let p = screen_of(h.state(), [300.0, 100.0]); + right_click(&mut h, p, modifiers); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().ui.tool, tool); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (before.zoom, before.center)); + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dc4f36dddb..3ffcc17d12 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,7 +85,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out keep the view centre with the selected Zoom tool and Zoom Clicked Point to Center off; other cases retain their pressed-point behaviour; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. From 4c46db6206dfb6fd0db77fef1f0d187de3bc1fed Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:14:56 +0000 Subject: [PATCH 17/77] fix(ui): retain held-Z menu anchoring --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 4 +-- .../zoom_menu_anchor_tests.rs | 34 +++++++++++++++++++ docs/control-protocol.md | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 044f040c72..ff714d0f80 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -916,7 +916,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; true } -/// A view menu row. The selected Zoom tool's steps keep the view centre unless clicked-point +/// A view menu row. The non-temporary Zoom tool's steps keep the view centre unless clicked-point /// centring is enabled. Other view-tool cases retain their pressed-point anchoring. fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { let i = app.session.active_index().ok_or("no document")?; @@ -935,7 +935,7 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co } let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; - if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && !center_on_point { + if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && !center_on_point { return Ok(()); } let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index f0baa53b8a..3aa3d052f0 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -110,3 +110,37 @@ fn held_zoom_keys_still_open_no_view_menu() { } } } + +#[test] +fn held_z_menu_keeps_its_existing_pressed_point_anchor() { + for label in ["Zoom In", "Zoom Out"] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Brush; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(4); + h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert!(h.state().ui.spring.as_ref().is_some_and(|s| s.previous == Tool::Brush)); + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label(label).click(); + h.run_steps(3); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{label}: a held Z keeps today's anchor"); + h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ffcc17d12..598ccd47c6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,7 +85,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out keep the view centre with the selected Zoom tool and Zoom Clicked Point to Center off; other cases retain their pressed-point behaviour; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out keep the view centre with the non-temporary Zoom tool and Zoom Clicked Point to Center off; other cases retain their pressed-point behaviour; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. From a97555e273923390240990c90ccf6e3f2fe49114 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:43:00 +0000 Subject: [PATCH 18/77] fix(ui): preserve menu anchoring for held Zoom bindings --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 2 +- .../zoom_menu_anchor_tests.rs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index ff714d0f80..e174e93fa1 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -935,7 +935,7 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co } let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; - if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && !center_on_point { + if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && crate::hold_keys::held_tool(app, ctx).is_none() && !center_on_point { return Ok(()); } let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index 3aa3d052f0..5cb270385c 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -111,6 +111,48 @@ fn held_zoom_keys_still_open_no_view_menu() { } } +#[test] +fn held_zoom_with_zoom_selected_keeps_the_menu_pressed_point_anchor() { + for animated in [false, true] { + for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let mut h = harness(app); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }); + h.step(); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut)); + let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + h.event(egui::Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::ALT }); + h.step(); + } + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.tool == Tool::Zoom)); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut)); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, command); + assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300])); + assert_ne!(h.state().ui.views[0].center, center, "{command}: preserve the held tool's pressed-point behavior"); + h.step(); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.11); + h.step(); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: anchor during animation"); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3); + h.step(); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: settled anchor"); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0); + } + } +} + #[test] fn held_z_menu_keeps_its_existing_pressed_point_anchor() { for label in ["Zoom In", "Zoom Out"] { From e25be5e77bfc440cd21e7c8577a2a0a264d54b2a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 11:57:16 -0600 Subject: [PATCH 19/77] Magic Wand: Anti-alias keeps the region whole and softens only near-matching neighbours (#11776) --- crates/algo/src/selection.rs | 89 ++++++------ crates/algo/src/selection/tests.rs | 4 +- .../src/selection/wand_antialias_tests.rs | 128 ++++++++++++++++++ 3 files changed, 174 insertions(+), 47 deletions(-) create mode 100644 crates/algo/src/selection/wand_antialias_tests.rs diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index daf7a2526a..24126cf9f2 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -219,11 +219,44 @@ pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, } } if anti_alias { - antialias(&mut m, w, h); + let tol = (tolerance + 1e-3).floor().max(0.0); + let src = m.clone(); + for (i, v) in m.iter_mut().enumerate() { + if src[i] == 0.0 && touches_region(i % w, i / w, w, h, |x, y| src[y * w + x] > 0.0) { + let p = px[i]; + let over = (0..3).map(|c| (p[c] - target[c]).abs() * 255.0).fold(0.0f32, f32::max) - tol; + *v = f32::from(fringe_coverage(over, p[3].min(target[3]) * 255.0)) / 255.0; + } + } } m } +/// How many levels past the tolerance the anti-aliased fringe fades over, from fully selected to +/// nothing. Measured in the reference application (#11776): 3 levels past gives 251, 5 gives +/// 249 and 123 gives 110; a straight fade to zero at 216 levels gives all three. +const FRINGE_LEVELS: f32 = 216.0; + +/// Coverage (0–255) of an anti-aliased fringe pixel: a neighbour of the region whose most +/// different colour channel is `over` levels past the tolerance, and `alpha` (0–255) the lower +/// of its and the clicked pixel's opacity. Photoshop keeps the region itself fully selected and +/// softens only the pixels just outside it, fading with the distance past the tolerance; a +/// neighbour without colour (transparent, next to a shape on its own layer) gets none. +fn fringe_coverage(over: f32, alpha: f32) -> u8 { + let k = (1.0 - over.max(0.0) / FRINGE_LEVELS).clamp(0.0, 1.0) * (alpha / 255.0).clamp(0.0, 1.0); + (k * 255.0).round() as u8 +} + +/// True when one of the 8 neighbours of `(x, y)` (inside a `w` x `h` area) is in the region. +fn touches_region(x: usize, y: usize, w: usize, h: usize, selected: impl Fn(usize, usize) -> bool) -> bool { + (-1i32..=1).any(|dy| { + (-1i32..=1).any(|dx| { + let (nx, ny) = (x as i32 + dx, y as i32 + dy); + (dx, dy) != (0, 0) && nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && selected(nx as usize, ny as usize) + }) + }) +} + /// Straight RGBA8 copy of `area` of a surface, read in parallel bands on native. pub fn rgba8_image(s: &Surface, area: Rect) -> Vec<[u8; 4]> { let (w, h) = (area.width() as usize, area.height() as usize); @@ -356,25 +389,12 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 let x = x0 + xx; let v = at(x, y); *o = v; - if !anti_alias { - continue; - } - // Same rule as `antialias`: edge pixels blend with their 3×3 neighbourhood; neighbours - // outside the area count as the pixel itself. - let mut sum = 0u32; - let mut edge = false; - for dy in -1i32..=1 { - for dx in -1i32..=1 { - let (nx, ny) = (x as i32 + dx, y as i32 + dy); - let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { at(nx as usize, ny as usize) }; - edge |= n != v; - sum += n as u32; - } - } - if edge { - let avg = sum as f32 / (9.0 * 255.0); - let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg }; - *o = (f * 255.0).round() as u8; + // Same rule as `magic_wand`: the region stays fully selected; a pixel just outside + // it gets a fringe by how far its colour is past the tolerance. + if anti_alias && v == 0 && touches_region(x, y, w, h, |nx, ny| at(nx, ny) != 0) { + let p = img[y * w + x]; + let d = (0..3).map(|c| (p[c] as i32 - target[c] as i32).abs()).max().unwrap_or(0); + *o = fringe_coverage((d - tol) as f32, f32::from(p[3].min(target[3]))); } } }; @@ -435,32 +455,6 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti } } -/// Softens hard mask edges: edge pixels become the 3×3 average. -fn antialias(m: &mut [f32], w: usize, h: usize) { - let src = m.to_vec(); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let mut sum = 0.0; - let mut cnt = 0.0; - let mut edge = false; - for dy in -1i32..=1 { - for dx in -1i32..=1 { - let (xx, yy) = (x as i32 + dx, y as i32 + dy); - let v = if xx < 0 || yy < 0 || xx >= w as i32 || yy >= h as i32 { src[i] } else { src[yy as usize * w + xx as usize] }; - edge |= v != src[i]; - sum += v; - cnt += 1.0; - } - } - if edge { - // Keep selected pixels mostly selected, add a soft fringe. - m[i] = if src[i] > 0.5 { 0.5 + 0.5 * sum / cnt } else { 0.5 * sum / cnt }; - } - } - } -} - /// Colour Range: coverage falls off linearly with the per-channel colour /// distance; `fuzziness` in levels (0–200). pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec { @@ -780,10 +774,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { mod distance; #[cfg(test)] mod ellipse_aa_edge_tests; + mod inverse; mod lasso; mod marquee; mod morphology; +#[cfg(test)] +mod wand_antialias_tests; pub use distance::edt; pub use inverse::inverse_surface; diff --git a/crates/algo/src/selection/tests.rs b/crates/algo/src/selection/tests.rs index 4e7bdcbe06..5f02b53a58 100644 --- a/crates/algo/src/selection/tests.rs +++ b/crates/algo/src/selection/tests.rs @@ -106,7 +106,9 @@ fn wand_contiguous_vs_global() { let g = magic_wand(&px, a, (3, 3), 10.0, false, false); assert_eq!(g.iter().filter(|v| **v > 0.0).count(), 80); let aa = magic_wand(&px, a, (3, 3), 10.0, true, true); - assert!(aa[3 * 20 + 6] > 0.0 && aa[3 * 20 + 6] < 1.0); + // #11776: anti-aliasing keeps the region whole and adds no fringe next to a colour this far + // past the tolerance (white is 245 levels from red, beyond the fringe's fade). + assert_eq!((aa[3 * 20 + 5], aa[3 * 20 + 6]), (1.0, 0.0)); assert!(magic_wand(&px, a, (50, 3), 10.0, true, false).iter().all(|v| *v == 0.0)); } diff --git a/crates/algo/src/selection/wand_antialias_tests.rs b/crates/algo/src/selection/wand_antialias_tests.rs new file mode 100644 index 0000000000..6d435e12cf --- /dev/null +++ b/crates/algo/src/selection/wand_antialias_tests.rs @@ -0,0 +1,128 @@ +//! The Magic Wand's Anti-alias against the reference measurement (#11776): the region itself +//! stays fully selected, and a one-pixel soft fringe lies only outside it, next to colours just +//! past the tolerance. Document 800 x 600: a solid red rectangle 100,100–300,250 on a transparent +//! layer, and a grey ramp of ten 40 px blocks (grey 100, 105, …, 145) from x 100 at y 350–450, +//! on its own transparent layer or composited on white (Sample All Layers). + +use photocraft_geom::Rect; + +use super::{Region, magic_wand, wand_region}; + +const AREA: Rect = Rect { x0: 0, y0: 0, x1: 800, y1: 600 }; +const W: usize = 800; +const H: usize = 600; + +fn red_rectangle() -> Vec<[u8; 4]> { + let mut img = vec![[0u8; 4]; W * H]; + for y in 100..250 { + for x in 100..300 { + img[y * W + x] = [220, 30, 30, 255]; + } + } + img +} + +/// The grey ramp at y 350–450 on a transparent layer, or on white when `on_white`. +fn ramp(on_white: bool) -> Vec<[u8; 4]> { + let mut img = vec![if on_white { [255u8; 4] } else { [0u8; 4] }; W * H]; + for y in 350..450 { + for b in 0..10u8 { + let g = 100 + 5 * b; + for x in 100 + 40 * b as usize..140 + 40 * b as usize { + img[y * W + x] = [g, g, g, 255]; + } + } + } + img +} + +/// (bounds of every touched pixel, fully selected count, partly selected count). +fn stats(r: &Region) -> (Rect, usize, usize) { + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + let (mut full, mut part) = (0, 0); + for y in r.bbox.y0..r.bbox.y1 { + for x in r.bbox.x0..r.bbox.x1 { + let v = (r.at(x, y) * 255.0).round() as u8; + if v == 0 { + continue; + } + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + if v == 255 { full += 1 } else { part += 1 } + } + } + (Rect::new(x0, y0, x1, y1), full, part) +} + +fn coverage(r: &Region, x: i32, y: i32) -> u8 { + (r.at(x, y) * 255.0).round() as u8 +} + +#[test] +fn a_shape_next_to_a_clearly_different_colour_gets_no_fringe() { + let img = red_rectangle(); + for aa in [true, false] { + let r = wand_region(&img, AREA, (150, 150), 32.0, true, aa).unwrap(); + assert_eq!(stats(&r), (Rect::new(100, 100, 300, 250), 30000, 0), "anti-alias {aa}"); + } +} + +#[test] +fn the_grey_ramp_gets_one_nearly_selected_column_past_the_tolerance() { + let img = ramp(false); + // Tolerance 32 from grey 100: blocks 100 to 130 (x 100–380) fully selected; the 135 block + // is 3 levels past it, so its first column is nearly selected. + let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap(); + let (bounds, full, part) = stats(&r); + assert_eq!((full, part), (280 * 100, 100), "{bounds:?}"); + assert_eq!(bounds, Rect::new(100, 350, 381, 450)); + for y in [350, 400, 449] { + assert_eq!(coverage(&r, 379, y), 255, "the region's last column stays fully selected"); + assert_eq!(coverage(&r, 380, y), 251, "one fringe column of the next block, as measured"); + assert_eq!(coverage(&r, 381, y), 0, "only one column"); + } + // Transparent pixels above and below the ramp have no colour: no fringe there. + assert_eq!(coverage(&r, 200, 349), 0); + assert_eq!(coverage(&r, 200, 450), 0); + // Tolerance 10: x 100–220 plus one partly selected column of the 115 block. + let r = wand_region(&img, AREA, (110, 400), 10.0, true, true).unwrap(); + let (bounds, full, part) = stats(&r); + assert_eq!((bounds, full, part), (Rect::new(100, 350, 221, 450), 120 * 100, 100)); + assert_eq!(coverage(&r, 220, 400), 249, "5 levels past tolerance 10, as measured"); +} + +#[test] +fn anti_alias_off_has_no_partly_selected_pixels() { + let img = ramp(false); + let r = wand_region(&img, AREA, (110, 400), 32.0, true, false).unwrap(); + assert_eq!(stats(&r), (Rect::new(100, 350, 380, 450), 28000, 0)); +} + +#[test] +fn the_8_bit_and_f32_wands_draw_the_same_fringe() { + let img = ramp(false); + let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect(); + for (tol, contiguous) in [(32.0, true), (10.0, true), (32.0, false)] { + let r = wand_region(&img, AREA, (110, 400), tol, contiguous, true).unwrap(); + let m = magic_wand(&f, AREA, (110, 400), tol, contiguous, true); + for y in 340..460 { + for x in 90..520 { + let want = (m[y as usize * W + x as usize] * 255.0).round() as u8; + assert_eq!(coverage(&r, x, y), want, "tolerance {tol}, contiguous {contiguous} at {x},{y}"); + } + } + } +} + +#[test] +fn on_the_composite_the_white_around_the_ramp_gets_the_measured_fringe() { + // Sample All Layers: the ramp on white. White is 155 levels from grey 100, 123 past + // tolerance 32; the reference application's fringe there reads 110 (Photoshop 2025, the + // same points; the issue reports 131–177 around the ramp). + let img = ramp(true); + let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap(); + for (x, y) in [(200, 349), (200, 450), (99, 400), (99, 349)] { + assert_eq!(coverage(&r, x, y), 110, "{x},{y}"); + } + assert_eq!(coverage(&r, 380, 400), 251, "the near-miss block still gets the stronger fringe"); + assert_eq!(coverage(&r, 200, 400), 255, "inside stays fully selected"); +} From 16b842a67fb6f56f549fd8919a48603b50400d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 7 Nov 2026 19:06:33 +0100 Subject: [PATCH 20/77] fix(engine): the Type tool leaves a selection in Indexed Color In an Indexed Color or a Bitmap document Photoshop keeps no type layer: the Type tool still lets you click and type and shows the text while editing, but committing it leaves a selection in the shape of the glyphs and no layer, as one history state named "New Type Layer", and paints nothing. commit_here already made that result for the Type Mask tools, with type.createSelection and removeLayer in the typing session's one step, so the commit now runs it for a new Type tool layer in those two modes as well, and names the step back after type.create's own label. The mode check is the engine's (type_cmds::commits_as_selection) and not the type.create command's: that command is the primitive the tools make their working layer with, on the first click already with text in it while the placeholder preference is on, so a mode branch there would leave nothing to type into. Tests: cmd::type_indexed_mode_tests pins the commit in both modes (no type layer, the selection the Type Mask of the same text makes, one state named "New Type Layer", every pixel unchanged, one undo back), RGB, Grayscale, CMYK and Lab at 8, 16 and 32 bit, the command's own role, and a refused conversion; editing.rs pins the tool's commit and its cancel through the canvas' own pointer_up, insert and commit. Fixes #12041 --- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/type_cmds.rs | 36 ++- .../engine/src/cmd/type_indexed_mode_tests.rs | 220 ++++++++++++++++++ crates/ui-egui/src/tools/type_tool/editing.rs | 137 +++++++++++ 4 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/type_indexed_mode_tests.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c4addf4c22..a140b5db5d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -150,6 +150,8 @@ pub mod type_area_cmds; pub mod type_caret_cmds; pub mod type_cmds; pub mod type_extra_cmds; +#[cfg(test)] +mod type_indexed_mode_tests; pub mod type_mask_cmds; pub mod type_spell_cmds; pub mod type_styles_cmds; diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index d05c05f46a..16d3d29a91 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -use photocraft_color::{Color, PixelFormat}; +use photocraft_color::{Color, ColorMode, PixelFormat}; use photocraft_doc::text::{ AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, @@ -937,6 +937,38 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result Type > "Fill new type layers with placeholder +/// text" (on by default) already gives that first call non-empty text. Branching there would make +/// the click produce a selection and no layer, so there would be nothing to type into - while the +/// reference application still lets you click and type in these modes and shows the text while +/// editing. Only its commit differs. +/// - `type.create` is also what automation, actions and a script's new text layer go through, and +/// the measured difference is the tool's. +pub fn commits_as_selection(mode: ColorMode) -> bool { + matches!(mode, ColorMode::Indexed | ColorMode::Bitmap) +} + +/// Name the step the Type tool's commit made in such a document (#12041). The commit runs the Type +/// Mask tools' `type.createSelection` with `removeLayer` - the same conversion, coalesced into the +/// one step of the typing session - and that command names the step "Type Mask", while Photoshop's +/// history state here is the type layer's own name. +pub fn name_selection_commit(s: &mut Session) { + if let Some(st) = s.active_mut() { + st.history.set_current_label(NEW_TYPE_LAYER_LABEL); + } +} + const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##; pub fn specs() -> Vec { @@ -953,7 +985,7 @@ pub fn specs() -> Vec { let mut t = new_text_layer(s, p, "type.create")?; let given = p.get("name").and_then(Value::as_str).map(str::to_string); let name = given.clone().unwrap_or_else(|| layer_name(&t.text)); - let id = s.edit("New Type Layer", |doc, active| { + let id = s.edit(NEW_TYPE_LAYER_LABEL, |doc, active| { refresh(doc, &mut t); let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Text(t)), crate::layer_place::Place::AutoEditable); *active = Some(id); diff --git a/crates/engine/src/cmd/type_indexed_mode_tests.rs b/crates/engine/src/cmd/type_indexed_mode_tests.rs new file mode 100644 index 0000000000..d60899c96f --- /dev/null +++ b/crates/engine/src/cmd/type_indexed_mode_tests.rs @@ -0,0 +1,220 @@ +//! #12041: committing Type-tool text in an Indexed Color or a Bitmap document. +//! +//! The reference application still lets you click and type there and shows the text while editing; +//! the commit leaves **no type layer** and a selection in the shape of the glyphs, as one history +//! state named "New Type Layer", and paints nothing. [`super::type_cmds::commits_as_selection`] is +//! that mode check and [`super::type_cmds::name_selection_commit`] the step's name; the conversion +//! is the Type Mask tools' `type.createSelection` with `removeLayer`, coalesced into the typing +//! session, which is what [`commit`] below runs and what `ui-egui`'s `commit_here` now does for the +//! Type tool in these modes. +//! +//! `type.create` itself stays mode-agnostic on purpose, and +//! [`the_type_layer_command_keeps_making_the_tools_working_layer`] pins that: the tools make the +//! layer they edit and show on canvas with that command, on the first click already with text in it +//! when Preferences > Type > "Fill new type layers with placeholder text" is on (the default), so a +//! mode branch there would take away the layer the session types into. + +use super::type_cmds::{commits_as_selection, name_selection_commit}; +use super::*; + +/// The typing session's coalesce key, as `ui-egui`'s `session_key` gives one. +const SESSION: &str = "type-session-12041"; + +/// The text the tool commits, at a baseline inside every document used here. `coalesce` for the +/// tool's own calls (one step for the session), without it for a script's or the pin's. +fn typed(coalesce: bool) -> Value { + let mut p = json!({ "text": "Hi", "x": 20, "y": 80, "size": 48 }); + if coalesce { + p["coalesce"] = json!(SESSION); + } + p +} + +/// A blank RGB document, to be converted to the mode under test. +fn blank() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 240, "height": 120, "background": "transparent"})).unwrap(); + s +} + +fn indexed() -> Session { + let mut s = blank(); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap(); + assert_eq!(mode(&s), ColorMode::Indexed, "the setup made an Indexed Color document"); + s +} + +fn bitmap() -> Session { + let mut s = blank(); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap(); + assert_eq!(mode(&s), ColorMode::Bitmap, "the setup made a Bitmap document"); + s +} + +fn mode(s: &Session) -> ColorMode { + s.active().unwrap().doc.mode +} + +/// The Type tool's commit of the session's working layer in a document [`commits_as_selection`] +/// names: the Type Mask tools' conversion, in the session's one step, which keeps the name the step +/// was opened with. +fn commit(s: &mut Session, layer: u64) { + s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).unwrap(); + name_selection_commit(s); +} + +fn text_layers(s: &Session) -> usize { + s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() +} + +fn layers(s: &Session) -> usize { + s.active().unwrap().doc.walk().len() +} + +/// The selection's coverage over the whole canvas, in document pixels. +fn coverage(s: &Session) -> Vec { + let d = s.active().unwrap().doc.clone(); + let b = d.bounds(); + let mut out = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + out.push(d.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0))); + } + } + out +} + +/// The selection's bounds, as its corners. +fn selection_corners(s: &Session) -> Option<[i32; 4]> { + s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()).map(|r| [r.x0, r.y0, r.x1, r.y1]) +} + +/// Every layer's pixels, quantised: what the commit must leave exactly as it was. +fn pixels(s: &Session) -> Vec { + let d = s.active().unwrap().doc.clone(); + let b = d.bounds(); + let mut out = Vec::new(); + for (_, _, l) in d.walk() { + let Some(surface) = l.surface() else { continue }; + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + out.extend(surface.rgba(x, y).map(|v| (v * 255.0).round() as u8)); + } + } + } + out +} + +fn history(s: &Session) -> Vec { + s.active().unwrap().history.entries() +} + +#[test] +fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected_and_no_layer() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let (steps, layers_before, pixels_before) = (history(&s).len(), layers(&s), pixels(&s)); + + // The tool's session: a type layer to edit and show while typing, as measured. + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(text_layers(&s), 1, "{name}: typing happens on a layer the tool can show"); + + commit(&mut s, layer); + + assert_eq!(text_layers(&s), 0, "{name}: committing leaves no type layer"); + assert_eq!(layers(&s), layers_before, "{name}: the working layer went with it"); + assert!(s.active().unwrap().doc.selection.is_some(), "{name}: the glyphs are selected"); + let selected = s.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(!selected.content_bounds().is_empty(), "{name}: the selection has a shape"); + assert!(coverage(&s).iter().any(|v| *v > 0.0), "{name}: the selected pixels are the glyphs'"); + assert_eq!(pixels(&s), pixels_before, "{name}: the commit paints nothing"); + + // One step for the whole session, named as Photoshop names it. + let h = history(&s); + assert_eq!(h.len(), steps + 1, "{name}: the session is one history state: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: its name"); + + // And undoing that one state gives the document back as it was. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: undo takes the selection"); + assert_eq!(layers(&s), layers_before, "{name}: undo takes the whole session"); + assert_eq!(pixels(&s), pixels_before, "{name}: nothing else was changed"); + } +} + +#[test] +fn the_selection_is_the_glyph_shape_the_type_mask_of_the_same_text_makes() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + commit(&mut s, layer); + let (got, got_bounds) = (coverage(&s), selection_corners(&s)); + + // The pin: the same text straight through the Type Mask command. + let mut pin = make(); + pin.execute("type.createSelection", typed(false)).unwrap(); + assert_eq!(got_bounds, selection_corners(&pin), "{name}: same selection bounds"); + assert_eq!(got, coverage(&pin), "{name}: same selection coverage"); + } +} + +#[test] +fn rgb_grayscale_cmyk_and_lab_keep_making_type_layers_at_every_depth() { + for (id, want) in [("rgb", ColorMode::Rgb), ("gray", ColorMode::Grayscale), ("cmyk", ColorMode::Cmyk), ("lab", ColorMode::Lab)] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 240, "height": 120, "mode": id, "depth": depth, "background": "transparent"})).unwrap(); + assert_eq!(mode(&s), want, "{id} at {depth} bit: the document really is that mode"); + assert!(!commits_as_selection(want), "{id} keeps type layers"); + + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + // Today's commit: rename the session's layer in the same step, select nothing. + let name = super::type_cmds::layer_name("Hi"); + s.execute("type.edit", json!({"layer": layer, "name": name, "coalesce": SESSION})).unwrap(); + assert_eq!(text_layers(&s), 1, "{id} at {depth} bit keeps its type layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{id} at {depth} bit selects nothing"); + let h = history(&s); + assert_eq!(h.len(), 2, "{id} at {depth} bit: open plus one step for the session: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{id} at {depth} bit keeps the step's name"); + } + } +} + +#[test] +fn the_type_layer_command_keeps_making_the_tools_working_layer_in_these_modes() { + // `type.create` is the primitive the Type tools create the layer they edit with (and scripts and + // actions their text layers with), so the colour-mode branch is the commit's, not its own. + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let id = s.execute("type.create", typed(false)).unwrap()["layer"].as_u64().unwrap(); + assert!(matches!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Text(_)), "{name}: the command still makes the layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: and selects nothing itself"); + assert_eq!(history(&s).last().map(String::as_str), Some("New Type Layer"), "{name}: its own step name"); + } +} + +#[test] +fn a_refused_conversion_leaves_the_typed_text_and_the_document_alone() { + // The commit's fallback in `ui-egui` keeps today's type layer when the conversion is refused, + // so the typed text is not lost with a selection that was never made. Here the document would + // have to keep no layer at all, which is what `type.createSelection` with `removeLayer` refuses. + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let only = s.active().unwrap().doc.layers[0].id.0; + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.delete", json!({"layer": only})).unwrap(); + let (pixels_before, steps, label) = (pixels(&s), history(&s).len(), history(&s).last().cloned()); + + assert!( + s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).is_err(), + "{name}: the conversion is refused" + ); + // `commit_here` stops there and keeps the layer, so the step is never renamed either. + assert_eq!(text_layers(&s), 1, "{name}: the typed text stays as a type layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: nothing was selected"); + assert_eq!(pixels(&s), pixels_before, "{name}: the refused step painted nothing"); + assert_eq!(history(&s).len(), steps, "{name}: the refused step is no new state"); + assert_eq!(history(&s).last().cloned(), label, "{name}: the refused conversion renames nothing"); + } +} diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index f93826c1d1..20eccbce41 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -473,6 +473,27 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { } return; } + // #12041: an Indexed Color or Bitmap document has no type layer to keep. The session's working + // layer is still edited and shown while typing, as the reference application shows it, and the + // commit makes what a Type Mask tool's commit makes: the glyphs become the selection and the + // layer goes, in the typing session's one history step - which keeps the "New Type Layer" name + // it was opened with, as Photoshop's state there is named. + // `photocraft_engine::type_cmds::commits_as_selection` says why the check belongs to the commit + // and not to the `type.create` command. Two cases are not measured in these modes and keep + // today's behaviour: an edit of a layer the session did not create (`ed.created`), and a commit + // with no text in it, which is the empty-layer clean-up below. + let leaves_a_selection = ed.created && app.session.active().is_some_and(|st| photocraft_engine::type_cmds::commits_as_selection(st.doc.mode)); + if leaves_a_selection && !text.trim().is_empty() { + if let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { + // The refused step left the document alone: keep today's type layer, so what was typed + // is not lost with a selection that was never made. + app.ui.status = e; + app.ui.status_error = true; + return; + } + photocraft_engine::type_cmds::name_selection_commit(&mut app.session); + return; + } if text.trim().is_empty() && ed.created { let _ = app.run("layer.delete", json!({"layer": ed.layer})); } else if ed.created { @@ -526,3 +547,119 @@ fn undo_session(app: &mut PhotocraftApp, ed: &TextEdit) { let _ = app.run("edit.undo", json!({})); } } + +/// #12041: what the Type tool's commit does in a document whose colour mode has no type layers to +/// keep. The engine side - the mode check, the conversion it reuses and the step's name - is pinned +/// in `photocraft-engine`'s `cmd::type_indexed_mode_tests`; this is the tool's own commit, which is +/// where the difference is measured. +#[cfg(test)] +mod indexed_commit_tests { + use super::{cancel, commit, insert}; + use crate::PhotocraftApp; + use crate::state::Tool; + use crate::tools::type_tool::pointer_up; + use photocraft_doc::{ColorMode, LayerContent}; + use serde_json::json; + + /// A new transparent document in `mode`: `rgb` keeps today's behaviour, `indexed` and `bitmap` + /// are the two the issue is about. + fn app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200, "background": "transparent"})).unwrap(); + match mode { + "indexed" => { + app.run("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap(); + assert_eq!(doc_mode(&app), ColorMode::Indexed, "the document is Indexed Color"); + } + "bitmap" => { + app.run("image.mode.grayscale", json!({})).unwrap(); + app.run("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap(); + assert_eq!(doc_mode(&app), ColorMode::Bitmap, "the document is Bitmap"); + } + _ => assert_eq!(doc_mode(&app), ColorMode::Rgb, "the document is RGB"), + } + app.sync_views(); + app.ui.tool = Tool::Type; + app + } + + fn doc_mode(app: &PhotocraftApp) -> ColorMode { + app.session.active().unwrap().doc.mode + } + + fn text_layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() + } + + fn layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.walk().len() + } + + fn states(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() + } + + fn last_state(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().last().cloned() + } + + fn selection(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()) + } + + /// The reference application, in an Indexed Color and in a Bitmap document: clicking and typing + /// works and the text is shown while editing, and the commit leaves no type layer but a + /// selection in the shape of the glyphs, as the one history state named "New Type Layer". + #[test] + fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected() { + // The bounds the same text covers where it does become a layer. + let mut rgb = app("rgb"); + pointer_up(&mut rgb, [80.0, 120.0], [80.0, 120.0]); + insert(&mut rgb, "Hi"); + commit(&mut rgb); + let glyphs = rgb.session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().content_bounds(); + assert_eq!(text_layers(&rgb), 1, "an RGB document keeps its type layer"); + assert!(selection(&rgb).is_none(), "and selects nothing"); + assert_eq!(last_state(&rgb).as_deref(), Some("New Type Layer"), "as the session's one state"); + + for mode in ["indexed", "bitmap"] { + let mut app = app(mode); + let (layers_before, states_before) = (layers(&app), states(&app)); + + // Typing: the working layer is there, and shown, as measured. + pointer_up(&mut app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut app, "Hi"); + assert!(app.ui.text_edit.is_some(), "{mode}: the session is open"); + assert_eq!(text_layers(&app), 1, "{mode}: the text is edited on a layer"); + + commit(&mut app); + assert!(app.ui.text_edit.is_none(), "{mode}: the commit ends the session"); + assert_eq!(text_layers(&app), 0, "{mode}: committing leaves no type layer"); + assert_eq!(layers(&app), layers_before, "{mode}: the working layer went with it"); + assert_eq!(selection(&app), Some(glyphs), "{mode}: the typed glyphs are the selection"); + assert_eq!(states(&app), states_before + 1, "{mode}: one state for the session"); + assert_eq!(last_state(&app).as_deref(), Some("New Type Layer"), "{mode}: and its name"); + + app.run("edit.undo", json!({})).unwrap(); + assert!(selection(&app).is_none(), "{mode}: one undo takes the selection"); + assert_eq!(layers(&app), layers_before, "{mode}: and the whole session"); + } + } + + /// Cancelling such a session is unchanged: no layer and no selection are left, as elsewhere. + #[test] + fn cancelling_in_indexed_color_or_bitmap_leaves_nothing() { + for mode in ["rgb", "indexed", "bitmap"] { + let mut app = app(mode); + let (layers_before, states_before) = (layers(&app), states(&app)); + pointer_up(&mut app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut app, "Hi"); + cancel(&mut app); + assert!(app.ui.text_edit.is_none(), "{mode}: the session ended"); + assert_eq!(text_layers(&app), 0, "{mode}: no layer is left"); + assert_eq!(layers(&app), layers_before, "{mode}: no layer is added"); + assert!(selection(&app).is_none(), "{mode}: nothing is selected"); + assert_eq!(states(&app), states_before, "{mode}: and no history state"); + } + } +} From 5c0bec3036f408adf2670f4197215ebb62ff286f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 7 Nov 2026 19:36:53 +0100 Subject: [PATCH 21/77] fix(engine): grey out Straighten Layer where the reference does Straighten Layer was offered wherever a ruler line existed, so its button was live for every target and the command turned whatever it was pointed at: it rotated and cropped the canvas on the Background, turned several selected layers or a group, failed on a locked layer with `layer "..." is locked`, and rotated pixels nobody could see on a hidden layer. The reference application (#12033) keeps the button off for the Background, a multi-layer selection, a group, Lock All and Lock Position, and answers a hidden target layer with its alert and no change at all. The command's own `enabled` check now says the first set, so the options bar, the CLI, the control channel and an action step all agree about what is offered; the hidden layer stays enabled and is refused by the command, before it reads anything for the edit, so a refused click leaves the document, its History and the ruler line exactly as they were. The states the ticket marks *not measured* keep today's behaviour. --- crates/engine/src/cmd/actions_cmds/tests.rs | 19 +- crates/engine/src/cmd/analysis_cmds.rs | 65 ++++- crates/engine/src/cmd/analysis_cmds/tests.rs | 7 +- .../cmd/analysis_straighten_enabled_tests.rs | 259 ++++++++++++++++++ .../src/cmd/transform_cmds/multi_tests.rs | 20 +- crates/ui-egui/src/tools/analysis_ui.rs | 21 +- crates/ui-egui/src/tools/analysis_ui/tests.rs | 117 ++++++++ 7 files changed, 486 insertions(+), 22 deletions(-) create mode 100644 crates/engine/src/cmd/analysis_straighten_enabled_tests.rs diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 6a18a871f1..ed5da88e7d 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -334,11 +334,11 @@ fn record_drops_the_steps_of_a_command_that_failed_and_was_taken_back() { assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no Duplicate step for a copy that was taken back"); // Straighten rolls back when its crop is refused after the rotation ran. - ruled_background(&mut s, 8); + ruled_layer(&mut s, 8); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("refused".into())) } else { Ok(()) }); s.execute("actions.record", json!({"name": "Refused"})).unwrap(); - assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err()); + assert!(s.execute("image.analysis.straightenLayer", json!({"crop": true})).is_err()); assert_eq!(s.active().unwrap().doc.size.width, 80, "the rotation was taken back"); assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no rotation step for a rotation that was taken back"); } @@ -500,23 +500,32 @@ fn ruled_background(s: &mut Session, depth: u32) { .unwrap(); } +/// The same, with a pixel layer on top: since #12033 the Background alone greys Straighten Layer +/// out, so the tests that run the command target a layer and pass `crop` explicitly. +fn ruled_layer(s: &mut Session, depth: u32) { + ruled_background(s, depth); + s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap(); +} + #[test] fn record_command_that_runs_commands_replays_it_once() { // Straighten Layer runs Image Rotation and Crop itself: only its own call is recorded, // so playback rotates once, not three times (rotate, crop, then straighten again). for depth in [8, 16] { let mut s = Session::new(); - ruled_background(&mut s, depth); + ruled_layer(&mut s, depth); s.execute("actions.record", json!({"name": "Level"})).unwrap(); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); - s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + // #12033: the crop is asked for, as automation does; the Background it used to default to + // now greys the command out. + s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap(); s.execute("actions.stop", json!({})).unwrap(); let got = s.execute("actions.get", json!({"action": "Level"})).unwrap(); let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect(); assert_eq!(ids, ["image.analysis.rulerTool", "image.analysis.straightenLayer"], "depth {depth}"); let direct = s.active().unwrap().doc.clone(); - ruled_background(&mut s, depth); + ruled_layer(&mut s, depth); let before = s.active().unwrap().history.past_len(); let played = s.execute("actions.play", json!({"action": "Level"})).unwrap(); assert!(played.get("failed").is_none(), "depth {depth}: {played}"); diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index d9d7d1510c..76744c37fc 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -13,6 +13,11 @@ //! values the luminosity (0.30 R + 0.59 G + 0.11 B) of the composite in the document's value //! range (0–255 at 8 bits, 0–32768 at 16 bits, 0–1 at 32 bits), integrated density area × mean. //! Lengths and areas are scaled by the measurement scale. +//! +//! Straighten Layer is offered where the reference application offers it: the ruler line is needed, +//! and the target must be a single layer that is not the Background, not a group and not locked in +//! place ([`straighten_disabled`], #12033). A hidden target layer keeps the button and is refused +//! with [`STRAIGHTEN_HIDDEN_ERROR`] before anything changes. use std::sync::Arc; @@ -138,6 +143,53 @@ fn has_ruler(s: &Session) -> std::result::Result<(), String> { s.active().and_then(|d| d.doc.measurement.ruler).map(|_| ()).ok_or_else(|| "no ruler line".into()) } +/// The reference's alert for Straighten Layer on a hidden target layer (#12033), measured: one +/// alert window with an OK button, and the document and its History stay as they were. The button +/// is *enabled* for a hidden layer, so this is the command's refusal and not a greyed state; the +/// options bar shows the same text through `tl!` (English is the catalog source). +pub const STRAIGHTEN_HIDDEN_ERROR: &str = "Could not straighten the layer because the target layer is hidden."; + +/// The enabled check of `image.analysis.straightenLayer`: a ruler line, and a target the reference +/// straightens ([`straighten_disabled`], #12033). +fn has_straightenable_layer(s: &Session) -> std::result::Result<(), String> { + has_ruler(s)?; + straighten_disabled(s).map_or(Ok(()), |why| Err(why.into())) +} + +/// Why Straighten Layer is greyed out for the current target, `None` when its button is live +/// (#12033). Measured with a ruler line drawn: the Background, two or more selected layers, a +/// group, a layer with Lock All and a layer with Lock Position all grey it out. +/// +/// Deliberately not here: a hidden target layer keeps the button enabled and is refused by +/// [`straighten`] with [`STRAIGHTEN_HIDDEN_ERROR`], and the states the ticket marks *not measured* +/// (a layer with a mask, a shape, vector or adjustment layer, artboards, and the lock kinds other +/// than Lock All and Lock Position) keep today's behaviour. A document with no active layer keeps +/// turning the whole canvas as it does now. +fn straighten_disabled(s: &Session) -> Option<&'static str> { + let st = s.active()?; + if st.selected_layers().len() > 1 { + return Some("several layers selected"); + } + // The active layer is the one the Layers panel targets; with none, the command turns the + // canvas, which the reference was not measured for. + let id = st.active_layer?; + if st.doc.is_background(id) { + return Some("the Background"); + } + if st.doc.layer(id).is_some_and(|l| l.is_group()) { + return Some("a group"); + } + // Locks inherited from a group count, as they do for the Move tool. + let locks = st.doc.effective_locks(id); + if locks.all { + return Some("the layer is locked"); + } + if locks.position { + return Some("the layer's position is locked"); + } + None +} + fn has_log(s: &Session) -> std::result::Result<(), String> { if s.analysis.log.is_empty() { Err("the Measurement Log is empty".into()) } else { Ok(()) } } @@ -718,6 +770,12 @@ pub fn straighten_angle(r: &Ruler) -> f64 { fn straighten(s: &mut Session, p: &Value) -> Result { let d = doc(s)?; let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line".into()))?; + // A hidden target layer keeps its button and gets this alert instead of a rotation (#12033). + // Asked before anything is read for the edit, so the document, its History and the ruler line + // stay exactly as they were; a layer inside a hidden group is hidden too (#571). + if s.active().and_then(|st| st.active_layer).is_some_and(|id| d.path_of(id).is_some_and(|path| !d.shown_at(&path))) { + return Err(EngineError::Other(STRAIGHTEN_HIDDEN_ERROR.into())); + } let rot = straighten_angle(&r); if rot.abs() < 1e-9 { return Ok(json!({"angle": 0.0})); @@ -1297,8 +1355,8 @@ pub fn specs() -> Vec { "image.analysis.straightenLayer", "Straighten Layer", [], - r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image)"##, - has_ruler, + r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image; off for the Background, two or more selected layers, a group, Lock All and Lock Position, and a hidden target layer answers "Could not straighten the layer because the target layer is hidden." with nothing changed (#12033))"##, + has_straightenable_layer, straighten, true ), @@ -1332,5 +1390,8 @@ pub fn specs() -> Vec { ] } +#[cfg(test)] +#[path = "analysis_straighten_enabled_tests.rs"] +mod analysis_straighten_enabled_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/analysis_cmds/tests.rs b/crates/engine/src/cmd/analysis_cmds/tests.rs index dc76470bc9..6978b4d059 100644 --- a/crates/engine/src/cmd/analysis_cmds/tests.rs +++ b/crates/engine/src/cmd/analysis_cmds/tests.rs @@ -231,10 +231,13 @@ fn straighten_layer_and_image() { assert_eq!(s.active().unwrap().history.past_len(), before + 1, "one history step"); assert_eq!(d(&s).size.width, 80); assert!(d(&s).measurement.ruler.is_none()); - // The Background: rotate the canvas and crop to the image. + // The Background used to rotate the canvas and crop it to the image; #12033 greys that state + // out, so the same crop now happens on a layer and only when it is asked for. let mut s = session(80, 60, 16); s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [40, 40]})).unwrap(); - let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert!(!s.is_enabled("image.analysis.straightenLayer"), "the Background is greyed out (#12033)"); + s.execute("layer.new.layer", json!({})).unwrap(); + let r = s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap(); assert_eq!(r["cropped"], true); assert_eq!(r["angle"].as_f64().unwrap().abs(), 45.0); let nd = d(&s); diff --git a/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs new file mode 100644 index 0000000000..9e27c2ad8d --- /dev/null +++ b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs @@ -0,0 +1,259 @@ +//! Straighten Layer's enabled states and its hidden-layer refusal, measured against the reference +//! application (#12033). +//! +//! With a ruler line drawn the reference greys the button out for the Background, two or more +//! selected layers, a group, a layer with Lock All and a layer with Lock Position. A hidden target +//! layer keeps the button enabled: the click answers an alert ("Could not straighten the layer +//! because the target layer is hidden.") and nothing changes. Pixel, text and smart object layers +//! stay enabled and are rotated. +//! +//! A layer with a mask, a shape or vector layer, an adjustment layer, artboards and the other lock +//! kinds are *not measured* there: they keep today's behaviour, so the tests below only pin that +//! the new check does not reach them. + +use photocraft_doc::LayerId; + +use super::*; + +const CMD: &str = "image.analysis.straightenLayer"; + +/// A white document with a Background, as File > New makes one. +fn session(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap(); + s +} + +/// The same document in CMYK at 16 bits, which the reference also straightens. +fn cmyk_16(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "mode": "cmyk", "depth": 16, "background": "white"})).unwrap(); + s +} + +/// A ruler line to straighten by: without one the command is off for every target. +fn rule(s: &mut Session) { + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); +} + +/// A block of opaque pixels on the layer: an empty layer has nothing to transform, which would +/// hide the state under test behind "nothing to transform". The colour has the layer's own channel +/// count, so it works for RGB, Grayscale and CMYK at 8 and 16 bits alike. +fn paint(s: &mut Session, l: LayerId) { + s.edit("paint", |doc, _| { + let surf = doc.layer_mut(l).unwrap().surface_mut().unwrap(); + let mut px = vec![0.0; surf.channels()]; + if let Some(first) = px.first_mut() { + *first = 1.0; + } + if let Some(last) = px.last_mut() { + *last = 1.0; + } + surf.fill_rect(Rect::new(10, 10, 50, 20), &px); + Ok(()) + }) + .unwrap(); +} + +/// A new pixel layer with something on it, which `layer.new.layer` makes the active one. +fn pixel_layer(s: &mut Session, name: &str) -> LayerId { + let l = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()); + paint(s, l); + l +} + +/// A type layer over the canvas. +fn text_layer(s: &mut Session) -> LayerId { + LayerId(s.execute("type.create", json!({"x": 40, "y": 50, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap()) +} + +/// A pixel layer turned into a smart object. +fn smart_layer(s: &mut Session) -> LayerId { + let l = pixel_layer(s, "Smart"); + s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": l.0})).unwrap(); + l +} + +fn doc_of(s: &Session) -> Arc { + s.active().unwrap().doc.clone() +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// A state the reference greys the button out for: the engine says the command is off, so the menu +/// item and the options-bar button follow, and a call answered anyway (the CLI, the control channel, +/// an action step) is refused as a disabled command rather than run. Nothing is touched: the +/// document is the same `Arc`, no History step is added and the ruler line stays. +fn assert_greyed(s: &mut Session, what: &str) { + let (before, history) = (doc_of(s), steps(s)); + assert!(!s.is_enabled(CMD), "{what}: the button is greyed out"); + let e = s.execute(CMD, json!({})).unwrap_err(); + let disabled = matches!(&e, EngineError::Disabled(id, _) if id == CMD); + assert!(disabled, "{what}: refused as a disabled command, got {e}"); + assert!(Arc::ptr_eq(&before, &doc_of(s)), "{what}: the document was not touched"); + assert_eq!(steps(s), history, "{what}: no History step"); + assert!(doc_of(s).measurement.ruler.is_some(), "{what}: the ruler line is still there"); +} + +/// The angle the ruler line of [`rule`] straightens by. +const ROT: f64 = 4.7636; + +#[test] +fn the_background_greys_straighten_layer_out() { + // Today it rotates the canvas and crops it (800 x 600 becomes 500 x 375); the reference has no + // button for it at all. + let mut s = session(100, 80); + assert!(doc_of(&s).is_background(s.active().unwrap().active_layer.unwrap()), "the Background is the target"); + rule(&mut s); + assert_greyed(&mut s, "the Background"); +} + +#[test] +fn two_selected_layers_grey_straighten_layer_out() { + let mut s = session(100, 80); + let a = pixel_layer(&mut s, "One"); + let b = pixel_layer(&mut s, "Two"); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected"); + rule(&mut s); + assert_greyed(&mut s, "two selected layers"); + // Back to one layer, the button is live again. + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + assert!(s.is_enabled(CMD), "one selected layer"); +} + +#[test] +fn a_group_greys_straighten_layer_out() { + let mut s = session(100, 80); + pixel_layer(&mut s, "Inner"); + s.execute("layer.groupLayers", json!({})).unwrap(); + let g = s.active().unwrap().active_layer.unwrap(); + assert!(doc_of(&s).layer(g).unwrap().is_group(), "the new group is the target"); + rule(&mut s); + assert_greyed(&mut s, "a group"); + // A layer inside the group is a layer, so the button is on for it. + let inner = doc_of(&s).layer(g).unwrap().children().unwrap()[0].id; + s.execute("layer.select", json!({"layer": inner.0})).unwrap(); + assert!(s.is_enabled(CMD), "a layer in a group"); +} + +#[test] +fn lock_all_and_lock_position_grey_straighten_layer_out() { + // Measured: both grey the button. Today it is enabled and the command fails with + // `layer "Colors" is locked`, which is what a greyed button cannot do any more. + for key in ["all", "position"] { + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Colors"); + s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap(); + rule(&mut s); + assert_greyed(&mut s, &format!("Lock {}", if key == "all" { "All" } else { "Position" })); + } +} + +#[test] +fn the_other_lock_kinds_are_not_reached_by_the_new_check() { + // Not measured in the reference, so they keep today's behaviour: the button stays on and the + // command answers as it does now. + for key in ["transparency", "pixels", "artboard"] { + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Colors"); + s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap(); + rule(&mut s); + assert!(s.is_enabled(CMD), "Lock {key}: not measured, still enabled"); + } +} + +#[test] +fn a_hidden_layer_stays_enabled_and_is_refused_with_the_alert() { + // The one measured refusal: the button is on, the click shows the alert and nothing changes. + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Gone"); + s.execute("layer.setProps", json!({"layer": l.0, "visible": false})).unwrap(); + rule(&mut s); + let (before, history) = (doc_of(&s), steps(&s)); + assert!(s.is_enabled(CMD), "a hidden layer keeps the button on"); + let e = s.execute(CMD, json!({})).unwrap_err(); + assert_eq!(e.to_string(), STRAIGHTEN_HIDDEN_ERROR, "the reference's alert text"); + assert!(matches!(e, EngineError::Other(_)), "an alert, not a greyed state: {e}"); + assert!(Arc::ptr_eq(&before, &doc_of(&s)), "nothing changed"); + assert_eq!(steps(&s), history, "no History step"); + assert!(doc_of(&s).measurement.ruler.is_some(), "a straighten that never ran leaves the ruler line"); +} + +#[test] +fn a_layer_hidden_with_its_group_is_refused_the_same_way() { + // A layer inside a hidden group is hidden, as for the painting tools (#571). + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Inner"); + s.execute("layer.groupLayers", json!({})).unwrap(); + let g = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"layer": g.0, "visible": false})).unwrap(); + s.execute("layer.select", json!({"layer": l.0})).unwrap(); + rule(&mut s); + let (before, history) = (doc_of(&s), steps(&s)); + assert!(s.is_enabled(CMD), "the target is a layer, not the group"); + assert_eq!(s.execute(CMD, json!({})).unwrap_err().to_string(), STRAIGHTEN_HIDDEN_ERROR); + assert!(Arc::ptr_eq(&before, &doc_of(&s))); + assert_eq!(steps(&s), history); +} + +#[test] +fn without_a_ruler_line_the_button_stays_greyed_for_every_layer() { + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + assert!(!s.is_enabled(CMD), "no ruler line"); + s.execute("layer.setProps", json!({"layer": s.active().unwrap().active_layer.unwrap().0, "visible": false})).unwrap(); + assert!(!s.is_enabled(CMD), "a hidden layer without a ruler line stays greyed, not enabled"); +} + +#[test] +fn a_single_visible_layer_still_rotates_without_cropping() { + // The pin for the state that stays: the layer turns about the canvas centre, the canvas does not. + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + rule(&mut s); + let (history, size) = (steps(&s), (doc_of(&s).size.width, doc_of(&s).size.height)); + assert!(s.is_enabled(CMD), "a layer is what the button is for"); + let r = s.execute(CMD, json!({})).unwrap(); + assert_eq!(r["cropped"], false, "{r}"); + assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{r}"); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas keeps its size"); + assert_eq!(steps(&s), history + 1, "one History step"); + assert!(doc_of(&s).measurement.ruler.is_none(), "the ruler line is cleared"); + assert!(s.undo()); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "and one Undo takes it back"); +} + +#[test] +fn pixel_text_and_smart_object_layers_stay_enabled_and_rotate() { + // Measured, also in a CMYK 16-bit document. + type Maker = (&'static str, fn(&mut Session) -> LayerId); + let make: [Maker; 3] = [("pixel", |s| pixel_layer(s, "Paint")), ("type", text_layer), ("smart object", smart_layer)]; + for (what, make_layer) in make { + for new in [session as fn(u32, u32) -> Session, cmyk_16] { + let mut s = new(100, 80); + make_layer(&mut s); + rule(&mut s); + assert!(s.is_enabled(CMD), "{what}: enabled"); + let r = s.execute(CMD, json!({})); + let r = r.unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(r["cropped"], false, "{what}: rotated, not cropped"); + assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{what}: {r}"); + } + } +} + +#[test] +fn an_explicit_crop_still_crops_for_automation() { + // The `crop` param stays available to automation where the command is enabled. + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + rule(&mut s); + let r = s.execute(CMD, json!({"crop": true})).unwrap(); + assert_eq!(r["cropped"], true, "{r}"); + let d = doc_of(&s); + assert!(d.size.width < 100 && d.size.height < 80, "the canvas is rotated and cropped to {:?}", [d.size.width, d.size.height]); +} diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index a50e4239fd..ec0c817b12 100644 --- a/crates/engine/src/cmd/transform_cmds/multi_tests.rs +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -223,24 +223,24 @@ fn bad_layers_params_are_errors_not_panics() { } #[test] -fn straighten_layer_rotates_every_selected_layer() { - // Photoshop's Straighten Layer turns all the selected layers, not just the active one; it - // goes through `edit.transform` without a layer, so it takes the selection like Free Transform. +fn straighten_layer_wants_one_selected_layer() { + // #12033: the reference greys Straighten Layer out while two layers are selected, so it no + // longer turns the whole selection the way Free Transform does + // (`free_transform_moves_every_selected_layer_in_one_step` covers that). let (mut s, paint, text) = two_layers(); let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text)); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); let steps = s.active().unwrap().history.past_len(); - let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); - assert_eq!(r["cropped"], false); - assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too"); - assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned"); - assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); - assert!(s.undo()); - assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back"); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected, the type layer active"); + assert!(!s.is_enabled("image.analysis.straightenLayer"), "two selected layers"); + assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err()); + assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "nothing turned"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "and no history step"); // With one layer selected, only that layer turns. s.execute("layer.select", json!({"layer": paint.0})).unwrap(); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + assert!(s.is_enabled("image.analysis.straightenLayer"), "a single layer"); s.execute("image.analysis.straightenLayer", json!({})).unwrap(); assert!(bounds(&s, paint) != p0); assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index ed113909a6..39c8dc1e51 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -7,6 +7,10 @@ //! part of the serialisable UI state so the control channel can read and drive it). The Color //! Sampler points themselves live in the engine (`DocState.color_samplers`) so every front end //! shares them; this module only maps pointer input and draws the numbered markers. +//! +//! The Ruler options bar's Straighten Layer button is on when the engine's check says so, and a +//! hidden target layer answers its click with the reference's alert rather than the status line +//! (#12033). use egui::{Align2, Color32, CornerRadius, FontId, Pos2, RichText, Stroke, pos2, vec2}; use photocraft_doc::{Document, Ruler}; @@ -15,7 +19,7 @@ use serde_json::{Map, Value, json}; use crate::PhotocraftApp; use crate::canvas::{ToolEvent, ViewXform}; -use crate::state::Tool; +use crate::state::{DialogKind, Tool}; use crate::theme::Tokens; /// What a pointer drag is doing (not serialised). @@ -526,8 +530,19 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut app.ui.analysis.use_measurement_scale, tl!("Use Measurement Scale")); let has = doc.measurement.ruler.is_some(); - if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { - let _ = app.run("image.analysis.straightenLayer", json!({})); + // The engine decides this button's state, so it goes grey for exactly the targets + // Straighten Layer is off for: the Background, several selected layers, a group and a + // layer locked in place (#12033). + let straighten = app.session.is_enabled("image.analysis.straightenLayer"); + if ui.add_enabled_ui(straighten, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { + // A hidden target layer keeps the button on and answers the click with the + // reference's alert (its message and an OK button), changing nothing (#12033). + if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) + && why == photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR + { + let message = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR; + app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(message)}).as_object().cloned().unwrap_or_default()); + } } if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Clear"), 0.0)).inner.clicked() { let _ = app.run("image.analysis.rulerTool", json!({"clear": true})); diff --git a/crates/ui-egui/src/tools/analysis_ui/tests.rs b/crates/ui-egui/src/tools/analysis_ui/tests.rs index 3ed65a3ffd..502d83851a 100644 --- a/crates/ui-egui/src/tools/analysis_ui/tests.rs +++ b/crates/ui-egui/src/tools/analysis_ui/tests.rs @@ -296,3 +296,120 @@ fn count_size_fields_send_whole_numbers_the_engine_applies() { let g = &app.session.active().unwrap().doc.measurement.count_groups[0]; assert_eq!((g.marker_size, g.label_size), (8, 20)); } + +// ---------------------------------------------------------------- Straighten Layer button (#12033) + +/// The Ruler options bar, drawn wide enough for its buttons to show. +fn ruler_bar(mut app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + use egui::vec2; + app.ui.tool = Tool::Ruler; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui: &mut egui::Ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(4); + h +} + +/// A ruler line to straighten by, without one the button is off for every target. +fn rule(app: &mut PhotocraftApp) { + app.run("image.analysis.rulerTool", json!({"start": [10, 30], "end": [150, 25]})).unwrap(); +} + +/// The message of every open error dialog, in the order they were opened. +fn alerts(app: &PhotocraftApp) -> Vec<&str> { + app.ui.dialogs.iter().filter(|d| d.kind == DialogKind::Error).filter_map(|d| d.fields.get("message").and_then(|m| m.as_str())).collect() +} + +/// Each case opens one of the targets #12033 measured, on top of the plain document `app()` makes. +fn add_layer(app: &mut PhotocraftApp, name: &str) -> u64 { + app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap() +} + +fn on_a_layer(app: &mut PhotocraftApp) { + add_layer(app, "Paint"); +} + +fn on_the_background(_: &mut PhotocraftApp) {} + +fn on_two_layers(app: &mut PhotocraftApp) { + let a = add_layer(app, "One"); + let b = add_layer(app, "Two"); + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); +} + +fn on_a_group(app: &mut PhotocraftApp) { + add_layer(app, "Inner"); + app.run("layer.groupLayers", json!({})).unwrap(); +} + +fn locked(app: &mut PhotocraftApp, key: &str) { + let l = add_layer(app, "Colors"); + app.run("layer.setProps", json!({"layer": l, "locks": {key: true}})).unwrap(); +} + +fn on_lock_all(app: &mut PhotocraftApp) { + locked(app, "all"); +} + +fn on_lock_position(app: &mut PhotocraftApp) { + locked(app, "position"); +} + +/// #12033: the options bar asks the engine, so the button is greyed out for exactly the targets +/// `image.analysis.straightenLayer` is off for - the Background, two or more selected layers, a +/// group, Lock All and Lock Position - and live for a layer. +#[test] +fn the_straighten_layer_button_greys_out_where_the_command_is_off() { + use egui_kittest::kittest::{NodeT, Queryable}; + type Case = (&'static str, fn(&mut PhotocraftApp), bool); + let cases: [Case; 6] = [ + ("a pixel layer", on_a_layer, true), + ("the Background", on_the_background, false), + ("two selected layers", on_two_layers, false), + ("a group", on_a_group, false), + ("Lock All", on_lock_all, false), + ("Lock Position", on_lock_position, false), + ]; + for (what, prepare, live) in cases { + let (mut app, _) = app(); + prepare(&mut app); + rule(&mut app); + let h = ruler_bar(app); + assert_eq!(h.state().session.is_enabled("image.analysis.straightenLayer"), live, "{what}: the command's own check"); + let button = h.get_by_label("Straighten Layer").accesskit_node(); + assert_eq!(!button.is_disabled(), live, "{what}: the options-bar button follows the command"); + // The other Ruler button is unaffected: a ruler line is there to clear in every state. + assert!(!h.get_by_label("Clear").accesskit_node().is_disabled(), "{what}: Clear stays on"); + } +} + +/// #12033: a hidden target layer is the one state that keeps its button. The click answers with the +/// reference's alert - its measured message and an OK button - instead of the status line, and the +/// document, its History and the ruler line stay as they were. +#[test] +fn a_hidden_layer_keeps_the_button_and_the_click_shows_the_alert() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (mut app, _) = app(); + let l = app.run("layer.new.layer", json!({"name": "Gone"})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.setProps", json!({"layer": l, "visible": false})).unwrap(); + rule(&mut app); + let mut h = ruler_bar(app); + assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "a hidden layer keeps the button on"); + let (before, steps) = (h.state().session.active().unwrap().doc.clone(), h.state().session.active().unwrap().history.past_len()); + h.get_by_label("Straighten Layer").click(); + h.run_steps(2); + let st = h.state(); + assert_eq!(alerts(st), [photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR], "one alert with the reference's text"); + let now = st.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &now.doc), "the document was not touched"); + assert_eq!(now.history.past_len(), steps, "no History step"); + assert!(now.doc.measurement.ruler.is_some(), "the ruler line is still there"); +} From d4f27c60fa449688be2ae59744f01c01058c2b54 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 20:40:29 +0100 Subject: [PATCH 22/77] #9348: only Clone Stamp and Healing Brush wrap in Pattern Preview Measured in the reference application: in View > Pattern Preview the Clone Stamp and Healing Brush carry a stroke on across the canvas edges. Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Background Eraser, Pattern Stamp and History Brush show "Symmetry and Pattern Preview are not supported with this tool ... does not perform wrap-around" and stop at the edge; Spot Healing stops at the edge too. Keep the Healing Brush wrap (commit and live preview, through periodic::stroke) and drop the wrap from the other tools: their commands, WorkTiles' wrap tile, photocraft_paint::wrap and the canvas params go back to main's code. Tests: the tiling checks stay for Clone Stamp and Healing Brush; new tests check that the other tools' strokes, live and committed, stop at the edge and that the canvas sends `wrap` only for the two tools that wrap. Co-Authored-By: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/brush_cmds.rs | 13 +- crates/engine/src/cmd/eraser_cmds.rs | 31 +- crates/engine/src/cmd/retouch_cmds.rs | 39 +-- crates/engine/src/cmd/retouch_cmds/clone.rs | 21 +- crates/engine/src/cmd/retouch_cmds/dab.rs | 209 ++++++------ crates/engine/src/cmd/retouch_cmds/heal.rs | 78 +---- .../src/cmd/retouch_cmds/pattern_stamp.rs | 14 +- .../engine/src/cmd/retouch_cmds/sampling.rs | 49 +-- crates/engine/src/cmd/retouch_cmds/tests.rs | 312 ++++-------------- crates/paint/src/bg_erase.rs | 19 +- crates/paint/src/lib.rs | 4 +- crates/paint/src/replace.rs | 42 +-- crates/paint/src/retouch.rs | 264 --------------- crates/paint/src/wrap.rs | 166 ---------- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/strokes.rs | 8 - crates/ui-egui/src/stroke_timing_tests.rs | 54 --- crates/ui-egui/src/tools/eraser_ui.rs | 6 +- crates/ui-egui/src/tools/retouch_ui.rs | 23 +- crates/ui-egui/src/tools/retouch_ui/tests.rs | 36 +- 20 files changed, 287 insertions(+), 1105 deletions(-) delete mode 100644 crates/paint/src/wrap.rs diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 9142cbf6ff..e09ff2098c 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -9,7 +9,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, MixerState}; -use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement_wrapped, color_replacement_effect}; +use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; use photocraft_paint::tile::PREVIEW_SIDE; use photocraft_paint::{ BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke, @@ -860,9 +860,8 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { let dmg = s.edit("Color Replacement", |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; - let wrap = flag(p, "wrap", false).then(|| doc.bounds()); let surf = paint_surface(doc, id, &json!({}))?; - Ok(apply_color_replacement_wrapped(surf, &stroke, &rs, sel.as_ref(), lock, wrap)) + Ok(apply_color_replacement(surf, &stroke, &rs, sel.as_ref(), lock)) })?; Ok(damage_json(s, dmg)) } @@ -871,10 +870,8 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { /// recolouring per dab as the commit, so the preview at the last point is the committed result. pub fn live_color_replacement(s: &Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", p, stroke, Some(id), 0, |pre, wrap| { - Box::new(color_replacement_effect(pre.clone(), rs, wrap)) - })? - .with_tail_preview() + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", p, stroke, Some(id), 0, |pre| Box::new(color_replacement_effect(pre.clone(), rs)))? + .with_tail_preview() } fn brush_json(b: &BrushSettings) -> Value { @@ -1339,7 +1336,7 @@ pub fn specs() -> Vec { spec!( "paint.colorReplacement", "Color Replacement", - r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, + r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##, crate::commands::has_paintable, color_replacement, true diff --git a/crates/engine/src/cmd/eraser_cmds.rs b/crates/engine/src/cmd/eraser_cmds.rs index d33f1afae0..1433498078 100644 --- a/crates/engine/src/cmd/eraser_cmds.rs +++ b/crates/engine/src/cmd/eraser_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::Stroke; -use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser_wrapped, background_eraser_effect, background_eraser_stroke}; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser, background_eraser_effect, background_eraser_stroke}; use photocraft_paint::replace::{Limits, Sampling}; use serde_json::{Value, json}; @@ -165,18 +165,16 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { let bg = s.tools.background; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); - // View › Pattern Preview: the stroke carries on across the canvas edges. - let wrap = flag(p, "wrap", false).then(|| doc.bounds()); if colors.is_none() { crate::extra_cmds::background_to_layer(doc, id); } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; if let Some(colors) = colors { - let effect = selected_background_effect(surf, &bs, colors, bg, wrap); - Ok(photocraft_paint::retouch::apply_dab_stroke_wrapped(surf, &background_eraser_stroke(&stroke), selection.as_ref(), false, 0, wrap, effect)) + let effect = selected_background_effect(surf, &bs, colors, bg); + Ok(photocraft_paint::retouch::apply_dab_stroke(surf, &background_eraser_stroke(&stroke), selection.as_ref(), false, 0, effect)) } else { - Ok(apply_background_eraser_wrapped(surf, &stroke, &bs, selection.as_ref(), wrap)) + Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) } })?; Ok(damage(s, dmg, true)) @@ -187,7 +185,6 @@ fn selected_background_effect( bs: &BgEraseSettings, colors: crate::color_channels::ColorChannels, bg: [f32; 4], - wrap: Option, ) -> crate::retouch_cmds::DabEffect { let fmt = pre.format(); let alpha = fmt.mode.color_channels(); @@ -195,7 +192,7 @@ fn selected_background_effect( // Opaque Backgrounds have no alpha sample. The matching algorithm uses a temporary opaque // alpha to calculate erased coverage; the real layer keeps its original format and identity. let opaque = pre.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)); - let Some(mut effect) = background_eraser_effect(&opaque, bs, wrap) else { + let Some(mut effect) = background_eraser_effect(&opaque, bs) else { return Box::new(|_, _| {}); }; Box::new(move |region, footprint| { @@ -238,20 +235,16 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result Box::new(effect), - // No alpha: the commit erases nothing either. - None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), - } + crate::retouch_cmds::LiveDab::with_effect_on(doc, CMD, &json!({}), stroke, Some(id), 0, |pre| match background_eraser_effect(pre, &bs) { + Some(effect) => Box::new(effect), + // No alpha: the commit erases nothing either. + None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), })? .with_tail_preview() } @@ -274,7 +267,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index e6cc4eb3d4..bf05a7e879 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -16,8 +16,7 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; -use photocraft_paint::wrap::wrap_coord; +use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -123,10 +122,6 @@ fn num(p: &Value, k: &str, d: f32) -> f32 { fn flag(p: &Value, k: &str, d: bool) -> bool { p.get(k).and_then(Value::as_bool).unwrap_or(d) } -/// The canvas a stroke with `"wrap": true` repeats across (View › Pattern Preview). -fn wrap_tile(pre: &Document, p: &Value) -> Option { - flag(p, "wrap", false).then(|| pre.bounds()).filter(|t| !t.is_empty()) -} fn string<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str { p.get(k).and_then(Value::as_str).unwrap_or(d) } @@ -253,7 +248,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","phase","aligned","pattern"}"# + r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]? → {"damage","phase","aligned","pattern"}"# ), enabled: pattern_stamp::enabled, run: pattern_stamp::run, @@ -276,9 +271,7 @@ pub fn specs() -> Vec { label: "Spot Healing Brush", menu: &[], shortcut: None, - params: brush_params!( - r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: spot_healing, journal: true, @@ -320,9 +313,7 @@ pub fn specs() -> Vec { label: "Dodge", menu: &[], shortcut: None, - params: brush_params!( - r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.dodge"), journal: true, @@ -332,9 +323,7 @@ pub fn specs() -> Vec { label: "Burn", menu: &[], shortcut: None, - params: brush_params!( - r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.burn"), journal: true, @@ -344,9 +333,7 @@ pub fn specs() -> Vec { label: "Sponge", menu: &[], shortcut: None, - params: brush_params!( - r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sponge"), journal: true, @@ -356,9 +343,7 @@ pub fn specs() -> Vec { label: "Blur", menu: &[], shortcut: None, - params: brush_params!( - r#","strength":1..100=50,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.blur"), journal: true, @@ -368,9 +353,7 @@ pub fn specs() -> Vec { label: "Sharpen", menu: &[], shortcut: None, - params: brush_params!( - r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sharpen"), journal: true, @@ -380,9 +363,7 @@ pub fn specs() -> Vec { label: "Smudge", menu: &[], shortcut: None, - params: brush_params!( - r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.smudge"), journal: true, @@ -393,7 +374,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"# ), enabled: has_pixel_layer, run: history_brush, diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index 9e79894a44..66cf276332 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -265,7 +265,14 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { let bounds = self.renderer.bounds().union(&self.tail); - if flag(&self.params, "wrap", false) { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } + if self.wraps() { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } + } + + /// Whether the stroke repeats across the canvas edges (View › Pattern Preview). Only the Clone + /// Stamp and the Healing Brush do: the reference application's other retouching tools stop + /// at the edge. + fn wraps(&self) -> bool { + matches!(self.cmd.as_str(), "paint.cloneStamp" | "paint.healingBrush") && flag(&self.params, "wrap", false) } /// Every point rendered so far, in order. @@ -317,7 +324,7 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); - if flag(&self.params, "wrap", false) { + if self.wraps() { return self.push_periodic(); } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); @@ -346,13 +353,11 @@ impl LiveRetouch { let mut tail = self.renderer.tail_preview(); self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect()); dirty = dirty.union(&self.tail); - let (pre, id, pre_surf, live_paint, live_cmd) = (&self.pre, self.id, &self.pre_surf, &self.paint, self.cmd.as_str()); - let fmt = pre_surf.format(); - let paint = |r| match live_paint { - LivePaint::Clone { which, map, cmd } => clone_sample(pre, id, pre_surf, *which, r, map, true, cmd), - LivePaint::History(src) => periodic::sample(pre, id, src, SampleLayers::Current, r, live_cmd), - LivePaint::Pattern { tile, place } => Ok(pattern_stamp::paint_region(tile, place, &fmt, r)), + let LivePaint::Clone { which, map, cmd } = &self.paint else { + return Err(bad(&self.cmd, "periodic painting requires a clone source")); }; + let (pre, id, pre_surf, live_cmd) = (&self.pre, self.id, &self.pre_surf, self.cmd.as_str()); + let paint = |r| clone_sample(pre, id, pre_surf, *which, r, map, true, cmd); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; periodic::Composite { target: surf, diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index 597a1a5d74..e51ac10021 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -1,10 +1,9 @@ use super::sampling::{AllLayers, sample_all_layers}; use super::{ BrushSettings, Document, EngineError, Footprint, IndexedParallelIterator, LayerId, ParallelIterator, ParallelSliceMut, PixelFormat, Rect, Region, Result, - Session, Smudge, Stroke, StrokePoint, Surface, ToneRange, Value, WorkTiles, alpha_index, apply_dab_stroke_wrapped, bad, damage_json, dodge_burn, flag, - from_rgba_into, has_pixel_layer, json, local_blur, local_sharpen, num, parse_brush, run_stroke, sponge, string, to_rgba, wrap_tile, + Session, Smudge, Stroke, StrokePoint, Surface, ToneRange, Value, alpha_index, apply_dab_stroke, bad, damage_json, dodge_burn, flag, from_rgba_into, + has_pixel_layer, json, local_blur, local_sharpen, num, parse_brush, run_stroke, sponge, string, to_rgba, }; -use photocraft_paint::wrap::{fits, fold_area, pieces, wrap_coord}; // --------------------------------------------------------------------------------------------- // Toning (Dodge / Burn / Sponge) and focus (Blur / Sharpen), Smudge @@ -47,9 +46,8 @@ fn color_dab(fmt: &PixelFormat, work: &mut Region, fp: &Footprint, strength: f32 /// stroke's coverage there (`toned` is the full-exposure colour). Dabs build up coverage as in a /// brush stroke, capped at full coverage, so Exposure caps a stroke the way Opacity caps a paint /// stroke: overlapping dabs and passes within one stroke don't compound the tone curve, and a -/// new stroke builds on the last. Wrapped around `wrap` (View › Pattern Preview), the coverage is -/// kept per canvas pixel, so a stroke coming back to a pixel one canvas away tones it once. -fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { +/// new stroke builds on the last. +fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { /// Side of the cells that remember, per touched pixel, the coverage applied so far and the /// original pixel (copied from the working pixels the first time a dab reaches it). Dense /// cells rather than a map per pixel: a map lookup per covered pixel made long strokes with @@ -62,8 +60,11 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let mut cells: std::collections::HashMap<(i32, i32), Cell> = std::collections::HashMap::new(); let a = alpha_index(&fmt); let n = fmt.channels(); - // Tone the pixels of `px` at `r` moved by `(dx, dy)`, remembered at `r`. - let mut tone = move |px: &mut Region, fp: &Footprint, r: Rect, (dx, dy): (i32, i32)| { + Box::new(move |work, fp| { + let r = fp.rect.intersect(&work.rect); + if r.is_empty() { + return; + } let mut enc = [0.0f32; 8]; for cy in r.y0.div_euclid(CELL)..=(r.y1 - 1).div_euclid(CELL) { for cx in r.x0.div_euclid(CELL)..=(r.x1 - 1).div_euclid(CELL) { @@ -77,7 +78,7 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let cell = cells.entry((cx, cy)).or_insert_with(|| Cell { cov: vec![-1.0; len], orig: vec![0.0; len * n] }); for y in part.y0..part.y1 { for x in part.x0..part.x1 { - let k = fp.at(x + dx, y + dy).clamp(0.0, 1.0); + let k = fp.at(x, y).clamp(0.0, 1.0); if k <= 0.0 { continue; } @@ -85,7 +86,7 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let (Some(prev), Some(src)) = (cell.cov.get_mut(i), cell.orig.get_mut(i * n..i * n + n)) else { continue }; if *prev < 0.0 { // First touch: the working pixel is still the original. - src.copy_from_slice(px.px(x + dx, y + dy)); + src.copy_from_slice(work.px(x, y)); *prev = 0.0; } // Dabs build up the stroke's coverage as a brush stroke's do (flow), up @@ -99,55 +100,16 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let t = toned([o[0], o[1], o[2]]); let m = [o[0] + (t[0] - o[0]) * k, o[1] + (t[1] - o[1]) * k, o[2] + (t[2] - o[2]) * k]; from_rgba_into(&fmt, [m[0], m[1], m[2], o[3]], &mut enc); - let out = px.px_mut(x + dx, y + dy); + let px = work.px_mut(x, y); for c in 0..n { if Some(c) != a { - out[c] = enc[c]; + px[c] = enc[c]; } } } } } } - }; - Box::new(move |work, fp| { - let r = fp.rect.intersect(&work.rect); - if r.is_empty() { - return; - } - match wrap { - None => tone(work, fp, r, (0, 0)), - Some(tile) if fits(r, tile) => { - for (part, off) in pieces(r, tile) { - tone(work, fp, part, off); - } - } - // A dab bigger than the canvas covers its pixels more than once: tone each at the - // most coverage any copy gets (the copy `WorkTiles` keeps), and give every copy that. - Some(tile) => { - let area = fold_area(r, tile); - let mut on = Region::new(area, work.ch); - let mut most = Footprint { dab: fp.dab, index: fp.index, rect: area, cov: vec![0.0; area.width() as usize * area.height() as usize] }; - let at = |x, y| (wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (tx, ty) = at(x, y); - let i = on.index(tx, ty); - on.px_mut(tx, ty).copy_from_slice(work.px(x, y)); - if let Some(m) = most.cov.get_mut(i) { - *m = m.max(fp.at(x, y)); - } - } - } - tone(&mut on, &most, area, (0, 0)); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (tx, ty) = at(x, y); - work.px_mut(x, y).copy_from_slice(on.px(tx, ty)); - } - } - } - } }) } @@ -253,12 +215,11 @@ impl DabTool { } } - /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`, on a stroke - /// wrapped around `wrap`. - pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32, wrap: Option) -> DabEffect { + /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. + pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { let sp = spacing; match self.clone() { - Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, wrap, move |c| dodge_burn(c, exposure, range, burn, protect)), + Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))), Self::Focus { sharpen, strength, protect, sigma } => { @@ -320,10 +281,9 @@ pub(super) fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(l.bounds); } let fmt = surf.format(); - let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); - let mut effect = tool.effect(fmt, stroke.brush.spacing, wrap); - let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); - Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { + let mut effect = tool.effect(fmt, stroke.brush.spacing); + let mut sampler = all.then(|| AllLayers::new(pre, fmt)); + Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), None => effect(work, fp), })) @@ -335,6 +295,81 @@ pub(super) fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(r) } +/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]). +const WORK_TILE: i32 = 128; + +/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in +/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a +/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the +/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels. +struct WorkTiles { + ch: usize, + tiles: std::collections::HashMap<(i32, i32), Vec>, +} + +impl WorkTiles { + fn new(ch: usize) -> Self { + Self { ch, tiles: std::collections::HashMap::new() } + } + + fn tile_rect((tx, ty): (i32, i32)) -> Rect { + Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) + } + + /// The tiles `r` overlaps. + fn coords(r: Rect) -> impl Iterator { + let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); + let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); + let empty = r.is_empty(); + (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) + } + + /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. + fn read(&self, pre: &Surface, r: Rect) -> Region { + let mut out = Region::new(r, self.ch); + let n = self.ch; + let mut buf = Vec::new(); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + let tile = self.tiles.get(&c); + if tile.is_none() { + pre.read_region_into(part, &mut buf); + } + for y in part.y0..part.y1 { + let src = match tile { + Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), + None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), + }; + let dst = out.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + out + } + + /// Store `region` as the working pixels of its rectangle. + fn write(&mut self, pre: &Surface, region: &Region) { + let (r, n) = (region.rect, self.ch); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + for y in part.y0..part.y1 { + let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; + let src = region.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + } +} + /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. @@ -362,8 +397,6 @@ pub struct LiveDab { sel: Option, lock: bool, bounds: Rect, - /// The canvas a wrapped stroke repeats across (View › Pattern Preview, `"wrap": true`). - wrap: Option, /// Show the dabs finishing the stroke would add, so the preview at the last point is the /// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the /// tail runs on a scratch copy at every push, so an effect that carries paint from dab to @@ -393,14 +426,13 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, wrap| effect.effect(pre.format(), spacing, wrap))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?; live.tool = Some((tool, brush)); Ok(live) } - /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke - /// and the canvas a wrapped stroke repeats across) at each dab of `stroke`, reading `halo` - /// pixels around it. `p` gives the target and `wrap`. + /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) + /// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target. pub(crate) fn with_effect( s: &Session, cmd: &str, @@ -408,7 +440,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -420,7 +452,7 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { let captured = crate::channel_cmds::inject_target(s, cmd, p.clone()); let p = &captured; @@ -446,7 +478,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -458,14 +490,13 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { - let wrap = wrap_tile(&doc, p); let colors = crate::color_channels::explicit(p, crate::channel_cmds::color_count(&doc))?; let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); - let effect = effect(&pre_surf, wrap); + let effect = effect(&pre_surf); // Generate the same dabs as `apply_dab_stroke` (zoom 1), without a StrokeRenderer: // its accumulated coverage isn't used here, and each effect rasterizes its own footprint. let ctx = photocraft_paint::BrushContext::new(&stroke.brush); @@ -480,7 +511,7 @@ impl LiveDab { done: 0, effect, neighborhood, - work: WorkTiles::new(pre_surf.channels()).wrapped(wrap), + work: WorkTiles::new(pre_surf.channels()), pre_surf, colors, id, @@ -489,7 +520,6 @@ impl LiveDab { sel, lock, bounds: Rect::EMPTY, - wrap, preview_tail: false, tail: Rect::EMPTY, tool: None, @@ -525,11 +555,10 @@ impl LiveDab { if r.is_empty() { continue; } - let fp = ctx.footprint(d, self.done.saturating_add(k)); let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &fp); - self.work.write_dab(&self.pre_surf, ®ion, &fp); - dmg = dmg.union(&self.on_canvas(r)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); + self.work.write(&self.pre_surf, ®ion.crop(r)); + dmg = dmg.union(&r); } self.done = self.done.saturating_add(new.len()); new.clear(); @@ -537,24 +566,6 @@ impl LiveDab { dmg } - /// Where the stroke's rectangle `r` lands on the document: `r`, or in a wrapped stroke the - /// part of the canvas it covers. - fn on_canvas(&self, r: Rect) -> Rect { - self.wrap.map_or(r, |t| photocraft_paint::wrap::fold_area(r, t)) - } - - /// [`Self::show`] working pixels laid out in the stroke's own coordinates: a wrapped stroke - /// shows each part where it lies on the canvas. - fn show_unwrapped(&mut self, work: &Region) -> Result<()> { - let Some(tile) = self.wrap else { return self.show(work.rect, work) }; - for (part, (dx, dy)) in photocraft_paint::wrap::pieces(work.rect, tile) { - let mut piece = work.crop(part.translate(dx, dy)); - piece.rect = part; - self.show(part, &piece)?; - } - Ok(()) - } - /// Mix the working pixels of `r` back over the original into the shown document. fn show(&mut self, r: Rect, work: &Region) -> Result<()> { let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; @@ -599,7 +610,7 @@ impl LiveDab { // Back to the stroke without the previous tail. let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let shown = dmg.union(&old); - let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| self.on_canvas(*r)); + let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r); if shown.is_empty() && tail_rect.is_empty() { return Ok(Rect::EMPTY); } @@ -608,9 +619,9 @@ impl LiveDab { let work = self.work.read(&self.pre_surf, shown); self.show(shown, &work)?; } - if let Some((_, work)) = tail { - self.show_unwrapped(&work)?; - self.tail = tail_rect; + if let Some((r, work)) = tail { + self.show(r, &work)?; + self.tail = r; } Ok(shown.union(&tail_rect)) } diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index c68789e2c3..b26decfd69 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -3,7 +3,7 @@ use super::periodic; use super::sampling::{SampleLayers, composite_region, sample_all_layers, sample_layers}; use super::{ BlendMode, CompleteParams, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, Session, Stroke, Surface, Value, apply_coverage, bad, - coverage, damage_json, flag, inpaint, json, parse_brush, poisson, reject_huge_stroke, run_stroke, string, wrap_coord, wrap_tile, + coverage, damage_json, flag, inpaint, json, parse_brush, poisson, reject_huge_stroke, run_stroke, string, }; /// Grow a stroke's coverage map by `m` pixels of zero coverage (a Dirichlet boundary for the solve). @@ -105,17 +105,13 @@ pub(super) fn history_brush(s: &mut Session, p: &Value) -> Result { let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; - let dmg = run_stroke(s, "History Brush", id, p, |pre, surf, sel, lock| { + let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { if let Some(l) = live { *surf = l.surface; return Ok(l.bounds); } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; - if flag(p, "wrap", false) { - // Pattern Preview: the history state repeats with the canvas, as the stroke does. - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); - } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -193,68 +189,28 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -/// -/// Wrapped around the canvas `wrap` (View › Pattern Preview), the window is centred on the stroke -/// whatever the canvas edges and at most one canvas across, so it holds no second copy of the -/// stroke; its pixels repeat across the edges, and the healed pixels go where they lie on the -/// canvas. -#[allow(clippy::too_many_arguments)] -fn spot_heal_surface( - surf: &mut Surface, - pre: &Document, - stroke: &Stroke, - kind: SpotType, - all: bool, - sel: Option<&Surface>, - lock: bool, - wrap: Option, -) -> Result { - const CMD: &str = "paint.spotHealing"; - let (bounds, cov) = coverage(CMD, stroke)?; +fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { + let (bounds, cov) = coverage("paint.spotHealing", stroke)?; let size = stroke.brush.size; - let region_rect = match wrap { - Some(tile) => { - let (tw, th) = (i64::from(tile.width()), i64::from(tile.height())); - let x0 = (i64::from(bounds.x0) + i64::from(bounds.x1) - tw) / 2; - let y0 = (i64::from(bounds.y0) + i64::from(bounds.y1) - th) / 2; - let around = Rect::new(x0 as i32, y0 as i32, (x0 + tw) as i32, (y0 + th) as i32); - crate::fill_cmds::sampling_window(bounds, around) - } - None => crate::fill_cmds::sampling_window(bounds, pre.bounds()), - }; + let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); if region_rect.is_empty() { return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; let fmt = surf.format(); - let img = match wrap { - Some(_) => periodic::sample(pre, None, surf, if all { SampleLayers::All } else { SampleLayers::Current }, region_rect, CMD)?, - None if all => composite_region(pre, None, SampleLayers::All, region_rect, fmt), - None => Region::read(surf, region_rect), - }; + let img = if all { composite_region(pre, None, SampleLayers::All, region_rect, fmt) } else { Region::read(surf, region_rect) }; let (w, h, ch) = (img.width(), img.height(), img.ch); let bw = bounds.width() as usize; let mut stroke_cov = vec![0.0f32; w * h]; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw) as i32, bounds.y0 + (i / bw) as i32); - // A wrapped stroke longer than the window (one canvas) folds onto it: the strongest - // coverage of each pixel heals it once. - let (x, y) = match wrap { - Some(t) => { - let wrap_into = |v: i32, lo: i32, period: u32| wrap_coord(v, lo, lo.saturating_add(period as i32)); - (wrap_into(x, region_rect.x0, t.width()), wrap_into(y, region_rect.y0, t.height())) - } - None => (x, y), - }; if region_rect.contains(x, y) { - let px = &mut stroke_cov[img.index(x, y)]; - *px = px.max(*c); + stroke_cov[img.index(x, y)] = *c; } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); - // Nothing to heal, or (a wrapped dab covering its whole window) nothing to heal from. - if !hole.iter().any(|h| *h) || hole.iter().all(|h| *h) { + if !hole.iter().any(|h| *h) { return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels @@ -278,19 +234,8 @@ fn spot_heal_surface( let src = Region { rect: region_rect, ch, data: filled }; let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); - let cov_of = |r: Rect, (dx, dy): (i32, i32)| -> Vec { - (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x + dx, y + dy)]).collect() - }; - let Some(tile) = wrap else { - return Ok(apply_coverage(surf, dmg, &cov_of(dmg, (0, 0)), stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)); - }; - let mut done = Rect::EMPTY; - for (part, off) in photocraft_paint::wrap::pieces(dmg, tile) { - let mut paint = healed.crop(part.translate(off.0, off.1)); - paint.rect = part; - done = done.union(&apply_coverage(surf, part, &cov_of(part, off), stroke.brush.opacity, sel, lock, &paint, BlendMode::Normal)); - } - Ok(done) + let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); + Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) } pub(super) fn spot_healing(s: &mut Session, p: &Value) -> Result { @@ -304,7 +249,6 @@ pub(super) fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = - run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock, wrap_tile(pre, p)))?; + let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; Ok(json!({ "damage": damage_json(dmg) })) } diff --git a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs index 6b5dae73b5..06b5bd17e7 100644 --- a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs @@ -8,12 +8,12 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::{LayerId, Pattern}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke_wrapped, over_native}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, periodic, reject_huge_stroke, wrap_tile}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; use crate::{Result, Session}; const STREAM_PHASE_X: u64 = 101; @@ -88,16 +88,10 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { let pre = doc.clone(); let (surf, lock) = crate::channel_cmds::target_surface(doc, st.id, p)?; let sel = pre.selection.as_ref(); - // View › Pattern Preview: the stroke carries on across the canvas edges, the pattern - // following it as it goes. - let wrap = wrap_tile(&pre, p); if st.impressionist { // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. let effect = st.impressionist_effect(surf.format()); - Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) - } else if wrap.is_some() { - let (place, fmt) = (st.placement(), surf.format()); - periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, CMD, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) + Ok(apply_dab_stroke(surf, &st.stroke, sel, lock, 0, effect)) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } @@ -123,7 +117,7 @@ pub fn live_impressionist(s: &Session, p: &Value) -> Result { } let (stroke, id) = (st.stroke.clone(), st.id); let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); - super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre, _| st.impressionist_effect(pre.format()))?.with_tail_preview() + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() } /// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints diff --git a/crates/engine/src/cmd/retouch_cmds/sampling.rs b/crates/engine/src/cmd/retouch_cmds/sampling.rs index 8719e931bc..253f6648ad 100644 --- a/crates/engine/src/cmd/retouch_cmds/sampling.rs +++ b/crates/engine/src/cmd/retouch_cmds/sampling.rs @@ -1,7 +1,4 @@ -use super::{ - Document, Footprint, LayerId, PixelFormat, Rect, Region, Result, SampleType, Stroke, Surface, Value, WorkTiles, bad, flag, from_rgba_into, string, - targets_pixels, -}; +use super::{Document, Footprint, LayerId, PixelFormat, Rect, Region, Result, Surface, Value, bad, flag, from_rgba_into, string, targets_pixels}; // --------------------------------------------------------------------------------------------- // Sampling sources @@ -74,56 +71,26 @@ pub(super) struct AllLayers<'a> { pre: &'a Document, fmt: PixelFormat, visible: Option, - /// A stroke wrapped around the canvas (View › Pattern Preview): the composite at full - /// precision under everything the stroke reads, and the dabs' edits of it, kept once per - /// pixel of the canvas as the layer's working pixels are. - wrapped: Option<(Surface, WorkTiles)>, } impl<'a> AllLayers<'a> { pub(super) fn new(pre: &'a Document, fmt: PixelFormat) -> Self { - Self { pre, fmt, visible: None, wrapped: None } - } - - /// Sample for `stroke` wrapped around `wrap`, whose dabs read `halo` pixels around them. - pub(super) fn wrapped(mut self, wrap: Option, stroke: &Stroke, halo: i32) -> Self { - let Some(tile) = wrap else { return self }; - let ctx = photocraft_paint::BrushContext::new(&stroke.brush); - let reach = photocraft_paint::dabs(stroke).iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))).inflate(halo); - let area = photocraft_paint::wrap::fold_area(reach, tile); - let mut visible = Surface::new(PixelFormat { sample: SampleType::F32, ..self.fmt }); - if !area.is_empty() { - visible.write_region(area, &composite_region(self.pre, None, SampleLayers::All, area, self.fmt).data); - } - self.wrapped = Some((visible, WorkTiles::new(self.fmt.channels()).wrapped(Some(tile)))); - self + Self { pre, fmt, visible: None } } pub(super) fn dab(&mut self, work: &mut Region, fp: &Footprint, effect: impl FnOnce(&mut Region, &Footprint)) { - if let Some((visible, edits)) = self.wrapped.as_mut() { - let mut shown = edits.read(visible, work.rect); - effect(&mut shown, fp); - lay(work, &shown, fp); - edits.write_dab(visible, &shown, fp); - return; - } let (pre, fmt) = (self.pre, self.fmt); let visible = self.visible.get_or_insert_with(|| composite_region(pre, None, SampleLayers::All, work.rect, fmt)); if visible.rect != work.rect || visible.ch != work.ch { return effect(work, fp); } effect(visible, fp); - lay(work, visible, fp); - } -} - -/// Lay what a dab did to the visible pixels onto the layer's working pixels under it. -fn lay(work: &mut Region, visible: &Region, fp: &Footprint) { - let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - if fp.at(x, y) > 0.0 { - work.px_mut(x, y).copy_from_slice(visible.px(x, y)); + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + if fp.at(x, y) > 0.0 { + work.px_mut(x, y).copy_from_slice(visible.px(x, y)); + } } } } diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index ede2cb9348..b5730f5e34 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -1912,7 +1912,7 @@ fn live_dab_batches_match_the_accumulating_renderer() { p["flow"] = json!(63); let (stroke, _) = parse_brush(&s, &p, cmd).unwrap(); let (tool, _) = DabTool::parse(&s, cmd, &p, &stroke).unwrap(); - let mut effect = tool.effect(pre.format(), stroke.brush.spacing, None); + let mut effect = tool.effect(pre.format(), stroke.brush.spacing); let mut reference = StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); // A fixed region containing all dabs plus their read neighbourhoods. let orig = Region::read(&pre, Rect::new(-128, -128, 384, 256)); @@ -2534,7 +2534,7 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { } // --------------------------------------------------------------------------------------------- -// View › Pattern Preview: every retouching stroke wraps across the canvas edges (#8883) +// View › Pattern Preview: Clone Stamp and Healing Brush wrap across the canvas edges (#8883) // --------------------------------------------------------------------------------------------- /// The canvas the Pattern Preview tests wrap around. @@ -2555,48 +2555,11 @@ fn corner_stroke(extra: &Value) -> Value { p } -/// The texture on the layer. -fn textured(s: &mut Session) { - paint_layer(s, tiled_texture); -} - -/// The texture under an empty layer, which is the target (Sample All Layers). -fn textured_below(s: &mut Session) { - paint_layer(s, tiled_texture); - s.execute("layer.new.layer", json!({})).unwrap(); -} - -/// The texture in history state 1, painted over since. -fn textured_in_history(s: &mut Session) { - paint_layer(s, tiled_texture); - paint_layer(s, |_, _| [0.15, 0.2, 0.7, 1.0]); -} - -type Setup = fn(&mut Session); - -/// Every stroke tool that wraps, with options and a document for it. `true` marks the tools -/// that composite a paint image through the stroke's coverage (`periodic::Composite`), which -/// matches the ordinary composite only within rounding. -fn wrap_cases() -> Vec<(&'static str, Value, Setup, bool)> { - vec![ - ("paint.dodge", json!({"exposure": 60}), textured, false), - ("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false}), textured, false), - ("paint.sponge", json!({"mode": "saturate"}), textured, false), - ("paint.blur", json!({"strength": 80}), textured, false), - ("paint.sharpen", json!({"strength": 60}), textured, false), - ("paint.smudge", json!({"strength": 70}), textured, false), - ("paint.blur", json!({"strength": 80, "sampleAllLayers": true}), textured_below, false), - ("paint.smudge", json!({"strength": 70, "sampleAllLayers": true}), textured_below, false), - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), textured, false), - ("paint.backgroundEraser", json!({"tolerance": 40}), textured, false), - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60, "target": {"colors": [0, 2]}}), textured, false), - ("paint.backgroundEraser", json!({"tolerance": 40, "target": {"colors": [0, 2]}}), textured, false), - ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true}), textured, false), - ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured, true), - ("paint.historyBrush", json!({"state": 1}), textured_in_history, true), - ("paint.healingBrush", json!({"offset": [-20, -14]}), textured, true), - ("paint.cloneStamp", json!({"offset": [-20, -14]}), textured, true), - ] +/// The stroke tools that wrap, with their options. Both composite a paint image through the +/// stroke's coverage (`periodic::Composite`), which matches the ordinary composite only within +/// rounding. +fn wrap_cases() -> [(&'static str, Value); 2] { + [("paint.healingBrush", json!({"offset": [-20, -14]})), ("paint.cloneStamp", json!({"offset": [-20, -14]}))] } /// The native-channel difference `periodic::Composite` may leave against the ordinary composite. @@ -2614,11 +2577,11 @@ fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three // copy of that pixel it goes over, on a document holding the canvas tiled three by three. let (w, h) = (WRAP.0 as usize, WRAP.1 as usize); for depth in DEPTHS { - for (cmd, extra, setup, composite) in wrap_cases() { + for (cmd, extra) in wrap_cases() { let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb"); - setup(&mut canvas); + paint_layer(&mut canvas, tiled_texture); let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb"); - setup(&mut tiled); + paint_layer(&mut tiled, tiled_texture); let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra)); wrapped["wrap"] = json!(true); canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}")); @@ -2627,7 +2590,7 @@ fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled)); let n = got.len() / (w * h); let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n; - let tolerance = if composite { composite_tolerance(depth) } else { 0.0 }; + let tolerance = composite_tolerance(depth); let (mut moved, mut edges) = (0, [false; 4]); for y in 0..h { for x in 0..w { @@ -2657,66 +2620,31 @@ fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three #[test] fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() { let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60}); - let mut cases: Vec<(&str, Value, Setup, bool)> = wrap_cases(); - cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); - cases.push(("paint.spotHealing", json!({"type": "proximityMatch"}), textured, false)); for depth in [8, 32] { - for (cmd, extra, setup, composite) in &cases { + for (cmd, extra) in wrap_cases() { let run = |wrap: bool| { - // Big enough that Spot Healing's window stays clear of the edges either way. let mut s = session(300, 230, depth, "rgb"); - setup(&mut s); + paint_layer(&mut s, tiled_texture); let mut q = p.clone(); for (k, v) in extra.as_object().unwrap() { q[k] = v.clone(); } q["wrap"] = json!(wrap); - let r = s.execute(cmd, q).unwrap(); - (layer_pixels(&s), r["damage"].clone()) + s.execute(cmd, q).unwrap(); + layer_pixels(&s) }; - let ((plain, d0), (wrapped, d1)) = (run(false), run(true)); - let tolerance = if *composite { composite_tolerance(depth) } else { 0.0 }; + let (plain, wrapped) = (run(false), run(true)); let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); - assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: off by {error}"); - if !*composite { - assert_eq!(d0, d1, "{cmd} {extra} at {depth}-bit: the same damage"); - } - } - } -} - -#[test] -fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_nothing_else() { - // The window is centred on the stroke whatever the edges and one canvas across, so the - // corner heals from what surrounds it in the copies, the same as when the stroke stays inside. - for kind in ["contentAware", "createTexture", "proximityMatch"] { - let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); - paint_layer(&mut s, |x, y| if (x - 2).abs() < 3 && (y - 2).abs() < 3 { [0.0, 0.0, 0.0, 1.0] } else { tiled_texture(x, y) }); - let before = layer_pixels(&s); - // The dark blemish straddles the corner (0, 0); the stroke is drawn on the copy to its - // lower right, around (48, 36). - s.execute("paint.spotHealing", json!({"points": [[48, 36], [50, 38]], "size": 14, "hardness": 100, "type": kind, "wrap": true})).unwrap(); - let after = layer_pixels(&s); - let n = after.len() / (WRAP.0 * WRAP.1) as usize; - let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); - assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); - for (x, y) in [(46, 1), (1, 34), (46, 34)] { - assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); - assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); - } - for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { - assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); + assert!(error <= composite_tolerance(depth), "{cmd} {extra} at {depth}-bit: off by {error}"); } } } #[test] fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { - let mut cases = wrap_cases(); - cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); - for (cmd, extra, setup, _) in cases { + for (cmd, extra) in wrap_cases() { let mut s = session(16, 12, 8, "rgb"); - setup(&mut s); + paint_layer(&mut s, tiled_texture); let before = layer_pixels(&s); let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); for (k, v) in extra.as_object().unwrap() { @@ -2728,168 +2656,68 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { let st = s.active().unwrap(); let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); - // Spot Healing has nothing left to sample here (the next test). - if cmd != "paint.spotHealing" { - assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); - } + assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); } } #[test] -fn spot_healing_with_nothing_left_to_sample_leaves_the_layer_alone() { - // Wrapped, the dab covers all of a canvas smaller than it, so its window has no pixel left - // to heal from. - for depth in DEPTHS { - for kind in ["contentAware", "createTexture", "proximityMatch"] { - let mut s = session(48, 36, depth, "rgb"); - textured(&mut s); +fn the_tools_the_reference_application_does_not_wrap_stop_at_the_canvas_edge() { + // Measured in the reference application: in Pattern Preview the Clone Stamp and Healing + // Brush carry a stroke on across the canvas edges, but Blur … Sponge, Color Replacement, + // Background Eraser, Pattern Stamp and History Brush warn that they don't wrap, and Spot + // Healing stops at the edge too. Their `wrap` stroke over the corner paints as the same + // stroke without it. + let points = [[36.0, 24.0], [58.0, 39.0]]; + let cases = [ + ("paint.blur", json!({"strength": 80})), + ("paint.sharpen", json!({"strength": 60})), + ("paint.smudge", json!({"strength": 70})), + ("paint.dodge", json!({"exposure": 60})), + ("paint.burn", json!({"exposure": 40})), + ("paint.sponge", json!({"mode": "saturate"})), + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60})), + ("paint.backgroundEraser", json!({"tolerance": 40})), + ("paint.patternStamp", json!({"pattern": "Checkerboard"})), + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7})), + ("paint.historyBrush", json!({"state": 1})), + ("paint.spotHealing", json!({"type": "contentAware"})), + ]; + for (cmd, extra) in cases { + let run = |wrap: bool| { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, tiled_texture); + // Painted over since history state 1, so the History Brush has something to bring back. + paint_layer(&mut s, |x, y| tiled_texture(x + 7, y + 5)); + let mut p = dab_opts(&points, 100, &extra); + p["size"] = json!(12); + p["wrap"] = json!(wrap); let before = layer_pixels(&s); - let r = s.execute("paint.spotHealing", json!({"points": [[48, 36]], "size": 64, "hardness": 100, "type": kind, "wrap": true})).unwrap(); - assert_eq!(layer_pixels(&s), before, "{kind} at {depth}-bit"); - assert_eq!(r["damage"][2].as_i64().unwrap() * r["damage"][3].as_i64().unwrap(), 0, "{kind} at {depth}-bit: no damage, got {}", r["damage"]); - } + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + (before, layer_pixels(&s)) + }; + let ((before, plain), (_, wrapped)) = (run(false), run(true)); + assert!(plain != before, "{cmd} {extra}: the stroke paints inside the canvas"); + assert!(wrapped == plain, "{cmd} {extra}: nothing comes back at the opposite edges"); } } #[test] -fn a_wrapped_dodge_or_burn_stroke_tones_each_pixel_once() { - // A stroke round the canvas, or a dab bigger than it, comes back to pixels one canvas away; - // the stroke still tones each of them once, as a single dab does. - for depth in DEPTHS { - for cmd in ["paint.dodge", "paint.burn"] { - let run = |w: u32, h: u32, mut p: Value| { - let mut s = session(w, h, depth, "rgb"); - paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]); - p["exposure"] = json!(60); - s.execute(cmd, p).unwrap(); - layer_pixels(&s) - }; - let once = run(WRAP.0, WRAP.1, json!({"points": [[24, 18]], "size": 10, "hardness": 100})); - let n = once.len() / (WRAP.0 * WRAP.1) as usize; - let toned = &once[(18 * WRAP.0 as usize + 24) * n..][..n]; - assert_ne!(toned, &once[..n], "{cmd} at {depth}-bit: the dab tones"); - let round = run(WRAP.0, WRAP.1, json!({"points": [[2, 18], [122, 18]], "size": 10, "hardness": 100, "wrap": true})); - for x in 0..WRAP.0 as usize { - assert_eq!(&round[(18 * WRAP.0 as usize + x) * n..][..n], toned, "{cmd} at {depth}-bit: ({x}, 18)"); - } - let big = run(16, 12, json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 100, "wrap": true})); - for (i, px) in big.chunks(n).enumerate() { - assert_eq!(px, toned, "{cmd} at {depth}-bit: pixel {i} under the big dab"); - } - } - } -} - -#[test] -fn wrapped_live_strokes_show_what_their_commits_paint() { - // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the - // pixels they drew; the strokes that preview their finishing dabs show their commit's. - let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; - for depth in DEPTHS { - for (cmd, extra) in dab_cases() { - let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); - paint_layer(&mut s, tiled_texture); - let mut extra = extra.clone(); - extra["wrap"] = json!(true); - let rendered = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); - let want = layer_pixels(&s); - s.execute("edit.undo", json!({})).unwrap(); - offer_live(&mut s, cmd, &points, 50, &extra); - let taken = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); - assert_eq!(taken["fromLive"], json!(true), "{cmd} at {depth}-bit"); - assert_eq!(taken["damage"], rendered["damage"], "{cmd} at {depth}-bit"); - assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit: the live pixels are the command's"); - } - let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); - type Begin = fn(&Session, &Value) -> Result; - let shown: [(&str, Value, Begin); 5] = [ - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), crate::brush_cmds::live_color_replacement), - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60, "target": {"colors": [0, 2]}}), crate::brush_cmds::live_color_replacement), - ("paint.backgroundEraser", json!({"tolerance": 40}), crate::eraser_cmds::live_background_eraser), - ("paint.backgroundEraser", json!({"tolerance": 40, "target": {"colors": [0, 2]}}), crate::eraser_cmds::live_background_eraser), - // A seed, as the canvas sends: without one the jitter hashes all the points. - ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7}), live_pattern_impressionist), - ]; - for (cmd, extra, begin) in shown { - let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); - paint_layer(&mut s, tiled_texture); - let mut p = dab_opts(&points[..1], 50, &extra); - p["wrap"] = json!(true); - let mut live = begin(&s, &p).unwrap(); - live.push(&pts[1..]).unwrap(); - let shown = live.doc.clone(); - let pre = s.active().unwrap().doc.clone(); - p["points"] = json!(points); - s.execute(cmd, p).unwrap(); - let st = s.active().unwrap(); - let id = st.active_layer.unwrap(); - let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), st.doc.layer(id).unwrap().surface().unwrap()); - let before = pre.layer(id).unwrap().surface().unwrap(); - let worst = (0..WRAP.1 as i32) - .flat_map(|y| (0..WRAP.0 as i32).map(move |x| (x, y))) - .map(|(x, y)| (0..4).map(|c| (a.rgba(x, y)[c] - b.rgba(x, y)[c]).abs()).fold(0.0f32, f32::max)) - .fold(0.0f32, f32::max); - assert!(worst <= 2.0 / 255.0 + 1e-6, "{cmd} at {depth}-bit: live and commit differ by {worst}"); - let right = WRAP.0 as i32 - 3; - assert!((0..WRAP.1 as i32).any(|y| b.rgba(right, y) != before.rgba(right, y)), "{cmd} at {depth}-bit: the stroke came back at the right edge"); - } - } -} - -#[test] -fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { - // History Brush commits the pixels its live stroke drew across the edges; the aligned Pattern - // Stamp's preview is its commit. - let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; +fn live_history_and_pattern_strokes_stop_at_the_canvas_edge() { + // The History Brush and aligned Pattern Stamp previews stop at the edge as their commits do. + let points = [[36.0, 24.0], [58.0, 39.0]]; let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); - for depth in DEPTHS { - for (cmd, extra, setup) in [ - ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), - ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), - ] { - let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); - setup(&mut s); - let mut p = dab_opts(&points[..1], 50, &extra); - p["wrap"] = json!(true); + for (cmd, extra) in [("paint.historyBrush", json!({"state": 1})), ("paint.patternStamp", json!({"pattern": "Checkerboard"}))] { + let bounds = |wrap: bool| { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, tiled_texture); + paint_layer(&mut s, |_, _| [0.15, 0.2, 0.7, 1.0]); + let mut p = dab_opts(&points[..1], 100, &extra); + p["size"] = json!(12); + p["wrap"] = json!(wrap); let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap(); live.push(&pts[1..]).unwrap(); - p["points"] = json!(points); - let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 48, 36)); - let plain = s.execute(cmd, p.clone()).unwrap(); - let want = layer_pixels(&s); - let error = shown.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); - assert!(error <= composite_tolerance(depth), "{cmd} at {depth}-bit: the preview is off by {error}"); - if cmd == "paint.historyBrush" { - // A stroke drawn on the document as it is after the undo. - s.execute("edit.undo", json!({})).unwrap(); - let mut first = p.clone(); - first["points"] = json!(points[..1]); - let mut live = LiveRetouch::begin(&s, cmd, &first).unwrap(); - live.push(&pts[1..]).unwrap(); - s.offer_live_retouch(live).unwrap(); - let taken = s.execute(cmd, p).unwrap(); - assert_eq!(taken["fromLive"], json!(true), "{depth}-bit"); - assert_eq!(taken["damage"], plain["damage"], "{depth}-bit"); - assert!(layer_pixels(&s) == want, "{depth}-bit: the committed live pixels are the command's"); - } - } - } -} - -#[test] -fn a_wrapped_composite_stroke_over_its_work_budget_names_its_own_command() { - // Big, tightly spaced dabs pass the stroke footprint check but not the wrapped-work budget. - for (cmd, extra, setup) in [ - ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), - ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), - ] { - let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); - setup(&mut s); - let mut p = json!({"points": [[0, 0], [700, 0]], "size": 1000, "hardness": 100, "spacing": 1, "wrap": true}); - for (k, v) in extra.as_object().unwrap() { - p[k] = v.clone(); - } - let err = s.execute(cmd, p).unwrap_err().to_string(); - assert!(err.contains(cmd) && err.contains("coverage budget"), "{cmd}: {err}"); + live.bounds() + }; + assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn"); } } diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index e41308e76a..323a90036a 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::Stroke; use crate::replace::{Limits, Sampling}; -use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke_wrapped}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,14 +49,8 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { - apply_background_eraser_wrapped(target, stroke, bs, selection, None) -} - -/// [`apply_background_eraser`] wrapped around `wrap` (View › Pattern Preview): the stroke samples -/// and erases where its pixels lie on that canvas, so it carries on across the edges. -pub fn apply_background_eraser_wrapped(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>, wrap: Option) -> Rect { - let Some(effect) = background_eraser_effect(target, bs, wrap) else { return Rect::EMPTY }; - apply_dab_stroke_wrapped(target, &background_eraser_stroke(stroke), selection, false, 0, wrap, effect) + let Some(effect) = background_eraser_effect(target, bs) else { return Rect::EMPTY }; + apply_dab_stroke(target, &background_eraser_stroke(stroke), selection, false, 0, effect) } /// The stroke [`apply_background_eraser`] runs its dabs along: full opacity (the tool has none), @@ -70,9 +64,8 @@ pub fn background_eraser_stroke(stroke: &Stroke) -> Stroke { /// One Background Eraser dab's edit of a working copy of `pre` (the target before the stroke): /// shared by [`apply_background_eraser`] and live previews that run the dabs as they come, so both -/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). In a -/// stroke wrapped around `wrap`, `pre` is read where the working pixels lie on that canvas. -pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings, wrap: Option) -> Option> { +/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). +pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option> { let fmt = pre.format(); let a = alpha_index(&fmt)?; let nc = a; @@ -104,7 +97,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings, wrap: Optio return; } if orig.as_ref().is_none_or(|o| o.rect != work.rect) { - orig = Some(Region::read_wrapped(&pre, work.rect, wrap)); + orig = Some(Region::read(&pre, work.rect)); } let Some(orig) = orig.as_ref() else { return }; let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index fc356ff5cb..7054e11492 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -10,8 +10,7 @@ //! //! Module map: [`brush`] (the serde model), [`dynamics`] (points → dabs), [`render`] (dabs → //! stroke buffer → pixels), [`presets`] (built-ins), [`mixer`] (Mixer Brush), [`replace`] (Color -//! Replacement), [`retouch`] (sequential/accumulating helpers for the retouch tools), [`wrap`] -//! (View › Pattern Preview: strokes repeating across the canvas edges). +//! Replacement), [`retouch`] (sequential/accumulating helpers for the retouch tools). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -35,7 +34,6 @@ pub mod retouch; pub mod rng; pub mod symmetry; pub mod tile; -pub mod wrap; pub use brush::{ BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MAX_BRUSH_SIZE, MAX_SCATTER, MaskMode, Pattern, PatternStyle, Pose, PresetCaptures, diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 957037a0bd..20619cd48a 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -8,8 +8,7 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{Footprint, Region, apply_dab_stroke_wrapped}; -use crate::wrap::wrap_coord; +use crate::retouch::{Footprint, Region, apply_dab_stroke}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -80,29 +79,14 @@ fn dist(a: &[f32; 4], b: [f32; 3]) -> f32 { /// Replace colours along a stroke. Returns the damaged rectangle. pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &ReplaceSettings, selection: Option<&Surface>, lock_transparency: bool) -> Rect { - apply_color_replacement_wrapped(target, stroke, rs, selection, lock_transparency, None) -} - -/// [`apply_color_replacement`] wrapped around `wrap` (View › Pattern Preview): the stroke samples -/// and recolours where its pixels lie on that canvas, so it carries on across the edges. -pub fn apply_color_replacement_wrapped( - target: &mut Surface, - stroke: &Stroke, - rs: &ReplaceSettings, - selection: Option<&Surface>, - lock_transparency: bool, - wrap: Option, -) -> Rect { - let effect = color_replacement_effect(target.clone(), rs.clone(), wrap); - apply_dab_stroke_wrapped(target, stroke, selection, lock_transparency, 0, wrap, effect) + let effect = color_replacement_effect(target.clone(), rs.clone()); + apply_dab_stroke(target, stroke, selection, lock_transparency, 0, effect) } /// One Color Replacement dab's edit of a working copy, matching against `pre` (the target before /// the stroke). Shared by [`apply_color_replacement`] and live previews that run the dabs as they -/// come, so both recolour the same pixels. In a stroke wrapped around `wrap` (View › Pattern -/// Preview), `pre` is read where the dab's pixels lie on that canvas. -pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option) -> impl FnMut(&mut Region, &Footprint) { - let wrap = wrap.filter(|t| !t.is_empty()); +/// come, so both recolour the same pixels. +pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut(&mut Region, &Footprint) { let fmt = pre.format(); let n = fmt.channels(); let mode = match rs.mode { @@ -127,14 +111,13 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option< }; move |work: &mut Region, fp: &Footprint| { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); - let (sx, sy) = wrap.map_or((cx, cy), |t| (wrap_coord(cx, t.x0, t.x1), wrap_coord(cy, t.y0, t.y1))); let sampled = match rs.sampling { Sampling::Continuous => { - let c = pre.rgba(sx, sy); + let c = pre.rgba(cx, cy); [c[0], c[1], c[2]] } Sampling::Once => *once.get_or_insert_with(|| { - let c = pre.rgba(sx, sy); + let c = pre.rgba(cx, cy); [c[0], c[1], c[2]] }), Sampling::BackgroundSwatch => [rs.background[0], rs.background[1], rs.background[2]], @@ -144,15 +127,8 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option< return; } let (w, h) = (r.width() as usize, r.height() as usize); - let read = |q: Rect| { - let mut px = vec![[0.0f32; 4]; q.width() as usize * q.height() as usize]; - pre.read_rgba_into(q, &mut px); - px - }; - let pre_px = match wrap { - Some(t) => crate::wrap::read(r, t, 1, read), - None => read(r), - }; + let mut pre_px = vec![[0.0f32; 4]; w * h]; + pre.read_rgba_into(r, &mut pre_px); // Per-pixel match weight. let mut weight = vec![0.0f32; w * h]; for y in r.y0..r.y1 { diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 53ef5b9a01..ec66e32f84 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -14,13 +14,10 @@ //! All pixel data here is the surface's *native* channels as normalised `f32` (any depth, any colour //! model); colour-space-specific work happens in the callbacks. -use std::collections::HashMap; - use photocraft_color::{BlendMode, PixelFormat}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; -use crate::wrap::{fits, fold_area, wrap_coord}; use crate::{Dab, Stroke, dab_coverage, dabs}; /// A rectangle of native-channel pixels (`rect.width() × rect.height() × ch` floats, row-major). @@ -78,14 +75,6 @@ impl Region { pub fn write(&self, s: &mut Surface) { s.write_region(self.rect, &self.data); } - /// Read `rect` from a surface that repeats `wrap` apart (View › Pattern Preview): `rect` may - /// cross its edges. `None` (or an empty tile) is [`Region::read`]. - pub fn read_wrapped(s: &Surface, rect: Rect, wrap: Option) -> Self { - match wrap.filter(|t| !t.is_empty()) { - Some(tile) => Self { rect, ch: s.channels(), data: crate::wrap::read(rect, tile, s.channels(), |r| s.read_region(r)) }, - None => Self::read(s, rect), - } - } } /// One dab's footprint: coverage (hardness falloff × dab alpha) over its bounding rectangle. @@ -289,174 +278,6 @@ pub fn apply_dab_stroke( bounds } -/// [`apply_dab_stroke`] wrapped around `wrap` (View › Pattern Preview): each dab reads and edits -/// the pixels where it repeats on that tile, so a stroke crossing an edge carries on at the -/// opposite one. The dabs run one by one on a [`WorkTiles`] working copy, as live previews run -/// them. `None` (or an empty tile) is [`apply_dab_stroke`]. Returns the damaged rectangle. -pub fn apply_dab_stroke_wrapped( - target: &mut Surface, - stroke: &Stroke, - selection: Option<&Surface>, - lock_transparency: bool, - halo: i32, - wrap: Option, - mut effect: impl FnMut(&mut Region, &Footprint), -) -> Rect { - let Some(tile) = wrap.filter(|t| !t.is_empty()) else { - return apply_dab_stroke(target, stroke, selection, lock_transparency, halo, effect); - }; - let ctx = crate::BrushContext::new(&stroke.brush); - let mut work = WorkTiles::new(target.channels()).wrapped(Some(tile)); - let mut damage = Rect::EMPTY; - for (i, d) in dabs(stroke).iter().enumerate() { - let fp = ctx.footprint(d, i); - if fp.rect.is_empty() { - continue; - } - let mut region = work.read(target, fp.rect.inflate(halo)); - effect(&mut region, &fp); - work.write_dab(target, ®ion, &fp); - damage = damage.union(&fold_area(fp.rect, tile)); - } - let written: Vec = work.areas().map(|r| r.intersect(&tile)).filter(|r| !r.is_empty()).collect(); - for r in written { - let edited = work.read(target, r); - mix_back(target, &Region::read(target, r), &edited, r, stroke.brush.opacity, selection, lock_transparency); - } - damage -} - -/// Side of the tiles a [`WorkTiles`] working copy keeps its pixels in. -const WORK_TILE: i32 = 128; - -/// The working copy of a sequential-dab stroke run dab by dab (live previews, -/// [`apply_dab_stroke_wrapped`]): the target's native channels as `f32`, in tiles copied from the -/// pre-stroke surface the first time a dab writes to them. Unlike a `Surface`, it isn't rounded to -/// the document's depth between dabs, so each dab sees what [`apply_dab_stroke`]'s working copy -/// holds. Wrapped around a tile ([`Self::wrapped`]), it keeps each pixel once, where it lies on -/// the tile, and reads and writes it wherever it repeats. -pub struct WorkTiles { - ch: usize, - tiles: HashMap<(i32, i32), Vec>, - wrap: Option, -} - -impl WorkTiles { - pub fn new(ch: usize) -> Self { - Self { ch, tiles: HashMap::new(), wrap: None } - } - - /// Keep the pixels of a stroke wrapped around `tile` (View › Pattern Preview). `None` or an - /// empty tile doesn't wrap. - pub fn wrapped(mut self, tile: Option) -> Self { - self.wrap = tile.filter(|t| !t.is_empty()); - self - } - - fn tile_rect((tx, ty): (i32, i32)) -> Rect { - Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) - } - - /// The tiles `r` overlaps. - fn coords(r: Rect) -> impl Iterator { - let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); - let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); - let empty = r.is_empty(); - (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) - } - - /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. - /// Wrapped, `r` may cross the tile's edges or be bigger than it. - pub fn read(&self, pre: &Surface, r: Rect) -> Region { - match self.wrap { - Some(tile) => Region { rect: r, ch: self.ch, data: crate::wrap::read(r, tile, self.ch, |part| self.read_at(pre, part).data) }, - None => self.read_at(pre, r), - } - } - - fn read_at(&self, pre: &Surface, r: Rect) -> Region { - let mut out = Region::new(r, self.ch); - let n = self.ch; - let mut buf = Vec::new(); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - let tile = self.tiles.get(&c); - if tile.is_none() { - pre.read_region_into(part, &mut buf); - } - for y in part.y0..part.y1 { - let src = match tile { - Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), - None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), - }; - let dst = out.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - out - } - - /// Store a dab's edit: the pixels of `region` (which covers the dab) over the dab's rectangle - /// `fp.rect`. Wrapped, they land where they lie on the tile; a dab bigger than the tile covers - /// some pixels more than once, and each keeps the copy the dab covers most. - pub fn write_dab(&mut self, pre: &Surface, region: &Region, fp: &Footprint) { - let r = fp.rect.intersect(®ion.rect); - let Some(tile) = self.wrap else { - return self.write_at(pre, ®ion.crop(r)); - }; - if fits(r, tile) { - for (part, (dx, dy)) in crate::wrap::pieces(r, tile) { - let mut piece = region.crop(part.translate(dx, dy)); - piece.rect = part; - self.write_at(pre, &piece); - } - return; - } - let area = fold_area(r, tile); - let mut folded = self.read_at(pre, area); - let mut most = vec![0.0f32; area.width() as usize * area.height() as usize]; - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (c, tx, ty) = (fp.at(x, y), wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); - if let Some(m) = most.get_mut(folded.index(tx, ty)) - && c > *m - { - *m = c; - folded.px_mut(tx, ty).copy_from_slice(region.px(x, y)); - } - } - } - self.write_at(pre, &folded); - } - - /// Store `region` as the working pixels of its rectangle. - fn write_at(&mut self, pre: &Surface, region: &Region) { - let (r, n) = (region.rect, self.ch); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - for y in part.y0..part.y1 { - let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; - let src = region.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - } - - /// The rectangles of the tiles dabs have written to. - pub fn areas(&self) -> impl Iterator + '_ { - self.tiles.keys().map(|&c| Self::tile_rect(c)) - } -} - /// Mix a sequential-dab stroke's working pixels `work` back over the original `orig` at /// `opacity × selection` inside `rect`, and write that area to `target`: the last step of /// [`apply_dab_stroke`], shared with live previews that run the dabs as they come. @@ -1066,89 +887,4 @@ mod tests { } } } - - /// The canvas the Pattern Preview strokes below wrap around. - const TILE: Rect = Rect::new(0, 0, 40, 30); - - /// `area` of a surface showing the canvas repeated: every pixel of the canvas has its own colour. - fn tiled(fmt: PixelFormat, area: Rect) -> Surface { - let mut s = Surface::new(fmt); - let px = (area.y0..area.y1).flat_map(|y| (area.x0..area.x1).map(move |x| (wrap_coord(x, 0, 40), wrap_coord(y, 0, 30)))); - let data: Vec = px.flat_map(|(x, y)| [x as f32 / 40.0, y as f32 / 30.0, ((x * 7 + y * 3) % 13) as f32 / 13.0, 1.0]).collect(); - s.write_region(area, &data); - s - } - - fn smudge(size: f32) -> impl FnMut(&mut Region, &Footprint) { - let mut sm = Smudge::new(0.8, None, size).with_alpha(Some(3)); - move |work, fp| sm.dab(work, fp) - } - - #[test] - fn a_wrapped_sequential_stroke_paints_the_canvas_as_its_tiled_copies_would() { - // View › Pattern Preview: a smudge across a corner of the canvas copy it is drawn on gives - // the canvas exactly what it gives the canvas tiled three by three, where it runs into the - // next copies and carries their colours along. (The same points both times: moved points - // would place the dabs a rounding apart.) - let fmt = PixelFormat::RGBA32F; - let far = stroke(&[(68.0, 48.0), (92.0, 70.0)], 12.0, 0.5); - let halo = Smudge::sampling_radius(12.0); - let mut canvas = tiled(fmt, TILE); - let dmg = apply_dab_stroke_wrapped(&mut canvas, &far, None, false, halo, Some(TILE), smudge(12.0)); - let three = Rect::new(0, 0, 120, 90); - let (before, mut big) = (tiled(fmt, three), tiled(fmt, three)); - apply_dab_stroke(&mut big, &far, None, false, halo, smudge(12.0)); - let mut changed = 0; - for y in 0..30 { - for x in 0..40 { - let copies = [(40, 30), (80, 30), (40, 60), (80, 60)].map(|(dx, dy)| (x + dx, y + dy)); - let moved: Vec<_> = copies.into_iter().filter(|&(u, v)| big.pixel(u, v) != before.pixel(u, v)).collect(); - let want = match moved.as_slice() { - [] => before.pixel(x, y), - [(u, v)] => big.pixel(*u, *v), - _ => panic!("({x}, {y}): two copies under the stroke"), - }; - assert_eq!(canvas.pixel(x, y), want, "({x}, {y})"); - changed += usize::from(!moved.is_empty()); - } - } - assert!(changed > 300, "the smudge crossed both edges ({changed} px)"); - assert!(dmg.x0 >= 0 && dmg.y0 >= 0 && dmg.x1 <= 40 && dmg.y1 <= 30, "{dmg:?}"); - assert!((-12..0).all(|x| canvas.pixel(x, 20)[3] == 0.0) && (40..52).all(|x| canvas.pixel(x, 20)[3] == 0.0), "nothing off the canvas"); - } - - #[test] - fn a_wrapped_sequential_stroke_inside_the_canvas_paints_as_before() { - for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { - let st = stroke(&[(16.0, 12.0), (24.0, 18.0)], 6.0, 0.8); - let run = |wrap| { - let mut s = tiled(fmt, TILE); - let dmg = apply_dab_stroke_wrapped(&mut s, &st, None, false, Smudge::sampling_radius(6.0), wrap, smudge(6.0)); - (s, dmg) - }; - let ((plain, d0), (wrapped, d1)) = (run(None), run(Some(TILE))); - assert_eq!(d0, d1, "{fmt:?}"); - assert!((0..30).all(|y| (0..40).all(|x| plain.pixel(x, y) == wrapped.pixel(x, y))), "{fmt:?}"); - assert!(plain.pixel(20, 15) != tiled(fmt, TILE).pixel(20, 15), "{fmt:?}: the stroke smudged"); - } - } - - #[test] - fn a_wrapped_dab_bigger_than_the_canvas_edits_all_of_it_and_nothing_else() { - let small = Rect::new(0, 0, 10, 8); - let mut s = Surface::new(PixelFormat::RGBA32F); - s.fill_rect(small, &[1.0, 0.0, 0.0, 1.0]); - let darken = |work: &mut Region, fp: &Footprint| { - let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - work.px_mut(x, y)[0] *= 1.0 - 0.5 * fp.at(x, y); - } - } - }; - let dmg = apply_dab_stroke_wrapped(&mut s, &stroke(&[(5.0, 4.0), (9.0, 6.0)], 30.0, 0.0), None, false, 0, Some(small), darken); - assert_eq!(dmg, small); - assert!((0..8).all(|y| (0..10).all(|x| s.pixel(x, y)[0] < 1.0)), "every pixel under the dab's copies darkens"); - assert!((-5..15).all(|x| s.pixel(x, -1)[3] == 0.0 && s.pixel(x, 8)[3] == 0.0), "nothing off the canvas"); - } } diff --git a/crates/paint/src/wrap.rs b/crates/paint/src/wrap.rs deleted file mode 100644 index efad49d3ca..0000000000 --- a/crates/paint/src/wrap.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! View › Pattern Preview geometry: the document repeats its canvas (the *tile*) in every -//! direction, so a stroke past one edge carries on at the opposite one. Coordinates outside the -//! tile name the pixel a whole number of tile sizes away that lies on it. - -use photocraft_geom::Rect; - -/// `v` moved a whole number of periods into `lo..hi` (`v` itself when that is empty). -#[inline] -pub fn wrap_coord(v: i32, lo: i32, hi: i32) -> i32 { - let period = i64::from(hi) - i64::from(lo); - if period <= 0 { - return v; - } - (i64::from(lo) + (i64::from(v) - i64::from(lo)).rem_euclid(period)) as i32 -} - -/// Whether `rect` fits in `tile`, so it covers no pixel of the tile twice. -pub fn fits(rect: Rect, tile: Rect) -> bool { - rect.width() <= tile.width() && rect.height() <= tile.height() -} - -/// The part of `tile` that `rect` covers once moved onto it: per axis, the rectangle's span moved -/// a whole number of periods, or the whole tile where the span is longer or crosses an edge. -pub fn fold_area(rect: Rect, tile: Rect) -> Rect { - if rect.is_empty() || tile.is_empty() { - return Rect::EMPTY; - } - let span = |a0: i32, a1: i32, lo: i32, hi: i32| { - let start = wrap_coord(a0, lo, hi); - let len = i64::from(a1) - i64::from(a0); - if i64::from(start) + len > i64::from(hi) { (lo, hi) } else { (start, start + len as i32) } - }; - let (x0, x1) = span(rect.x0, rect.x1, tile.x0, tile.x1); - let (y0, y1) = span(rect.y0, rect.y1, tile.y0, tile.y1); - Rect::new(x0, y0, x1, y1) -} - -/// One axis of `a0..a1` cut where it crosses the edges of `lo..hi`: each run's start on the tile, -/// its start in `a0..a1`, and its length. -fn runs(a0: i32, a1: i32, lo: i32, hi: i32) -> Vec<(i32, i32, i32)> { - let period = i64::from(hi) - i64::from(lo); - let mut out = Vec::new(); - let mut a = i64::from(a0); - while a < i64::from(a1) { - let k = (a - i64::from(lo)).div_euclid(period) * period; - let end = (i64::from(a1) - k).min(i64::from(hi)); - let start = a - k; - out.push((start as i32, a as i32, (end - start) as i32)); - a = end + k; - } - out -} - -/// `r` cut where it crosses the edges of `tile`: each part moved onto the tile, with the offset -/// that moves it back (`part.translate(dx, dy)` is that part of `r`). A rectangle bigger than the -/// tile gives parts that overlap on it. -pub fn pieces(r: Rect, tile: Rect) -> Vec<(Rect, (i32, i32))> { - if r.is_empty() || tile.is_empty() { - return Vec::new(); - } - let xs = runs(r.x0, r.x1, tile.x0, tile.x1); - runs(r.y0, r.y1, tile.y0, tile.y1) - .into_iter() - .flat_map(|(ty, uy, h)| xs.iter().map(move |&(tx, ux, w)| (Rect::new(tx, ty, tx + w, ty + h), (ux - tx, uy - ty)))) - .collect() -} - -/// The values over `r` of an image repeating `tile` apart, given `read`, which returns a -/// rectangle of the tile (`n` values per pixel, row-major). A rectangle crossing at most one edge -/// per axis is read in its parts; a bigger one reads the tile area it covers once. -pub fn read(r: Rect, tile: Rect, n: usize, mut read: impl FnMut(Rect) -> Vec) -> Vec { - let w = r.width() as usize; - let mut out = vec![T::default(); w * r.height() as usize * n]; - if r.is_empty() || tile.is_empty() { - return out; - } - let (xs, ys) = (runs(r.x0, r.x1, tile.x0, tile.x1), runs(r.y0, r.y1, tile.y0, tile.y1)); - let mut copy = |src: &[T], at: usize, x: i32, y: i32, len: i32| { - let o = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; - let len = len as usize * n; - if let (Some(d), Some(s)) = (out.get_mut(o..o + len), src.get(at..at + len)) { - d.copy_from_slice(s); - } - }; - if xs.len() * ys.len() <= 4 { - for &(ty, uy, h) in &ys { - for &(tx, ux, len) in &xs { - let part = read(Rect::new(tx, ty, tx + len, ty + h)); - for row in 0..h { - copy(&part, row as usize * len as usize * n, ux, uy + row, len); - } - } - } - return out; - } - let area = fold_area(r, tile); - let src = read(area); - let aw = area.width() as usize; - for &(ty, uy, h) in &ys { - for row in 0..h { - for &(tx, ux, len) in &xs { - copy(&src, ((ty + row - area.y0) as usize * aw + (tx - area.x0) as usize) * n, ux, uy + row, len); - } - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn coordinates_wrap_onto_the_tile_from_any_distance() { - assert_eq!([-17, -1, 0, 15, 16, 33, 1_000_000].map(|v| wrap_coord(v, 0, 16)), [15, 15, 0, 15, 0, 1, 0]); - assert_eq!(wrap_coord(-3, 10, 20), 17); - } - - #[test] - fn pieces_cover_the_rectangle_once_and_land_on_the_tile() { - let tile = Rect::new(0, 0, 10, 8); - for r in [Rect::new(7, 6, 13, 11), Rect::new(-25, 3, 4, 5), Rect::new(2, 2, 5, 5), Rect::new(-3, -3, 30, 20)] { - let mut seen = std::collections::HashMap::new(); - for (part, (dx, dy)) in pieces(r, tile) { - assert!(tile.contains(part.x0, part.y0) && part.x1 <= tile.x1 && part.y1 <= tile.y1, "{part:?} on the tile"); - for y in part.y0..part.y1 { - for x in part.x0..part.x1 { - *seen.entry((x + dx, y + dy)).or_insert(0) += 1; - assert_eq!((wrap_coord(x + dx, 0, 10), wrap_coord(y + dy, 0, 8)), (x, y)); - } - } - } - assert_eq!(seen.len(), (r.width() * r.height()) as usize, "{r:?}"); - assert!(seen.values().all(|&c| c == 1) && seen.keys().all(|&(x, y)| r.contains(x, y)), "{r:?}"); - } - } - - #[test] - fn a_read_repeats_the_tile_whatever_the_rectangle() { - let tile = Rect::new(4, 2, 14, 10); - let value = |x: i32, y: i32| (wrap_coord(x, 4, 14) * 100 + wrap_coord(y, 2, 10)) as f32; - let mut reads = 0; - // Two values per pixel; `read` only ever asks for rectangles of the tile. - let mut from_tile = |q: Rect| { - reads += 1; - assert!(q.x0 >= tile.x0 && q.y0 >= tile.y0 && q.x1 <= tile.x1 && q.y1 <= tile.y1, "{q:?}"); - (q.y0..q.y1).flat_map(|y| (q.x0..q.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect::>() - }; - for r in [Rect::new(10, 7, 17, 12), Rect::new(5, 3, 9, 6), Rect::new(-31, -5, 40, 30), Rect::new(0, 0, 0, 5)] { - let got = read(r, tile, 2, &mut from_tile); - let want: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect(); - assert_eq!(got, want, "{r:?}"); - } - assert!(reads <= 4 + 1 + 1, "small rectangles read in parts, big ones once ({reads})"); - } - - #[test] - fn the_fold_area_is_where_a_rectangle_lands() { - let tile = Rect::new(0, 0, 10, 8); - assert_eq!(fold_area(Rect::new(12, 9, 15, 12), tile), Rect::new(2, 1, 5, 4)); - assert_eq!(fold_area(Rect::new(8, 2, 12, 4), tile), Rect::new(0, 2, 10, 4)); - assert_eq!(fold_area(Rect::new(-50, -50, 50, 50), tile), tile); - assert_eq!(fold_area(Rect::EMPTY, tile), Rect::EMPTY); - assert!(fits(Rect::new(3, 3, 13, 11), tile) && !fits(Rect::new(0, 0, 11, 2), tile)); - } -} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c0efd7ae37..ac471a9269 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -97,9 +97,7 @@ pub(crate) use selection::{floating_outline, selection_drag_delta}; pub(crate) use start_screen::start_screen_drop_hint; pub(crate) use strokes::multiple_color_target; pub use strokes::paint_target; -pub(crate) use strokes::{ - ClonePreviewCache, CommittedStroke, LiveStroke, cancel_mixer_on_context_change, freehand_tool, pattern_wrap, spread_ms, strokes_live, -}; +pub(crate) use strokes::{ClonePreviewCache, CommittedStroke, LiveStroke, cancel_mixer_on_context_change, freehand_tool, spread_ms, strokes_live}; use strokes::{HOLD_REPEAT_MS, draw_clone_preview, feed_live_stroke, pointer_button_modifiers, pointer_moves, stroke_wants_time, tap_at_peak_pressure}; #[cfg(test)] pub(crate) use strokes::{SPOT_HEALING_TRAIL, live_retouch_points}; diff --git a/crates/ui-egui/src/canvas/strokes.rs b/crates/ui-egui/src/canvas/strokes.rs index 404a46b9fa..c7c6d4b643 100644 --- a/crates/ui-egui/src/canvas/strokes.rs +++ b/crates/ui-egui/src/canvas/strokes.rs @@ -310,13 +310,6 @@ pub(super) fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec]) p } -/// View › Pattern Preview: a painting stroke goes on across the canvas edges (#8883). -pub(crate) fn pattern_wrap(app: &PhotocraftApp, p: &mut serde_json::Value) { - if app.ui.view.pattern_preview { - p["wrap"] = json!(true); - } -} - /// Tools whose strokes the engine renders while they are drawn (`LiveStroke`). pub(crate) fn strokes_live(tool: Tool) -> bool { matches!( @@ -394,7 +387,6 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option { } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? }; - pattern_wrap(app, &mut p); p["points"] = json!(d.points); p["target"] = paint_target(app); EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)) diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index 5e44707c28..ed59943fae 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -499,60 +499,6 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { } } -/// View › Pattern Preview (#8883): a Blur, Smudge or Dodge stroke dragged past the right edge -/// carries on at the left one, and the release still commits the pixels its live stroke drew. -#[test] -fn a_sequential_dab_stroke_past_the_edge_in_pattern_preview_commits_its_live_pixels() { - for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] { - let setup = |run: &mut dyn FnMut(&str, Value)| { - run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}})); - run("paint.stroke", json!({"points": [[0, 50], [200, 50]], "size": 30, "hardness": 0.5, "color": "#c04020"})); - run("paint.stroke", json!({"points": [[0, 35], [200, 65]], "size": 8, "hardness": 1.0, "color": "#2060c0"})); - }; - let mut h = harness(); - setup(&mut |id, p| { - h.state_mut().run(id, p).unwrap(); - }); - let before = h.state().session.active().unwrap().doc.clone(); - { - let app = h.state_mut(); - app.ui.tool = tool; - app.ui.view.pattern_preview = true; - app.ui.views[0].zoom = 2.0; - app.ui.views[0].center = [100.0, 50.0]; - } - h.run_steps(1); - move_to(&mut h, 150.0, 50.0); - button(&mut h, 150.0, 50.0, true); - for (x, y) in [(175.0, 52.0), (200.0, 50.0), (225.0, 47.0), (245.0, 50.0)] { - move_to(&mut h, x, y); - } - assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke"); - h.state_mut().retouch_from_live = false; - button(&mut h, 245.0, 50.0, false); - h.run_steps(2); - let app = h.state(); - assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again"); - let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke"); - assert_eq!(p["wrap"], json!(true), "{cmd}: {p}"); - let mut fresh = photocraft_engine::Session::new(); - fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); - setup(&mut |id, p| { - fresh.execute(id, p).unwrap(); - }); - fresh.execute(cmd, p).unwrap(); - let st = app.session.active().unwrap(); - let (a, b, pre) = ( - photocraft_compose::render(&st.doc, st.doc.bounds()), - photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()), - photocraft_compose::render(&before, st.doc.bounds()), - ); - assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ"); - let left = |buf: &photocraft_compose::Buffer| (30..70).flat_map(|y| (0..30).map(move |x| (x, y))).map(|(x, y)| buf.px[y * 200 + x]).collect::>(); - assert_ne!(left(&a), left(&pre), "{cmd}: the stroke came back at the left edge"); - } -} - /// Pressure changes Smudge's painted footprint, but not the square of colour it carries. /// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo. #[test] diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index 8782c29863..ac896eeb0b 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -49,16 +49,14 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - /// the commit, with the jitter seed from the first point (`retouch_ui::stroke_seed`). pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { let o = &app.ui.tool_options; - let mut p = json!({ + json!({ "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, "tolerance": o.bg_tolerance, "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), - }); - crate::canvas::pattern_wrap(app, &mut p); - p + }) } /// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index c694679202..6b6d0f07e2 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -29,13 +29,11 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { let o = &app.ui.tool_options; // The options bar's Mode is the tool's own brush's. let mode = crate::blend_keys::brush_tool_mode(app.ui.tool, app.session.tools.brush.mode); - let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label()}); + // View › Pattern Preview: both tools carry a stroke on across the canvas edges (#8883). + let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label(), "wrap": app.ui.view.pattern_preview}); if app.ui.tool == Tool::CloneStamp { - p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); - } else { - crate::canvas::pattern_wrap(app, &mut p); } // The Clone Source panel's active slot (set by ⌥-click) drives the stroke: the engine keeps // the aligned pairing and applies the slot's scale/rotation/flip. @@ -63,7 +61,7 @@ pub(crate) fn history_brush_params(app: &PhotocraftApp) -> Value { /// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit. pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; - let (cmd, mut p) = match tool { + Some(match tool { Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), @@ -73,9 +71,7 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st ), Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), _ => return None, - }; - crate::canvas::pattern_wrap(app, &mut p); - Some((cmd, p)) + }) } /// `paint.colorReplacement` params from the options bar (without `points`): shared by the live @@ -83,7 +79,7 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st /// stroke paints the same colour; Background Swatch sampling matches the background colour. pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { let o = &app.ui.tool_options; - let mut p = json!({ + json!({ "color": app.session.tools.foreground, "mode": o.cr_mode, "sampling": o.cr_sampling, @@ -92,9 +88,7 @@ pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { "antiAlias": o.cr_anti_alias, // A mask or channel target is refused: the tool recolours layer pixels only. "target": crate::canvas::paint_target(app), - }); - crate::canvas::pattern_wrap(app, &mut p); - p + }) } /// `paint.patternStamp` params from the options bar and the Patterns panel (without `points` and @@ -119,7 +113,6 @@ pub(crate) fn pattern_stamp_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { p["pattern"] = json!(id); } - crate::canvas::pattern_wrap(app, &mut p); p } @@ -188,10 +181,6 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m _ => return false, }; p["points"] = pts; - // Spot Healing and History Brush; the others' params have it already. - if !matches!(tool, Tool::Remove | Tool::MixerBrush) { - crate::canvas::pattern_wrap(app, &mut p); - } // The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints. p["target"] = crate::canvas::paint_target(app); let result = app.run(cmd, p); diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index bfe9672331..816c9ca12e 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -29,24 +29,26 @@ fn stripes(app: &mut PhotocraftApp, step: usize, target: &str) { } #[test] -fn every_retouching_stroke_wraps_in_pattern_preview() { - // View › Pattern Preview (#8883): a stroke across the right edge asks its command to go - // on at the opposite one, and only then. +fn only_clone_stamp_and_healing_brush_wrap_in_pattern_preview() { + // View › Pattern Preview (#8883): a Clone Stamp or Healing Brush stroke across the right edge + // asks its command to go on at the opposite one, and only then. The reference application's + // other retouching tools stop at the edge. let tools = [ - (Tool::Blur, "paint.blur"), - (Tool::Sharpen, "paint.sharpen"), - (Tool::Smudge, "paint.smudge"), - (Tool::Dodge, "paint.dodge"), - (Tool::Burn, "paint.burn"), - (Tool::Sponge, "paint.sponge"), - (Tool::ColorReplacement, "paint.colorReplacement"), - (Tool::PatternStamp, "paint.patternStamp"), - (Tool::Healing, "paint.healingBrush"), - (Tool::HistoryBrush, "paint.historyBrush"), - (Tool::SpotHealing, "paint.spotHealing"), - (Tool::BackgroundEraser, "paint.backgroundEraser"), + (Tool::CloneStamp, "paint.cloneStamp", true), + (Tool::Healing, "paint.healingBrush", true), + (Tool::Blur, "paint.blur", false), + (Tool::Sharpen, "paint.sharpen", false), + (Tool::Smudge, "paint.smudge", false), + (Tool::Dodge, "paint.dodge", false), + (Tool::Burn, "paint.burn", false), + (Tool::Sponge, "paint.sponge", false), + (Tool::ColorReplacement, "paint.colorReplacement", false), + (Tool::PatternStamp, "paint.patternStamp", false), + (Tool::HistoryBrush, "paint.historyBrush", false), + (Tool::SpotHealing, "paint.spotHealing", false), + (Tool::BackgroundEraser, "paint.backgroundEraser", false), ]; - for (tool, cmd) in tools { + for (tool, cmd, wraps) in tools { for pattern_preview in [false, true] { let mut app = app(); stripes(&mut app, 8, "pixels"); @@ -59,7 +61,7 @@ fn every_retouching_stroke_wraps_in_pattern_preview() { tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 30.0 }, m); assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status); let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().unwrap_or_else(|| panic!("{tool:?}: no {cmd}")); - assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), pattern_preview, "{tool:?}: {p}"); + assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), wraps && pattern_preview, "{tool:?}: {p}"); } } } From 920bf1b6a8810912aa0b73842b608ea20e2758d9 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 14:50:09 -0600 Subject: [PATCH 23/77] Count tool: History names Add/Drag/Delete Count Object, Add Count Group and one name per group setting (#11763) --- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/analysis_cmds.rs | 28 +++-- .../src/cmd/count_history_names_tests.rs | 100 ++++++++++++++++++ 3 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/count_history_names_tests.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c4addf4c22..47672f82b4 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -69,6 +69,8 @@ pub mod comps_cmds; pub mod contour_lib_cmds; #[cfg(test)] mod count_delete_group_tests; +#[cfg(test)] +mod count_history_names_tests; pub mod cutout_cmds; pub mod distort_cmds; pub mod document_preset_cmds; diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index d9d7d1510c..18dad6c9f8 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -796,7 +796,7 @@ fn count_add(s: &mut Session, p: &Value) -> Result { if gi.is_some_and(|i| i >= group_count) { return Err(bad("count.add", "no such group")); } - let n = s.edit("Count", |d, _| { + let n = s.edit("Add Count Object", |d, _| { if d.measurement.count_groups.is_empty() { let g = new_group(d, None, None); d.measurement.count_groups.push(g); @@ -846,7 +846,7 @@ fn locate(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { fn count_remove(s: &mut Session, p: &Value) -> Result { let (g, i) = locate(s, p, "count.remove")?; - s.edit("Count", |d, _| { + s.edit("Delete Count Object", |d, _| { d.measurement.count_groups[g].points.remove(i); Ok(()) })?; @@ -856,7 +856,7 @@ fn count_remove(s: &mut Session, p: &Value) -> Result { fn count_move(s: &mut Session, p: &Value) -> Result { let to = point(p.get("to")).ok_or_else(|| bad("count.move", "pass `to`: [x, y]"))?; let (g, i) = locate(s, p, "count.move")?; - s.edit("Count", |d, _| { + s.edit("Drag Count Object", |d, _| { d.measurement.count_groups[g].points[i] = to; Ok(()) })?; @@ -881,7 +881,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result { fn count_new_group(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); let color = color_of("count.newGroup", p, "color")?; - s.edit("New Count Group", |d, _| { + s.edit("Add Count Group", |d, _| { let g = new_group(d, name, color); d.measurement.count_groups.push(g); d.measurement.active_count_group = d.measurement.count_groups.len() - 1; @@ -949,8 +949,24 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { } let activate = p.get("active").and_then(Value::as_bool) == Some(true) || p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key("group")); let changed = grp != d.measurement.count_groups[g]; - if changed || (activate && d.measurement.active_count_group != g) { - s.edit("Count Group Options", |d, _| { + let switches = activate && d.measurement.active_count_group != g; + if changed || switches { + // One field changed: Photoshop's name for it (#11763). Several fields, or a switch of the + // current group (not measured), keep "Count Group Options". + let old = &d.measurement.count_groups[g]; + let fields = [ + (grp.name != old.name, "Set Count Group Name"), + (grp.visible != old.visible, "Set Count Group Visibility"), + (grp.color != old.color, "Set Count Draw Color"), + (grp.marker_size != old.marker_size, "Set Count Marker Size"), + (grp.label_size != old.label_size, "Set Count Font Size"), + ]; + let mut one = fields.iter().filter(|(c, _)| *c).map(|(_, l)| *l); + let label = match (one.next(), one.next()) { + (Some(l), None) if !switches => l, + _ => "Count Group Options", + }; + s.edit(label, |d, _| { d.measurement.count_groups[g] = grp; if activate { d.measurement.active_count_group = g; diff --git a/crates/engine/src/cmd/count_history_names_tests.rs b/crates/engine/src/cmd/count_history_names_tests.rs new file mode 100644 index 0000000000..2cfa357ab4 --- /dev/null +++ b/crates/engine/src/cmd/count_history_names_tests.rs @@ -0,0 +1,100 @@ +//! #11763: each Count tool edit gets the reference application's History name. A `count.setGroup` +//! that changes exactly one field takes that field's name; several fields at once, or a switch of +//! the current group, keep "Count Group Options". + +use serde_json::{Value, json}; + +use crate::Session; + +/// A 100 × 100 document with one count group holding one marker at 10,10 (its first state is +/// "Add Count Object"). +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// Run `id` and return the History name of the one step it added. +fn named(s: &mut Session, id: &str, p: Value) -> String { + let before = steps(s); + s.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + assert_eq!(steps(s), before + 1, "{id} {p} adds exactly one step"); + s.active().unwrap().history.undo_label().unwrap_or_default().to_string() +} + +#[test] +fn marker_edits_are_add_drag_and_delete_count_object() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + // The first click makes "Count Group 1" too, still one "Add Count Object" step. + assert_eq!(named(&mut s, "count.add", json!({"x": 10, "y": 10})), "Add Count Object"); + assert_eq!(named(&mut s, "count.add", json!({"x": 40, "y": 40})), "Add Count Object"); + assert_eq!(named(&mut s, "count.move", json!({"x": 10, "y": 10, "to": [20, 20]})), "Drag Count Object"); + assert_eq!(named(&mut s, "count.remove", json!({"x": 40, "y": 40})), "Delete Count Object"); +} + +#[test] +fn group_edits_keep_or_take_their_names() { + let mut s = session(); + assert_eq!(named(&mut s, "count.newGroup", json!({"name": "Leaves"})), "Add Count Group"); + assert_eq!(named(&mut s, "count.clear", json!({})), "Clear Count"); + assert_eq!(named(&mut s, "count.deleteGroup", json!({})), "Delete Count Group"); +} + +#[test] +fn a_single_field_change_uses_that_fields_name() { + for (p, want) in [ + (json!({"name": "Seeds"}), "Set Count Group Name"), + (json!({"visible": false}), "Set Count Group Visibility"), + (json!({"color": "#204080"}), "Set Count Draw Color"), + (json!({"markerSize": 5}), "Set Count Marker Size"), + (json!({"labelSize": 20}), "Set Count Font Size"), + ] { + let mut s = session(); + assert_eq!(named(&mut s, "count.setGroup", p.clone()), want, "{p}"); + } +} + +#[test] +fn several_fields_at_once_keep_count_group_options() { + let mut s = session(); + assert_eq!(named(&mut s, "count.setGroup", json!({"markerSize": 5, "labelSize": 20})), "Count Group Options"); + // A field sent with its current value doesn't count as a change. + let mut s = session(); + let name = s.execute("image.analysis.countTool", json!({})).unwrap()["groups"][0]["name"].clone(); + assert_eq!(named(&mut s, "count.setGroup", json!({"name": name, "markerSize": 6})), "Set Count Marker Size"); +} + +#[test] +fn switching_the_current_group_keeps_todays_name() { + let mut s = session(); + s.execute("count.newGroup", json!({"name": "Two"})).unwrap(); + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0})), "Count Group Options", "only `group`"); + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 1, "active": true})), "Count Group Options", "`active: true`, no field change"); + // A field change together with a switch is two changes: today's name too. + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0, "active": true, "visible": false})), "Count Group Options"); +} + +#[test] +fn no_change_adds_no_step_and_bad_params_stay_errors() { + let mut s = session(); + let before = steps(&s); + // The current group's own values: nothing changes, no step. + s.execute("count.setGroup", json!({"group": 0})).unwrap(); + assert_eq!(steps(&s), before); + for (id, p) in [ + ("count.setGroup", json!({"group": 9, "name": "x"})), + ("count.setGroup", json!({"markerSize": 2.5})), + ("count.move", json!({"x": 90, "y": 90, "to": [1, 1]})), + ("count.remove", json!({"x": 90, "y": 90})), + ("count.add", json!({"x": 1, "y": 1, "group": 7})), + ] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + assert_eq!(steps(&s), before, "{id} {p} adds no step"); + } +} From 8f40ec918128b8e1a3d1f65a97b14f05a2c3a85f Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:30:48 -0500 Subject: [PATCH 24/77] Pencil: the Auto Erase checkbox shows its tooltip Fixes #11968. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/options_bar.rs | 3 +- .../src/pencil_auto_erase_tooltip_tests.rs | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f19ec41da9..2e6702120d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1001,6 +1001,8 @@ mod doc_tab_menu_tests; #[cfg(test)] mod layers_empty_area_menu_tests; #[cfg(test)] +mod pencil_auto_erase_tooltip_tests; +#[cfg(test)] mod preferences_submenu_tests; #[cfg(test)] mod selection_move_step_tests; diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 34e4042f48..9a106a8c70 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -219,7 +219,8 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 }); widgets::vline(ui, 22.0); - widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase")); + widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase")) + .on_hover_text(tl!("Draws background color over foreground colors")); } Tool::Brush | Tool::Eraser => { brush_preset_chip(ui, b, &mut app.ui); diff --git a/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs b/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs new file mode 100644 index 0000000000..7ae125d24a --- /dev/null +++ b/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs @@ -0,0 +1,34 @@ +//! #11968: hovering the Pencil's Auto Erase checkbox shows "Draws background color over +//! foreground colors", in the Pro and the other themes. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; +use crate::theme::ThemeKind; + +const TIP: &str = "Draws background color over foreground colors"; + +fn bar(theme: ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = Tool::Pencil; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h +} + +#[test] +fn auto_erase_has_the_reference_tooltip() { + for theme in [ThemeKind::ProMedium, ThemeKind::Classic] { + let mut h = bar(theme); + // Hovering the checkbox's label shows it. + h.get_by_label("Auto Erase").hover(); + h.run_steps(60); + assert!(h.query_by_label(TIP).is_some(), "{theme:?}: no tooltip on hover"); + } +} From c1f630791a89a06cf7ac801073074fae72d3acbd Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 16:49:36 -0500 Subject: [PATCH 25/77] Open as Smart Object: tests (#11678) Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/file_cmds.rs | 4 + .../file_cmds/open_as_smart_object_tests.rs | 196 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 495f9d1892..cce669ec45 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -421,6 +421,10 @@ mod tests; #[path = "file_cmds/place_job_tests.rs"] mod place_job_tests; +#[cfg(test)] +#[path = "file_cmds/open_as_smart_object_tests.rs"] +mod open_as_smart_object_tests; + #[cfg(test)] mod aces_exr_tests { use super::*; diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs new file mode 100644 index 0000000000..6378c47353 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -0,0 +1,196 @@ +//! File › Open as Smart Object… (#11678): a file opens as a new untitled document holding one +//! embedded smart object layer. + +use super::*; + +fn tmp(name: &str) -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("photocraft-open-as-so-{}-{name}-{id}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.to_string_lossy().into_owned() +} + +/// A small PNG with a gradient, so the pixels differ from place to place. +fn write_png(dir: &str, name: &str, w: u32, h: u32) -> String { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let path = join(dir, name); + std::fs::write(&path, bytes).unwrap(); + path +} + +/// A two-layer 16-bit PSD at 300 ppi tagged with Display P3, made with the engine's own save. +fn write_psd16(dir: &str, name: &str, w: u32, h: u32) -> String { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "display-p3"})).unwrap(); + let path = join(dir, name); + save_doc(&s.active().unwrap().doc, &path, None).unwrap(); + path +} + +fn imported(path: &str) -> Document { + photocraft_io::import(&file_name(path), &std::fs::read(path).unwrap()).unwrap().document +} + +fn active(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn names(s: &Session) -> Vec { + s.documents().iter().map(|d| d.doc.name.clone()).collect() +} + +/// The one layer of the active document, which must be an embedded smart object. +fn embedded(s: &Session) -> (String, String, Arc>) { + let d = active(s); + assert_eq!(d.layers.len(), 1, "one layer, not a Background plus a layer"); + let layer = &d.layers[0]; + match &layer.content { + LayerContent::Smart(so) => match &so.source { + SmartSource::Embedded { file_name, bytes } => (layer.name.clone(), file_name.clone(), Arc::clone(bytes)), + other => panic!("expected an embedded smart object, got {other:?}"), + }, + _ => panic!("the layer is not a smart object"), + } +} + +#[test] +fn a_png_opens_as_an_untitled_document_with_one_embedded_smart_object() { + let dir = tmp("png"); + let path = write_png(&dir, "a_test.png", 64, 48); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["name"], "a_test-1", "{r}"); + assert_eq!(s.documents().len(), 1); + let st = s.active().unwrap(); + assert_eq!(st.doc.name, "a_test-1"); + assert_eq!(st.path, None, "untitled: Save asks where to save"); + + let file = imported(&path); + let d = &st.doc; + assert_eq!((d.size.width, d.size.height), (64, 48)); + assert_eq!(d.resolution_dpi, file.resolution_dpi); + assert_eq!((d.mode, d.depth), (file.mode, file.depth)); + assert_eq!(d.icc_profile, file.icc_profile); + + let (layer, original, bytes) = embedded(&s); + assert_eq!(layer, "a_test", "named after the file without its extension"); + assert_eq!(original, "a_test.png", "Edit Contents opens the original file name"); + assert_eq!(*bytes, std::fs::read(&path).unwrap(), "the file itself is embedded"); +} + +#[test] +fn the_smart_object_shows_the_file() { + let dir = tmp("pixels"); + let path = write_png(&dir, "grad.png", 32, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let want = photocraft_compose::render(&imported(&path), Rect::new(0, 0, 32, 16)).px.clone(); + let got = photocraft_compose::render(active(&s), Rect::new(0, 0, 32, 16)).px.clone(); + assert_eq!(got, want); +} + +#[test] +fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() { + let dir = tmp("psd"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let file = imported(&path); + assert_eq!(file.resolution_dpi, 300.0); + assert!(file.icc_profile.is_some()); + let d = active(&s); + assert_eq!(d.name, "c.layered-1", "only the last extension is dropped"); + assert_eq!((d.size.width, d.size.height), (40, 30)); + assert_eq!(d.resolution_dpi, 300.0); + assert_eq!((d.mode, d.depth), (file.mode, file.depth)); + assert_eq!(d.depth, photocraft_doc::SampleType::U16); + assert_eq!(d.icc_profile, file.icc_profile); + let (layer, original, _) = embedded(&s); + assert_eq!((layer.as_str(), original.as_str()), ("c.layered", "c.layered.psd")); +} + +#[test] +fn history_is_the_opening_snapshot_then_open_as_smart_object() { + let dir = tmp("history"); + let path = write_png(&dir, "a_test.png", 16, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.history.entries().last().map(String::as_str), Some("Open As Smart Object")); + assert_eq!(st.history.entries().len(), 2, "the opening state, then one step"); + assert_eq!(st.snapshots.first().map(|snap| snap.name.as_str()), Some("a_test"), "the opening snapshot is named after the layer"); +} + +#[test] +fn the_number_is_the_next_free_one_and_starts_again_after_they_close() { + let dir = tmp("numbers"); + let path = write_png(&dir, "a_test.png", 8, 8); + let mut s = Session::new(); + for _ in 0..3 { + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + } + assert_eq!(names(&s), ["a_test-1", "a_test-2", "a_test-3"]); + while !s.documents().is_empty() { + s.close(0); + } + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(names(&s), ["a_test-1"]); +} + +#[test] +fn a_plain_open_of_the_same_file_is_unchanged() { + let dir = tmp("plain"); + let path = write_png(&dir, "a_test.png", 8, 8); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + open_bytes_as(&mut s, &path, &std::fs::read(&path).unwrap(), None, Some(path.clone())).unwrap(); + assert_eq!(names(&s), ["a_test-1", "a_test.png"]); + assert!(matches!(active(&s).layers[0].content, LayerContent::Raster(_))); +} + +#[test] +fn several_files_open_one_document_each() { + let dir = tmp("several"); + let a = write_png(&dir, "a_test.png", 8, 8); + let b = write_psd16(&dir, "d_other.psd", 12, 10); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"paths": [a, b]})).unwrap(); + assert_eq!(r["documents"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!(names(&s), ["a_test-1", "d_other-1"]); + for st in s.documents() { + assert_eq!(st.doc.layers.len(), 1); + assert!(matches!(st.doc.layers[0].content, LayerContent::Smart(_))); + } +} + +#[test] +fn a_missing_or_unreadable_file_is_an_error_not_a_crash() { + let dir = tmp("bad"); + let junk = join(&dir, "notes.png"); + std::fs::write(&junk, b"this is not an image").unwrap(); + let text = join(&dir, "readme.txt"); + std::fs::write(&text, b"hello").unwrap(); + let mut s = Session::new(); + for path in [join(&dir, "missing.png"), junk, text] { + let r = s.execute("file.openAsSmartObject", json!({"path": path})); + assert!(r.is_err(), "{path}: {r:?}"); + } + assert!(s.documents().is_empty(), "a failed open leaves no document behind"); +} + +#[test] +fn bad_params_are_errors() { + let mut s = Session::new(); + for params in [json!({}), json!({"path": 7}), json!({"path": null}), json!({"paths": "a.png"}), json!({"paths": []}), json!({"paths": [1]})] { + let r = s.execute("file.openAsSmartObject", params.clone()); + assert!(r.is_err(), "{params}: {r:?}"); + } + assert!(s.documents().is_empty()); +} From 5e583a3f9fb2aa164f3a76f68be3b573ad7bd598 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 17:04:06 -0500 Subject: [PATCH 26/77] File: Open as Smart Object... opens a file as a new document holding one embedded smart object (#11678) New file.openAsSmartObject command (path or paths) and a File menu item after Open As. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/file_cmds.rs | 11 +++ .../src/cmd/file_cmds/open_as_smart_object.rs | 88 +++++++++++++++++++ crates/ui-egui/src/menu_catalog.rs | 1 + 3 files changed, 100 insertions(+) create mode 100644 crates/engine/src/cmd/file_cmds/open_as_smart_object.rs diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index cce669ec45..00efb839e1 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -24,6 +24,7 @@ mod batch_run; mod export; mod info; mod open; +mod open_as_smart_object; mod paths; mod place_steps; mod processing; @@ -50,6 +51,7 @@ pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_ pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; +use open_as_smart_object::open_as_smart_object; use paths::is_file; #[cfg(test)] use paths::openable_name; @@ -285,6 +287,15 @@ pub fn specs() -> Vec { native, open_as ), + spec!( + "file.openAsSmartObject", + "Open as Smart Object…", + &["File"], + None, + r##"{"path":str (or "paths":[str], one document each)} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, name, layer} ({documents:[…]} for paths)"##, + native, + open_as_smart_object + ), // One-shot permission for one file, not something to replay later: never journaled or // recorded in an action, since replaying it in another session/on another file would // grant memory use nobody approved (#10897). It also releases the queued source on both diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs new file mode 100644 index 0000000000..0d162b5238 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -0,0 +1,88 @@ +//! File › Open as Smart Object… (#11678): a file opens as a new untitled document with the file's +//! size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer. +//! +//! The layer is built as File › Place Embedded builds one (the same [`Prepared`] layer, inserted +//! by [`apply_place`]), at the file's own size and position `0, 0`. + +use super::open::{Prepared, apply_place, import_opened}; +use super::paths::{file_name, read_file, stem, untitled_name}; +use super::{Affine, Arc, Document, EngineError, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; + +const CMD: &str = "file.openAsSmartObject"; + +/// The history step's name. +const LABEL: &str = "Open As Smart Object"; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +/// The files to open: `paths` (several selected files) or `path` (one); whether it was one. +fn paths_param(p: &Value) -> Result<(Vec, bool)> { + match p.get("paths") { + None | Some(Value::Null) => Ok((vec![str_param(p, "path", CMD)?.to_string()], true)), + Some(Value::Array(list)) if p.get("path").is_none_or(Value::is_null) => { + let paths: Option> = list.iter().map(|v| v.as_str().filter(|v| !v.is_empty()).map(str::to_string)).collect(); + match paths { + Some(paths) if !paths.is_empty() => Ok((paths, false)), + _ => Err(bad("\"paths\" must be a non-empty list of file paths")), + } + } + Some(Value::Array(_)) => Err(bad("give \"path\" or \"paths\", not both")), + Some(_) => Err(bad("\"paths\" must be a list of file paths")), + } +} + +/// Opens each file as its own document, in order. A file that cannot be read stops the command +/// with an error; the documents opened before it stay open. +pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result { + let (paths, single) = paths_param(p)?; + let mut opened = Vec::new(); + for path in &paths { + opened.push(open_one(s, path)?); + } + Ok(if single { opened.swap_remove(0) } else { json!({"documents": opened}) }) +} + +fn open_one(s: &mut Session, path: &str) -> Result { + let bytes = read_file(path)?; + let src = import_opened(s, path, &bytes)?; + // The file as one image: the smart object's rendered contents. + let pixels = flattened_checked(&src, src.pixel_format(), CMD)?; + let (w, h) = (f64::from(src.size.width), f64::from(src.size.height)); + let layer_name = stem(path); + // "-1", the next free number while a document with that name is open. + let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); + + let mut doc = Document::new(name.clone(), src.size, src.mode, src.depth); + doc.resolution_dpi = src.resolution_dpi; + doc.icc_profile = src.icc_profile.clone(); + doc.color_table = src.color_table.clone(); + doc.duotone = src.duotone.clone(); + + // Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it. + let source = SmartSource::Embedded { file_name: file_name(path), bytes: Arc::new(bytes) }; + let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels)); + let prepared = Prepared { + content: LayerContent::Smart(so), + layer_name: layer_name.clone(), + label: LABEL, + scale: 1.0, + bounds: [0.0, 0.0, w, h], + smart: true, + frame: None, + }; + // Untitled: no path, so Save asks where to save. + let index = s.add_document(doc, None); + // The opening snapshot is named after the layer, as the reference names it. + if let Some(first) = s.active_mut().and_then(|st| st.snapshots.iter_mut().find(|snap| snap.id == 0)) { + first.name = layer_name; + } + match apply_place(s, prepared) { + Ok(placed) => Ok(json!({"document": index, "name": name, "layer": placed["layer"]})), + Err(e) => { + s.close(index); + Err(e) + } + } +} diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 3838a70bcf..3bba8bca22 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -7,6 +7,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["File"], "New…", Some("Cmd+N"), "file.new"), (&["File"], "Open…", Some("Cmd+O"), "file.open"), (&["File"], "Open As…", Some("Cmd+Alt+Shift+O"), "file.openAs"), + (&["File"], "Open as Smart Object…", None, "file.openAsSmartObject"), (&["File"], "Open EXR Parts…", None, "file.openExrParts"), (&["File"], "---", None, "---"), (&["File"], "Close", Some("Cmd+W"), "file.close"), From e2471998de0d283f7bfb8d905e254d60a63da7ec Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 17:08:24 -0500 Subject: [PATCH 27/77] File: Open as Smart Object... opens the same file dialog as Open and takes several files (#11678) The menu item with no path asks with File > Open's dialog (multi-select) and opens each chosen file as its own document; failures are reported as File > Open reports them. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/file_dialog.rs | 18 ++++++++++++++++++ crates/ui-egui/src/file_open/tests.rs | 18 ++++++++++++++++++ crates/ui-egui/src/menus/view_cmds/dialogs.rs | 1 + crates/ui-egui/src/menus/view_cmds/dispatch.rs | 1 + 4 files changed, 38 insertions(+) diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index bddc6796d3..a8edcdcf9f 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -257,6 +257,24 @@ impl PhotocraftApp { }) } + /// File › Open as Smart Object…: the File › Open dialog (same filters, several files); each + /// chosen file opens as its own document holding one embedded smart object, in name order, + /// reporting each failure as File › Open does. + pub fn open_dialog_as_smart_object(&mut self) -> Result { + let initial_dir = last_used_dir(&self.ui.recent_files); + self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, |app, answer| { + let FileDialogAnswer::Paths(mut paths) = answer else { return Err(UNEXPECTED.into()) }; + paths.sort_by(|a, b| crate::file_open::natural_cmp(a, b)); + for path in &paths { + if let Err(e) = crate::jobs_ui::run(app, "file.openAsSmartObject", json!({"path": path})) { + app.open_failed(&crate::file_open::display_name(path), &e); + } + } + app.sync_views(); + Ok(Value::Null) + }) + } + /// Opens each picked file's contents as its own document, in name order (like /// [`Self::open_paths`]), reporting each failure. fn open_contents(&mut self, mut files: Vec<(String, Vec)>) { diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index a617332bd1..3fdf448099 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -1694,3 +1694,21 @@ fn queued_drop_waits_for_an_external_document_job() { assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); assert!(app.drop_places.is_empty()); } + +/// File › Open as Smart Object… (#11678) asks with File › Open's dialog and opens each chosen +/// file as its own untitled document holding one embedded smart object. +#[test] +fn open_as_smart_object_dialog_opens_one_document_per_selected_file() { + let (dir, paths) = files("open-as-so", &["one.png", "two.png", "bad.png"], &png_bytes(8, 6)); + std::fs::write(&paths[2], b"not an image").unwrap(); + let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]); + + menus::invoke(&mut app, &egui::Context::default(), "file.openAsSmartObject", json!({})).unwrap(); + assert!(app.file_dialog_open(), "the menu item asks for files first"); + answer(&mut app); + + assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); + assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); + assert!(app.ui.status_error, "the unreadable file is reported"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 16cefbd87b..b2b7d56158 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -345,6 +345,7 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let dialog = |app: &mut PhotocraftApp, fields: Value, choices: Value| Some(Ok(json!({"dialog": form(app, id, label, fields, choices)}))); match id { "file.openAs" => Some(app.open_dialog_file()), + "file.openAsSmartObject" => Some(app.open_dialog_as_smart_object()), "file.openExrParts" => open_exr_parts_dialog(app), "file.saveACopy" => Some(save_a_copy(app)), "file.placeEmbedded" | "file.placeLinked" => { diff --git a/crates/ui-egui/src/menus/view_cmds/dispatch.rs b/crates/ui-egui/src/menus/view_cmds/dispatch.rs index f1e9b2770f..aef68d2dd6 100644 --- a/crates/ui-egui/src/menus/view_cmds/dispatch.rs +++ b/crates/ui-egui/src/menus/view_cmds/dispatch.rs @@ -223,6 +223,7 @@ fn wraps(id: &str) -> bool { matches!( id, "file.openAs" + | "file.openAsSmartObject" | "file.openExrParts" | "file.saveACopy" | "file.placeEmbedded" From 321e306a8f00be0dd4e6ed7989d23302bb0e1027 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 16:14:42 -0600 Subject: [PATCH 28/77] Perspective Crop: the options bar's Cancel and Commit show only during a session (#11978) --- .../perspective_crop_bar_buttons_tests.rs | 72 +++++++++++++++++++ .../ui-egui/src/tools/perspective_crop_ui.rs | 8 +++ 2 files changed, 80 insertions(+) create mode 100644 crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs diff --git a/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs b/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs new file mode 100644 index 0000000000..01d4043b5e --- /dev/null +++ b/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs @@ -0,0 +1,72 @@ +//! #11978: the Perspective Crop options bar shows its Cancel and Commit buttons only during a +//! session (a quad, or corners being clicked), and they still commit and cancel. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; + +const COMMIT: &str = "Commit current crop operation"; +const CANCEL: &str = "Cancel current crop operation"; + +/// A 200 × 150 document with the Perspective Crop tool chosen and no session. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.ui.tool = Tool::PerspectiveCrop; + app +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 80.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn shows(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.query_by_label_contains(label).is_some() +} + +#[test] +fn without_a_session_neither_button_shows() { + let h = harness(app()); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL)); + assert!(h.query_by_label("Show Grid").is_some(), "the bar still ends with Show Grid"); +} + +#[test] +fn a_pending_quad_shows_both_and_commit_commits() { + let mut app = app(); + app.ui.perspective_crop = Some([[20.0, 20.0], [180.0, 20.0], [180.0, 130.0], [20.0, 130.0]]); + let mut h = harness(app); + assert!(shows(&h, COMMIT) && shows(&h, CANCEL)); + h.get_by_label_contains(COMMIT).click(); + h.run_steps(3); + let size = h.state().session.active().unwrap().doc.size; + assert_eq!((size.width, size.height), (160, 110), "Commit cropped to the quad"); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after commit"); +} + +#[test] +fn clicked_corners_show_both_and_cancel_cancels() { + let mut app = app(); + app.pcrop.clicks = vec![[20.0, 20.0], [180.0, 25.0]]; + let mut h = harness(app); + assert!(shows(&h, COMMIT) && shows(&h, CANCEL), "with clicked corners"); + h.get_by_label_contains(CANCEL).click(); + h.run_steps(3); + assert!(h.state().pcrop.clicks.is_empty(), "Cancel dropped the corners"); + assert_eq!(h.state().session.active().unwrap().doc.size.width, 200, "nothing cropped"); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after cancel"); +} diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index ed60c90d41..9875916d72 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -305,6 +305,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut app.ui.tool_options.perspective_crop_grid, tl!("Show Grid")); + // Commit and Cancel show only during a session: a quad, clicked corners, or a drag (#11978). + if !pending(app) { + return; + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let commit_tip = crate::i18n::fmt(tl!("Commit current crop operation ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]); if crate::icons::button(ui, "check", 26.0, false, &commit_tip).clicked() { @@ -316,6 +320,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } +#[cfg(test)] +#[path = "perspective_crop_bar_buttons_tests.rs"] +mod perspective_crop_bar_buttons_tests; + #[cfg(test)] mod tests { use super::*; From 324489a66fccb8e20dd09727856854dc4bae962f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 16:06:44 -0600 Subject: [PATCH 29/77] Notes: default colour 255,255,131, popup at icon + (8,10) 240x140, local-time date; the popup keeps its size under transforms (#11795) --- crates/engine/src/canvas_geom.rs | 7 +- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/notes_cmds.rs | 42 ++++++--- .../src/cmd/notes_stored_values_tests.rs | 87 +++++++++++++++++++ crates/ui-egui/src/tools/analysis_ui.rs | 2 +- 5 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/notes_stored_values_tests.rs diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 93bc4654b9..38cddc6ef5 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -242,9 +242,14 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if let Some(wp) = &mut doc.work_path { *wp = wp.transform(a); } + // A note's icon point maps with the pixels; its popup moves with it and keeps its size and its + // offset from the icon, as Photoshop's does under Image Size, Canvas Size and turns (#11795). for n in &mut doc.notes { + let [px, py] = n.position; + let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]); n.position = map_pt(a, n.position); - n.popup = map_box(a, n.popup); + let [x, y] = n.position; + n.popup = [x + dx, y + dy, x + dx + w, y + dy + h]; } for g in &mut doc.measurement.count_groups { for p in &mut g.points { diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c4addf4c22..6a741c521c 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -114,6 +114,8 @@ pub(super) mod migrate_cmds; pub mod mode_cmds; pub mod multichannel_cmds; pub mod notes_cmds; +#[cfg(test)] +mod notes_stored_values_tests; mod ops; pub mod paint_cmds; mod params; diff --git a/crates/engine/src/cmd/notes_cmds.rs b/crates/engine/src/cmd/notes_cmds.rs index 2f589f3390..133417649d 100644 --- a/crates/engine/src/cmd/notes_cmds.rs +++ b/crates/engine/src/cmd/notes_cmds.rs @@ -22,14 +22,33 @@ fn has_notes(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.doc.notes.is_empty()) { Ok(()) } else { Err("the document has no notes".into()) } } -/// PDF-style date (`D:YYYYMMDDhhmmssZ`) as Photoshop stores in notes; empty without a clock. +/// Photoshop's default note colour (#11795): RGB 255, 255, 131 (stored as 16-bit 0x8383 blue). +pub const DEFAULT_NOTE_COLOR: [f32; 3] = [1.0, 1.0, 131.0 / 255.0]; + +/// The PDF date (`D:YYYYMMDDHHMMSS+HH'mm'`) Photoshop stores in a note: local time with its +/// offset from UTC (#11795), through chrono. Empty without a clock (the web build). fn pdf_date() -> String { - let iso = crate::analysis_cmds::now_iso(); - if iso.len() < 19 { - return String::new(); + #[cfg(not(target_arch = "wasm32"))] + { + let now = chrono::Local::now(); + pdf_date_at(&now.with_timezone(now.offset())) } - let digits: String = iso[..19].chars().filter(char::is_ascii_digit).collect(); - format!("D:{digits}Z") + #[cfg(target_arch = "wasm32")] + { + String::new() + } +} + +/// [`pdf_date`] for the moment `at`: its own wall-clock time and UTC offset. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn pdf_date_at(at: &chrono::DateTime) -> String +where + Tz::Offset: std::fmt::Display, +{ + use chrono::Offset; + let off = at.offset().fix().local_minus_utc(); + let (sign, off) = if off < 0 { ('-', -off) } else { ('+', off) }; + format!("D:{}{sign}{:02}'{:02}'", at.format("%Y%m%d%H%M%S"), off / 3600, off % 3600 / 60) } pub fn note_json(i: usize, n: &Note) -> Value { @@ -44,7 +63,8 @@ fn color_of(cmd: &str, p: &Value) -> Result> { if p.get("color").is_none() { return Ok(None); } - let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?; + let [r, g, b] = DEFAULT_NOTE_COLOR; + let c = crate::commands::color_param(cmd, p, "color", [r, g, b, 1.0])?; Ok(Some(Color::rgb(c[0], c[1], c[2]))) } @@ -65,10 +85,11 @@ fn add(s: &mut Session, p: &Value) -> Result { author: p.get("author").and_then(Value::as_str).unwrap_or("").to_string(), text: p.get("text").and_then(Value::as_str).unwrap_or("").to_string(), position: [x, y], - popup: [x + 20.0, y + 20.0, x + 260.0, y + 160.0], + // Photoshop's popup: 240 × 140, at the icon's top-left + (8, 10) (#11795). + popup: [x + 8.0, y + 10.0, x + 248.0, y + 150.0], + color: Color::rgb(DEFAULT_NOTE_COLOR[0], DEFAULT_NOTE_COLOR[1], DEFAULT_NOTE_COLOR[2]), open: p.get("open").and_then(Value::as_bool).unwrap_or(true), modified: pdf_date(), - ..Default::default() }; if let Some(c) = color_of(CMD, p)? { n.color = c; @@ -270,7 +291,8 @@ mod tests { let d = s.active().unwrap().doc.clone(); assert_eq!(d.notes[0].text, "fixed"); assert_eq!(d.notes[0].position, [20.0, 12.0]); - assert_eq!(d.notes[0].popup[0], 40.0); + // The popup travels with its icon: 20 + 8 (#11795). + assert_eq!(d.notes[0].popup[0], 28.0); // Unchanged edits don't add history. let before = s.active().unwrap().history.past_len(); assert_eq!(s.execute("notes.set", json!({"index": 0, "text": "fixed"})).unwrap()["changed"], false); diff --git a/crates/engine/src/cmd/notes_stored_values_tests.rs b/crates/engine/src/cmd/notes_stored_values_tests.rs new file mode 100644 index 0000000000..b0920139d6 --- /dev/null +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -0,0 +1,87 @@ +//! #11795: what a new note stores, as in Photoshop: colour 255, 255, 131, a 240 × 140 popup at the +//! icon's top-left + (8, 10), and a local-time PDF date with its offset. Document operations move +//! the popup with its icon and keep its size. + +use chrono::{FixedOffset, TimeZone}; +use serde_json::json; + +use super::notes_cmds::{DEFAULT_NOTE_COLOR, pdf_date_at}; +use crate::Session; + +fn session(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap(); + s +} + +fn note(s: &Session, i: usize) -> photocraft_doc::Note { + s.active().unwrap().doc.notes[i].clone() +} + +#[test] +fn a_new_note_has_the_default_colour_and_popup() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + let n = note(&s, 0); + let rgb = n.color.to_rgb(); + assert_eq!(rgb.map(|v| (v * 255.0).round() as i32), [255, 255, 131], "{rgb:?}"); + assert_eq!(DEFAULT_NOTE_COLOR.map(|v| (v * 65535.0).round() as u32), [0xffff, 0xffff, 0x8383], "the 16-bit components Photoshop saves"); + assert_eq!(n.popup, [108.0, 110.0, 348.0, 250.0], "icon + (8, 10), 240 × 140"); + // A note on the pasteboard keeps its popup there too. + s.execute("notes.add", json!({"x": -178, "y": 300})).unwrap(); + assert_eq!(note(&s, 1).popup, [-170.0, 310.0, 70.0, 450.0]); + // An explicit colour still wins. + s.execute("notes.add", json!({"x": 10, "y": 10, "color": [0.25, 0.5, 1.0]})).unwrap(); + assert_eq!(note(&s, 2).color.to_rgb(), [0.25, 0.5, 1.0]); +} + +#[test] +fn the_date_is_local_time_with_its_offset() { + let at = |offset: i32| FixedOffset::east_opt(offset).unwrap().with_ymd_and_hms(2026, 11, 6, 20, 14, 55).unwrap(); + assert_eq!(pdf_date_at(&at(3600)), "D:20261106201455+01'00'"); + assert_eq!(pdf_date_at(&at(0)), "D:20261106201455+00'00'"); + assert_eq!(pdf_date_at(&at(-(5 * 3600 + 30 * 60))), "D:20261106201455-05'30'"); + // A new note's own date has that shape. + let mut s = session(100, 100); + s.execute("notes.add", json!({"x": 10, "y": 10})).unwrap(); + let d = note(&s, 0).modified; + let b = d.as_bytes(); + assert!(d.len() == 23 && d.starts_with("D:") && matches!(b[16], b'+' | b'-') && b[19] == b'\'' && b[22] == b'\'', "{d}"); + assert!(d[2..16].chars().all(|c| c.is_ascii_digit()), "{d}"); +} + +#[test] +fn image_size_moves_the_icon_and_keeps_the_popup_size() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 99})).unwrap(); + let before = note(&s, 0); + s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [50.0, 49.5], "the icon point scales as today"); + let size = |p: [f64; 4]| [p[2] - p[0], p[3] - p[1]]; + assert_eq!(size(n.popup), size(before.popup), "240 × 140 still"); + assert_eq!([n.popup[0] - n.position[0], n.popup[1] - n.position[1]], [8.0, 10.0], "still at icon + (8, 10)"); +} + +#[test] +fn canvas_size_and_a_quarter_turn_keep_the_popup_size() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + s.execute("image.canvasSize", json!({"width": 400, "height": 300, "anchor": "center"})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [0.0, 50.0], "moved by the canvas offset"); + assert_eq!(n.popup, [8.0, 60.0, 248.0, 200.0]); + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let r = note(&s, 0); + assert_eq!([r.popup[2] - r.popup[0], r.popup[3] - r.popup[1]], [240.0, 140.0], "a turn doesn't swap the popup's sides"); + assert_eq!([r.popup[0] - r.position[0], r.popup[1] - r.position[1]], [8.0, 10.0], "the popup moves with its icon"); +} + +#[test] +fn a_mode_change_leaves_notes_alone() { + let mut s = session(100, 100); + s.execute("notes.add", json!({"x": 10, "y": 20})).unwrap(); + let before = note(&s, 0); + s.execute("image.mode.cmyk", json!({})).unwrap(); + assert_eq!(note(&s, 0), before); +} diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index ed113909a6..a0c1feecde 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -64,7 +64,7 @@ impl Default for AnalysisUi { log_selected: Vec::new(), note_selected: None, note_author: String::new(), - note_color: [1.0, 1.0, 0.51], + note_color: photocraft_engine::notes_cmds::DEFAULT_NOTE_COLOR, use_measurement_scale: false, dialog: None, drag: None, From 8f527110be6456fc5df44cea07ffc2a875936137 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:20:55 +0100 Subject: [PATCH 30/77] #9396: the continue-path press no longer closes a short path, and hover stops cloning it CodeRabbit on #9396: with both ends of a short open path in reach (as when zoomed out), the canvas showed the continue-path pointer, but the press resumed the path and then hit its first anchor, so the release closed it. The press that resumes a path now never closes it, and the nearer end is the one carried on. The path a Pen path carries on is now borrowed instead of cloned, so the hover check no longer copies the whole path each frame. Co-Authored-By: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 33 ++++++++++++++++++++ crates/ui-egui/src/tools/vector_ui/pen.rs | 38 ++++++++++++++--------- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 78143ffaff..71c272f793 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -556,3 +556,36 @@ fn a_targeted_vector_mask_carries_on_its_open_path() { crate::vector_ui::pen_commit(&mut app, false); assert_eq!(mask(&app), [3], "the same subpath, extended"); } + +/// #9396 review: on a short open path whose two ends are both within reach of the pointer (as +/// when zoomed out), the press under the continue-path pointer carries the nearer end on and never +/// closes the path, which it did when the other end was its first anchor. +#[test] +fn a_press_in_reach_of_both_ends_carries_the_nearer_one_on_and_does_not_close() { + let mut app = app(Tool::Pen); + assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels"); + // The ends sit beside the first segment, which Auto Add/Delete would put an anchor on. + app.ui.tool_options.pen_auto_add_delete = false; + for (x, y) in [(20.0, 20.0), (60.0, 20.0), (60.0, 40.0), (26.0, 22.0)] { + click(&mut app, x, y); + } + crate::vector_ui::pen_commit(&mut app, false); + let ends = |app: &PhotocraftApp| { + let pen = app.ui.pen.as_ref().expect("a resumed draft"); + assert!(pen.resumes.is_some(), "{pen:?}"); + pen.knots.last().map(|k| k[0]) + }; + // (24, 22): 2 px from the last anchor, 4.5 px from the first. + assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 24.0, 22.0)); + click(&mut app, 24.0, 22.0); + assert!(!work_path(&app).subpaths[0].closed, "the press carried the path on, it did not close it"); + assert_eq!(ends(&app), Some([26.0, 22.0]), "from the nearer, last anchor"); + click(&mut app, 100.0, 100.0); + crate::vector_ui::pen_commit(&mut app, false); + let sub = &work_path(&app).subpaths[0]; + assert!(!sub.closed && sub.knots.len() == 5 && sub.knots[4].anchor.x == 100.0, "{sub:?}"); + // (21, 20): 1 px from the first anchor, 5.4 px from the last: carried on from the first. + click(&mut app, 21.0, 20.0); + assert!(!work_path(&app).subpaths[0].closed); + assert_eq!(ends(&app), Some([20.0, 20.0]), "from the nearer, first anchor"); +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index bc534f3cf4..83e99f983f 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -1,6 +1,6 @@ use super::curvature::{curvature_hit, curvature_refresh}; use super::options::selected_shape; -use super::path_ops::{active_shape_path, targeted_vector_mask, work_path_shown}; +use super::path_ops::{targeted_vector_mask, targeted_vector_mask_id, work_path_shown}; use super::shapes::{fill_param, stroke_param}; use super::{Document, LayerContent, Path, PenPath, PhotocraftApp, Resumed, Tokens, Tool, ToolOptions, Value, json, vec2}; @@ -53,10 +53,12 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { } // A press on an endpoint of an open path carries on drawing it, as in Photoshop (#6593): the // press then acts on its last anchor, so a drag pulls out the next segment's handle. + let mut resuming = false; if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) && let Some(resumed) = resume_at(app, x, y, tol) { app.ui.pen = Some(resumed); + resuming = true; } let Some(owner) = capture_pen_owner(app) else { return }; let pen = app.ui.pen.get_or_insert_with(PenPath::default); @@ -67,7 +69,10 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { pen.adjusting_in = false; pen.last_pointer = Some([x, y]); pen.reposition = false; + // The press that carries a path on never closes it, even when its other end is in reach too: + // the canvas showed the continue-path pointer there (`continues_path_at`). if let Some(first) = pen.knots.first().map(|k| k[0]) + && !resuming && on_closing_anchor(pen, x, y, tol) { // Pressing the first anchor closes the path on release. Until then a stand-in for it @@ -518,21 +523,24 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { /// The path a Pen path carries on (#6593): a targeted vector mask, else in Shape mode the /// selected shape layer's, else the path a new Pen path would join (the one selected in the Paths /// panel, else the work path). Its Paths-panel name ("layer" for a layer's), layer and path. -fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, Path)> { +fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, &Path)> { let st = app.session.active()?; - if let Some((id, p)) = targeted_vector_mask(app) { - return Some(("layer".to_string(), Some(id), p)); + // Borrowed, not cloned: the canvas asks on every hover (`continues_path_at`). + if let Some(id) = targeted_vector_mask_id(app) { + let mask = st.doc.layer(photocraft_doc::LayerId(id))?.vector_mask.as_ref()?; + return Some(("layer".to_string(), Some(id), &mask.path)); } if app.ui.tool_options.vector_mode == "shape" { - let (id, p) = active_shape_path(app)?; - return Some(("layer".to_string(), Some(id), p)); + let l = st.active_layer.and_then(|id| st.doc.layer(id))?; + let LayerContent::Shape(sh) = &l.content else { return None }; + return Some(("layer".to_string(), Some(l.id.0), &sh.path)); } let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). if name == "work" && !work_path_shown(app) { return None; } - let p = doc_path(&st.doc, &name)?.clone(); + let p = doc_path(&st.doc, &name)?; Some((name, None, p)) } @@ -556,14 +564,16 @@ fn resumed(doc: u64, name: String, layer: Option, index: usize, sub: &photo pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { let doc = app.session.active()?.doc.id.0; let (name, layer, path) = resume_target(app)?; - let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; + let dist = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y); path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { - let reversed = if s.knots.last().is_some_and(near) { - false - } else if s.knots.first().is_some_and(near) { - true - } else { - return None; + let last = s.knots.last().map(dist).filter(|d| *d < tol); + let first = s.knots.first().map(dist).filter(|d| *d < tol); + // With both ends in reach (a short path, zoomed out) the nearer one is carried on. + let reversed = match (last, first) { + (Some(l), Some(f)) => f < l, + (Some(_), None) => false, + (None, Some(_)) => true, + (None, None) => return None, }; Some(resumed(doc, name.clone(), layer, i, s, reversed)) }) From b7e71b9ed525a1d2eee68a0a5407ee8744d4f275 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 18:23:00 -0600 Subject: [PATCH 31/77] Notes: an unset (all-zero) popup stays unset under Image Size, Canvas Size and turns (CodeRabbit review on crates/engine/src/canvas_geom.rs:252) PSD export writes its default popup for an all-zero one; moving it with the icon turned it into a 0 x 0 rectangle elsewhere, which export then wrote as is. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/canvas_geom.rs | 5 +++++ crates/engine/src/cmd/notes_stored_values_tests.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 38cddc6ef5..43fecf84de 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -245,6 +245,11 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { // A note's icon point maps with the pixels; its popup moves with it and keeps its size and its // offset from the icon, as Photoshop's does under Image Size, Canvas Size and turns (#11795). for n in &mut doc.notes { + // An all-zero popup means none is stored (PSD export writes its default one): keep it. + if n.popup == [0.0; 4] { + n.position = map_pt(a, n.position); + continue; + } let [px, py] = n.position; let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]); n.position = map_pt(a, n.position); diff --git a/crates/engine/src/cmd/notes_stored_values_tests.rs b/crates/engine/src/cmd/notes_stored_values_tests.rs index b0920139d6..cb1897f121 100644 --- a/crates/engine/src/cmd/notes_stored_values_tests.rs +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -85,3 +85,16 @@ fn a_mode_change_leaves_notes_alone() { s.execute("image.mode.cmyk", json!({})).unwrap(); assert_eq!(note(&s, 0), before); } + +#[test] +fn an_unset_popup_stays_unset_under_image_size() { + // An all-zero popup means "none stored": PSD export writes its default popup for it, so Image + // Size must not turn it into a 0 × 0 rectangle somewhere else. + let mut s = session(600, 400); + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + doc.notes.push(photocraft_doc::Note { position: [100.0, 100.0], ..Default::default() }); + s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [50.0, 50.0]); + assert_eq!(n.popup, [0.0; 4]); +} From 4a8e26a11a79843c31cb4cf31b5931e4e5d75cb4 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 18:25:41 -0600 Subject: [PATCH 32/77] Notes: after a turn or flip the popup keeps today's origin, with its size kept (CodeRabbit review on crates/engine/src/canvas_geom.rs:252) #11795 didn't measure where the popup sits after a rotation, so that case keeps today's placement (the top-left of the mapped rectangle) and only the size rule applies. Image Size and Canvas Size still keep the popup's offset from the icon. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/canvas_geom.rs | 16 ++++++++++++---- .../src/cmd/notes_stored_values_tests.rs | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 43fecf84de..ff40ea8a8b 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -242,8 +242,10 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if let Some(wp) = &mut doc.work_path { *wp = wp.transform(a); } - // A note's icon point maps with the pixels; its popup moves with it and keeps its size and its - // offset from the icon, as Photoshop's does under Image Size, Canvas Size and turns (#11795). + // A note's icon point maps with the pixels; its popup keeps its size (#11795). Under Image Size + // and Canvas Size it also keeps its offset from the icon, as Photoshop's does. Where it sits + // after a turn or flip wasn't measured, so it keeps today's origin there: the top-left of the + // mapped rectangle. for n in &mut doc.notes { // An all-zero popup means none is stored (PSD export writes its default one): keep it. if n.popup == [0.0; 4] { @@ -253,8 +255,14 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { let [px, py] = n.position; let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]); n.position = map_pt(a, n.position); - let [x, y] = n.position; - n.popup = [x + dx, y + dy, x + dx + w, y + dy + h]; + let [m0, m1, m2, m3, ..] = a.m; + let [x, y] = if m1.abs() < EPS && m2.abs() < EPS && m0 > 0.0 && m3 > 0.0 { + [n.position[0] + dx, n.position[1] + dy] + } else { + let [l, t, ..] = map_box(a, n.popup); + [l, t] + }; + n.popup = [x, y, x + w, y + h]; } for g in &mut doc.measurement.count_groups { for p in &mut g.points { diff --git a/crates/engine/src/cmd/notes_stored_values_tests.rs b/crates/engine/src/cmd/notes_stored_values_tests.rs index cb1897f121..1db8e5681b 100644 --- a/crates/engine/src/cmd/notes_stored_values_tests.rs +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -74,7 +74,6 @@ fn canvas_size_and_a_quarter_turn_keep_the_popup_size() { s.execute("image.imageRotation.90cw", json!({})).unwrap(); let r = note(&s, 0); assert_eq!([r.popup[2] - r.popup[0], r.popup[3] - r.popup[1]], [240.0, 140.0], "a turn doesn't swap the popup's sides"); - assert_eq!([r.popup[0] - r.position[0], r.popup[1] - r.position[1]], [8.0, 10.0], "the popup moves with its icon"); } #[test] @@ -86,6 +85,23 @@ fn a_mode_change_leaves_notes_alone() { assert_eq!(note(&s, 0), before); } +#[test] +fn a_turn_keeps_todays_popup_origin_and_the_popup_size() { + // Where the popup sits after a turn wasn't measured (#11795), so it keeps today's origin (the + // top-left of the turned rectangle) and only the size rule applies. + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + // The popup [108, 110, 348, 250] turned 90° clockwise on a 400 px tall canvas spans x 150..290, + // y 108..348; it starts there and stays 240 × 140. + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + assert_eq!(note(&s, 0).popup, [150.0, 108.0, 390.0, 248.0]); + // A flip keeps today's mirrored popup, which already has its size. + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap(); + assert_eq!(note(&s, 0).popup, [252.0, 110.0, 492.0, 250.0]); +} + #[test] fn an_unset_popup_stays_unset_under_image_size() { // An all-zero popup means "none stored": PSD export writes its default popup for it, so Image From 45edc58f8914fbbba43edacd380ce96a39df23d5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:36:26 +0100 Subject: [PATCH 33/77] PR #9991: give filmstrip raws their sidecar crop The filmstrip that main added while #9991 was open holds raws opened together. Only the shown raw read its sidecar crop, so a raw that was never shown, or was shown and left, opened uncropped. The crop is now read when the raw is queued. Co-Authored-By: ljkx <177671075+ljkx@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/dialogs/camera_raw_ui/open.rs | 3 ++- .../src/dialogs/camera_raw_ui/tests.rs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 1e543c359d..95da88ae1d 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -75,7 +75,8 @@ pub fn queue_open_dialog( bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, - crop: None, + // Read now: a filmstrip raw that is never shown still opens with its sidecar's crop. + crop: path.and_then(read_sidecar).and_then(|(_, report, _)| report.crop), }; let held = super::filmstrip::HeldRaw::new(app, raw); app.raw_shelf.push(held); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs index 87f7111d2d..76591f3df1 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -1030,6 +1030,33 @@ fn a_sidecar_crop_crops_the_opened_raw() { let _ = std::fs::remove_dir_all(&dir); } +/// #9985 with the filmstrip: raws opened together each open with their own sidecar's crop, also +/// the one that is never shown and the one that was shown and left. +#[test] +fn filmstrip_raws_open_with_their_sidecar_crops() { + let lightroom = r#""#; + let (mut app, dng, dir, raw_path, _) = raw_with_sidecar("filmcrop", Some(lightroom)); + let paths: Vec = (1..3).map(|i| dir.join(format!("IMG_010{i}.cr2")).to_string_lossy().into_owned()).collect(); + for p in &paths { + std::fs::write(p, &dng).unwrap(); + std::fs::write(sidecar_path(p), lightroom).unwrap(); + } + let ctx = egui::Context::default(); + app.open_file(&raw_path, &dng).unwrap(); + for p in &paths { + app.open_file(p, &dng).unwrap(); + } + assert_eq!(app.raw_shelf.len(), 3); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + filmstrip::show_raw(&mut app, &ctx, 1).unwrap(); + filmstrip::show_raw(&mut app, &ctx, 0).unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(3), "{r}"); + let sizes: Vec<_> = app.session.documents().iter().map(|st| (st.doc.size.width, st.doc.size.height)).collect(); + assert_eq!(sizes, vec![(24, 16); 3], "shown, shown and left, and never shown: all cropped"); + let _ = std::fs::remove_dir_all(&dir); +} + /// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing /// back: nothing changed. #[test] From 1b0a3e13367aa08e7b47e0efd79e49e5576662b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Nov 2026 01:40:36 +0100 Subject: [PATCH 34/77] fix(engine): refuse Straighten Layer for a target that is gone `Session::edit` keeps the selected layers valid after an edit but not the active id, so an edit that drops the active layer leaves one behind that the document does not hold. The new enabled check read that id and answered "live", and with an explicit `crop` the command then rotated and cropped the whole canvas for a layer that is not there - the one outcome of this state that changes pixels. Refusing it is what `select_layer` and `restore_layer_selection` already do with a gone id, and it costs the resolve check the target lookup was doing anyway. No command shipped today drops the active layer without re-targeting, so no real document changes behaviour: this is the guard the review asked for, and the test reaches it through `Session::edit` directly. --- crates/engine/src/cmd/analysis_cmds.rs | 11 +++++++-- .../cmd/analysis_straighten_enabled_tests.rs | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index 76744c37fc..c095bcd81d 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -164,7 +164,7 @@ fn has_straightenable_layer(s: &Session) -> std::result::Result<(), String> { /// [`straighten`] with [`STRAIGHTEN_HIDDEN_ERROR`], and the states the ticket marks *not measured* /// (a layer with a mask, a shape, vector or adjustment layer, artboards, and the lock kinds other /// than Lock All and Lock Position) keep today's behaviour. A document with no active layer keeps -/// turning the whole canvas as it does now. +/// turning the whole canvas as it does now; an active id the document no longer holds is refused. fn straighten_disabled(s: &Session) -> Option<&'static str> { let st = s.active()?; if st.selected_layers().len() > 1 { @@ -173,10 +173,17 @@ fn straighten_disabled(s: &Session) -> Option<&'static str> { // The active layer is the one the Layers panel targets; with none, the command turns the // canvas, which the reference was not measured for. let id = st.active_layer?; + // An id the document no longer holds: `Session::edit` keeps the *selected* layers valid but + // not the active one, so an edit that drops the active layer can leave a stale id behind. + // Turning and cropping the whole canvas for a layer that is not there is the worse answer, and + // `select_layer` and `restore_layer_selection` both refuse such an id. + let Some(layer) = st.doc.layer(id) else { + return Some("no active layer"); + }; if st.doc.is_background(id) { return Some("the Background"); } - if st.doc.layer(id).is_some_and(|l| l.is_group()) { + if layer.is_group() { return Some("a group"); } // Locks inherited from a group count, as they do for the Move tool. diff --git a/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs index 9e27c2ad8d..b2d107d7bb 100644 --- a/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs +++ b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs @@ -209,6 +209,30 @@ fn without_a_ruler_line_the_button_stays_greyed_for_every_layer() { assert!(!s.is_enabled(CMD), "a hidden layer without a ruler line stays greyed, not enabled"); } +#[test] +fn an_active_layer_the_document_no_longer_holds_is_refused() { + // `Session::edit` prunes the selected layers but not the active id, so an edit that drops the + // active layer can leave one behind (`select_layer` and `restore_layer_selection` both refuse + // such an id). The command must then say it is off, not rotate and crop the whole canvas for a + // layer that is not there. + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Gone"); + s.edit("drop", |doc, _| { + doc.layers.retain(|layer| layer.id != l); + Ok(()) + }) + .unwrap(); + assert_eq!(s.active().unwrap().active_layer, Some(l), "the target still points at the dropped layer"); + assert!(doc_of(&s).layer(l).is_none(), "and the document does not hold it"); + rule(&mut s); + assert_greyed(&mut s, "an active layer that is gone"); + // The destructive case this closes: `crop` asks for the canvas, which would turn and shrink for + // a target that no longer exists. + let size = (doc_of(&s).size.width, doc_of(&s).size.height); + assert!(s.execute(CMD, json!({"crop": true})).is_err()); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas kept its size"); +} + #[test] fn a_single_visible_layer_still_rotates_without_cropping() { // The pin for the state that stays: the layer turns about the canvas centre, the canvas does not. From c517e295b57ed74aab48309dc91c13eec4326da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Nov 2026 01:40:36 +0100 Subject: [PATCH 35/77] fix(ui): say why a Straighten Layer click was refused `PhotocraftApp::run` puts what a command answered into the status line, but a refusal raised before the command runs - the automation gate a control or MCP-driven click passes through - returns from `run_command_at_depth` ahead of that report, and the button handler only reacted to the hidden-layer alert. A denied synthetic click therefore did nothing at all, which reads as a dead button rather than a refused one. It now reports any error it gets, the way `eraser_ui` and the toolbar flyout do, and keeps opening the reference's alert for the hidden layer alone. --- crates/ui-egui/src/tools/analysis_ui.rs | 21 ++++++++++----- crates/ui-egui/src/tools/analysis_ui/tests.rs | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index 39c8dc1e51..3a0b879e50 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -535,13 +535,20 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // layer locked in place (#12033). let straighten = app.session.is_enabled("image.analysis.straightenLayer"); if ui.add_enabled_ui(straighten, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { - // A hidden target layer keeps the button on and answers the click with the - // reference's alert (its message and an OK button), changing nothing (#12033). - if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) - && why == photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR - { - let message = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR; - app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(message)}).as_object().cloned().unwrap_or_default()); + let hidden = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR; + if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) { + // A hidden target layer keeps the button on and answers the click with the + // reference's alert (its message and an OK button), changing nothing (#12033). + if why == hidden { + app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(hidden)}).as_object().cloned().unwrap_or_default()); + } + // Every other refusal goes to the status line, as the eraser tools do. What the + // command itself answered is reported there by `PhotocraftApp::run`, but a + // refusal raised before it runs - the automation gate a control or MCP-driven + // click passes - returns early, and a click that says nothing reads as a dead + // button (#12148). + app.ui.status = why; + app.ui.status_error = true; } } if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Clear"), 0.0)).inner.clicked() { diff --git a/crates/ui-egui/src/tools/analysis_ui/tests.rs b/crates/ui-egui/src/tools/analysis_ui/tests.rs index 502d83851a..517e7df649 100644 --- a/crates/ui-egui/src/tools/analysis_ui/tests.rs +++ b/crates/ui-egui/src/tools/analysis_ui/tests.rs @@ -391,6 +391,33 @@ fn the_straighten_layer_button_greys_out_where_the_command_is_off() { } } +/// A refused click says so. `PhotocraftApp::run` reports what the *command* answered in the status +/// line, but a refusal raised before it runs - the automation gate a control/MCP-driven click goes +/// through - returns early, so the button has to report it itself. As the toolbar flyout does +/// (`a_failed_edit_toolbar_from_the_flyout_reports_its_error_in_the_status_bar`). +#[test] +fn a_denied_synthetic_click_reports_the_refusal_instead_of_saying_nothing() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (mut app, _) = app(); + on_a_layer(&mut app); + rule(&mut app); + app.automation_input = true; + app.services.automation_command = + Some(Box::new(|id, _| if id == "image.analysis.straightenLayer" { Err("straightening is denied".into()) } else { Ok(()) })); + let mut h = ruler_bar(app); + assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "the layer is a valid target"); + let steps = h.state().session.active().unwrap().history.past_len(); + h.get_by_label("Straighten Layer").click(); + h.run_steps(2); + let st = h.state(); + assert_eq!(st.ui.status, "straightening is denied", "the refusal reaches the status line"); + assert!(st.ui.status_error, "as an error"); + assert!(alerts(st).is_empty(), "the alert stays the hidden layer's own"); + let now = st.session.active().unwrap(); + assert!(now.doc.measurement.ruler.is_some(), "nothing ran: the ruler line is still up"); + assert_eq!(now.history.past_len(), steps, "and nothing was recorded"); +} + /// #12033: a hidden target layer is the one state that keeps its button. The click answers with the /// reference's alert - its measured message and an OK button - instead of the status line, and the /// document, its History and the ruler line stay as they were. From 599baaa26e385eee471df5eb17c80f6f883cecaf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 18:42:08 -0600 Subject: [PATCH 36/77] Brush Preset picker: the tip's angle/roundness widget at its top left (#11821) The picker shows the Brush Settings panel's angle/roundness widget left of its Size and Hardness block, the same widget made reachable from the picker. Its drags go through tools.setBrush as one journaled change each, and Escape reverts an angle or roundness edit as it does a Size or Hardness edit. A pointer position that isn't finite leaves the tip as it was. --- .../src/brush_picker_angle_widget_tests.rs | 167 ++++++++++++++++++ crates/ui-egui/src/panels/brush_sections.rs | 5 +- crates/ui-egui/src/tools/brush_picker.rs | 103 +++++++---- 3 files changed, 235 insertions(+), 40 deletions(-) create mode 100644 crates/ui-egui/src/brush_picker_angle_widget_tests.rs diff --git a/crates/ui-egui/src/brush_picker_angle_widget_tests.rs b/crates/ui-egui/src/brush_picker_angle_widget_tests.rs new file mode 100644 index 0000000000..caed875a5b --- /dev/null +++ b/crates/ui-egui/src/brush_picker_angle_widget_tests.rs @@ -0,0 +1,167 @@ +//! #11821: the Brush Preset picker shows the tip's angle/roundness widget at its top left, the +//! Brush Settings panel's own widget, and its drags go through `tools.setBrush` as one journaled +//! change each; Escape reverts them as it does a Size or Hardness edit. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::brush_sections::{EllipseGrab, ellipse_drag, ellipse_grab, roundness_handles}; +use crate::state::Tool; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 20, "hardness": 1.0, "angle": 0.0, "roundness": 1.0}})).unwrap(); + app.ui.tool = Tool::Brush; + app.sync_views(); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let c = h.state().last_canvas_rect.center(); + let ctx = h.ctx.clone(); + crate::brush_picker::open(&mut h.state_mut().ui, &ctx, [c.x, c.y]); + h.run_steps(3); + h +} + +/// The widget's centre on screen. It is the top block's first item, 84 × 84, and the Size column +/// starts one item spacing right of it, its first row (24 pt tall, the label centred in it) level +/// with the widget's top. +fn widget_centre(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + use egui_kittest::kittest::Queryable; + let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open"); + let label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect(); + let spacing = h.ctx.global_style().spacing.item_spacing.x; + Pos2::new(label.left() - spacing - 42.0, label.center().y - 12.0 + 42.0) +} + +fn brush(h: &Harness<'static, PhotocraftApp>) -> (f32, f32) { + let b = &h.state().session.tools.brush; + (b.angle, b.roundness) +} + +/// A press at `from`, moves through `path` and the release at its end, a frame each. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, path: &[Pos2]) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for p in path { + h.event(Event::PointerMoved(*p)); + h.run_steps(1); + } + let end = path.last().copied().unwrap_or(from); + h.event(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn the_widget_sits_at_the_pickers_top_left_left_of_size() { + use egui_kittest::kittest::Queryable; + let h = harness(); + let c = widget_centre(&h); + // Its top left is the picker's content corner: inside the popup frame's margin and stroke. + let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open"); + let style = h.ctx.global_style(); + let inset = + vec2(f32::from(style.spacing.menu_margin.left), f32::from(style.spacing.menu_margin.top)) + egui::Vec2::splat(style.visuals.window_stroke().width); + let corner = c - vec2(42.0, 42.0); + assert!((corner - (picker.min + inset)).length() <= 2.0, "the widget at the top left: {corner:?} vs {:?}", picker.min + inset); + // The Size field is right of it, in the same top block. + let size = h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the Size field").rect(); + assert!(size.left() > c.x + 42.0 && size.top() < c.y + 42.0, "{size:?} vs centre {c:?}"); +} + +#[test] +fn dragging_the_arrow_sets_the_angle_as_the_panel_widget_does() { + let mut h = harness(); + let c = widget_centre(&h); + let (angle, roundness) = brush(&h); + // On the arrow (angle 0 points right), away from the roundness handles. + let from = c + vec2(28.0, 0.0); + assert_eq!(ellipse_grab(from - c, angle, roundness), EllipseGrab::Angle); + let to = c + vec2(-20.0, -20.0); + let journal = h.state().session.journal.len(); + drag(&mut h, from, &[c + vec2(10.0, -25.0), to]); + let want = ellipse_drag(EllipseGrab::Angle, to - c, angle, roundness); + assert_eq!(brush(&h), want, "the panel widget's values for the same drag"); + assert_eq!(want.0, 135.0); + let added: Vec = h.state().session.journal[journal..].iter().map(|(id, _)| id.clone()).collect(); + assert_eq!(added, ["tools.setBrush"], "one journaled change for the drag"); +} + +#[test] +fn dragging_a_handle_sets_the_roundness_as_the_panel_widget_does() { + let mut h = harness(); + let c = widget_centre(&h); + let (angle, roundness) = brush(&h); + let handle = c + roundness_handles(angle, roundness)[0]; + assert_eq!(ellipse_grab(handle - c, angle, roundness), EllipseGrab::Roundness); + let to = c + vec2(3.0, -12.0); + // The grab is judged where the drag starts: the first move stays within the handle's reach. + drag(&mut h, handle, &[handle + vec2(0.0, 6.5), c + vec2(2.0, -20.0), to]); + let want = ellipse_drag(EllipseGrab::Roundness, to - c, angle, roundness); + assert_eq!(brush(&h), want); + assert!((want.1 - 12.0 / 34.0).abs() < 1e-4, "{want:?}"); + // The Brush Settings panel reads the same brush. + assert_eq!(h.state().session.tools.brush.roundness, want.1); +} + +#[test] +fn escape_reverts_an_angle_edit_as_it_does_a_size_edit() { + let mut h = harness(); + let c = widget_centre(&h); + let opened = brush(&h); + drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, -15.0), c + vec2(0.0, -30.0)]); + assert_eq!(brush(&h).0, 90.0); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(brush(&h), opened, "Escape goes back to the values the picker opened with"); + assert_eq!(h.state().ui.brush_picker, None, "and closes it"); +} + +#[test] +fn enter_keeps_an_angle_edit_and_closes() { + let mut h = harness(); + let c = widget_centre(&h); + drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, 15.0), c + vec2(0.0, 30.0)]); + assert_eq!(brush(&h).0, -90.0); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(brush(&h).0, -90.0); + assert_eq!(h.state().ui.brush_picker, None); +} + +#[test] +fn non_finite_pointer_positions_leave_the_brush_unchanged() { + let mut h = harness(); + let c = widget_centre(&h); + let before = brush(&h); + let handle = c + roundness_handles(before.0, before.1)[0]; + h.event(Event::PointerMoved(handle)); + h.run_steps(1); + h.event(Event::PointerButton { pos: handle, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + // The pointer reports non-finite positions while the handle is held. (egui's own debug checks + // panic once a drag is under way at such a position, or on a release there, so the test stops + // here.) + for p in [Pos2::new(f32::NAN, c.y), Pos2::new(c.x, f32::INFINITY)] { + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let now = brush(&h); + assert!(now.0.is_finite() && now.1.is_finite(), "{p:?}: {now:?}"); + assert_eq!(now, before, "{p:?}"); + } +} diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index 7026f83352..38026cb94c 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -186,8 +186,9 @@ pub fn ellipse_drag(grab: EllipseGrab, rel: egui::Vec2, angle: f32, roundness: f } } -/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. -fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { +/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. Also at the +/// Brush Preset picker's top left (#11821). +pub(crate) fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(84.0, 84.0), Sense::click_and_drag()); let c = rect.center(); diff --git a/crates/ui-egui/src/tools/brush_picker.rs b/crates/ui-egui/src/tools/brush_picker.rs index 3e59a90444..a09179944e 100644 --- a/crates/ui-egui/src/tools/brush_picker.rs +++ b/crates/ui-egui/src/tools/brush_picker.rs @@ -91,31 +91,44 @@ pub(crate) fn opened_id() -> egui::Id { picker_id().with("opened-with") } -/// What Escape in the picker goes back to (#11816): the Size and Hardness it opened with (or a -/// picked preset brought), and the values its own fields last typed. Escape reverts a value only -/// while it is still the typed one, so a change from elsewhere (`]`, the options bar) stays. +/// What Escape in the picker goes back to (#11816): the Size, Hardness, Angle and Roundness it +/// opened with (or a picked preset brought), and the values its own controls last set. Escape +/// reverts a value only while it is still the picker's, so a change from elsewhere (`]`, the +/// options bar) stays. Angle and Roundness come from the tip widget (#11821). #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct Opened { - opened: [f32; 2], - typed: [f32; 2], + opened: [f32; 4], + typed: [f32; 4], +} + +fn picker_values(b: &photocraft_engine::BrushSettings) -> [f32; 4] { + [b.size, b.hardness, b.angle, b.roundness] } impl Opened { pub(crate) fn new(b: &photocraft_engine::BrushSettings) -> Self { - Self { opened: [b.size, b.hardness], typed: [b.size, b.hardness] } + Self { opened: picker_values(b), typed: picker_values(b) } } pub(crate) fn typed(&mut self, b: &photocraft_engine::BrushSettings) { - self.typed = [b.size, b.hardness]; + self.typed = picker_values(b); } pub(crate) fn revert(&self, now: &photocraft_engine::BrushSettings) -> photocraft_engine::BrushSettings { let mut b = now.clone(); - if b.size == self.typed[0] { - b.size = self.opened[0]; + let [size, hardness, angle, roundness] = self.opened; + let [typed_size, typed_hardness, typed_angle, typed_roundness] = self.typed; + if b.size == typed_size { + b.size = size; } - if b.hardness == self.typed[1] { - b.hardness = self.opened[1]; + if b.hardness == typed_hardness { + b.hardness = hardness; + } + if b.angle == typed_angle { + b.angle = angle; + } + if b.roundness == typed_roundness { + b.roundness = roundness; } b } @@ -260,35 +273,45 @@ pub fn body( ui.set_width(size.x); ui.set_min_height(size.y); let top = ui.cursor().min.y; - // The Size and Hardness controls live in one block flush with the left edge, at most - // [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space past the block empty instead of - // stretching the rows across it. - ui.scope(|ui| { - ui.set_max_width(ui.available_width().min(HEAD_MAX_W)); - // Size: value field plus a logarithmic slider (small sizes get most of the travel). - ui.horizontal(|ui| { - ui.label(RichText::new(tl!("Size")).color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut size = b.size; - if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { - b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); - } + // The tip's angle/roundness widget at the top left (#11821), then the Size and Hardness + // controls in one block, at most [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space + // past the block empty instead of stretching the rows across it. + ui.horizontal_top(|ui| { + let (angle, roundness) = (b.angle, b.roundness); + crate::brush_sections::ellipse_widget(ui, &mut b.angle, &mut b.roundness); + // A pointer position that isn't finite leaves the tip as it was. + let finite = |p: egui::Pos2| p.x.is_finite() && p.y.is_finite(); + let pointer_ok = ui.input(|i| i.pointer.interact_pos().is_none_or(finite) && i.pointer.latest_pos().is_none_or(finite)); + if !(pointer_ok && b.angle.is_finite() && b.roundness.is_finite()) { + (b.angle, b.roundness) = (angle, roundness); + } + ui.vertical(|ui| { + ui.set_max_width(ui.available_width().min(HEAD_MAX_W)); + // Size: value field plus a logarithmic slider (small sizes get most of the travel). + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Size")).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut size = b.size; + if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { + b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); + } + }); }); - }); - let mut lv = b.size.max(1.0).ln(); - let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); - if size_slider.changed() { - b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); - } - dismiss_popup(&size_slider); - if b.tip == TipShape::Round { - let mut hard = b.hardness * 100.0; - let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None); - if hardness.changed() { - b.hardness = (hard / 100.0).clamp(0.0, 1.0); + let mut lv = b.size.max(1.0).ln(); + let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); + if size_slider.changed() { + b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); } - dismiss_popup(&hardness); - } + dismiss_popup(&size_slider); + if b.tip == TipShape::Round { + let mut hard = b.hardness * 100.0; + let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None); + if hardness.changed() { + b.hardness = (hard / 100.0).clamp(0.0, 1.0); + } + dismiss_popup(&hardness); + } + }); }); ui.add_space(6.0); widgets::hairline(ui); @@ -437,3 +460,7 @@ fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset], } }); } + +#[cfg(test)] +#[path = "../brush_picker_angle_widget_tests.rs"] +mod brush_picker_angle_widget_tests; From 8da11e891af338d2a7006b39c4c47e018fb7194d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:54:33 +0100 Subject: [PATCH 37/77] PR #9991: Batch's quiet RAW opening applies the sidecar crop Main added a non-interactive RAW opener for Batch while #9991 was open. It refused a sidecar whose crop keys were unsupported. #9991 moves those keys from the ignored list into LoadReport::crop, so after the merge Batch would drop the crop without saying so. It now passes the crop into RawTuning, as the interactive open does. Co-Authored-By: ljkx <177671075+ljkx@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file_cmds/raw_open.rs | 13 ++++++++++--- crates/engine/src/cmd/file_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/raw_open.rs b/crates/engine/src/cmd/file_cmds/raw_open.rs index 905f359aaa..c252acc1de 100644 --- a/crates/engine/src/cmd/file_cmds/raw_open.rs +++ b/crates/engine/src/cmd/file_cmds/raw_open.rs @@ -5,9 +5,12 @@ use photocraft_io::{ImportResult, raw::RawTuning}; use super::{EngineError, Result, Session, Value, json}; +/// The prior settings of a sidecar, and its crop (in the raw's stored orientation). +type Prior = (CameraRaw, Option); + /// Read only a bounded regular XMP file. No ambient sidecar reads are granted to gated sessions. #[cfg(not(target_arch = "wasm32"))] -fn prior_settings(path: Option<&str>) -> Result> { +fn prior_settings(path: Option<&str>) -> Result> { use std::io::Read; let Some(path) = path else { return Ok(None) }; let sidecar = match path.rsplit_once('.') { @@ -31,11 +34,11 @@ fn prior_settings(path: Option<&str>) -> Result> { if !report.ignored.is_empty() { return Err(EngineError::Other(format!("{sidecar}: unsupported prior RAW settings: {}", report.ignored.join(", ")))); } - Ok(Some(settings)) + Ok(Some((settings, report.crop))) } #[cfg(target_arch = "wasm32")] -fn prior_settings(path: Option<&str>) -> Result> { +fn prior_settings(path: Option<&str>) -> Result> { if path.is_some() { Err(EngineError::Other("cannot read RAW sidecars on the web".into())) } else { Ok(None) } } @@ -67,6 +70,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8]) return Err(EngineError::Other("RAW sidecar opening requires a trusted session; no implicit read capability is available".into())); } let prior = prior_settings(path)?; + let crop = prior.as_ref().and_then(|(_, crop)| *crop); + let prior = prior.map(|(settings, _)| settings); let mut settings = prior.clone().unwrap_or_default(); let opts = crate::jobs::import_options(s.prefs()); let mut imported = photocraft_io::import_with_options(name, bytes, &opts).map_err(|error| EngineError::Other(format!("{name}: {error}")))?; @@ -87,6 +92,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8]) clarity: settings.clarity, texture: settings.texture, dehaze: settings.dehaze, + // The sidecar's crop, as the interactive open applies it: before the Camera Raw step. + crop, }; if tuning != (RawTuning { demosaic: opts.demosaic, ..Default::default() }) { super::open::raw_defaults(s, &imported.warnings, &mut imported.document); diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index d42d333128..33b1f60875 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -734,6 +734,27 @@ fn batch_quiet_raw_applies_prior_settings_and_workflow_without_touching_inputs() assert!(s.batch_open_notices.is_none()); } +/// #9985: quiet RAW opening crops to the sidecar's crop, as the interactive open does. +#[test] +fn batch_quiet_raw_applies_the_sidecar_crop() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let dir = tmp("batch-quiet-raw-crop"); + let path = join(&dir, "raw.dng"); + let (w, h) = (24, 16); + let bytes = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build(); + std::fs::write(&path, &bytes).unwrap(); + let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 }; + let xmp = photocraft_io::camera_raw_xmp::save_with_crop(&photocraft_algo::camera_raw::CameraRaw::default(), Some(crop)); + std::fs::write(join(&dir, "raw.xmp"), &xmp).unwrap(); + let mut s = Session::new(); + let output = join(&dir, "out"); + let p = json!({"input":[path],"output":output,"format":"psd","suppressOpenOptions":true,"suppressWarnings":true,"steps":[]}); + let result = s.execute("file.automate.batch", p).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let actual = photocraft_io::import("raw.psd", &std::fs::read(join(&output, "raw.psd")).unwrap()).unwrap().document; + assert_eq!((actual.size.width, actual.size.height), (12, 8), "the 24x16 frame cropped to x 3..15, y 4..12"); +} + fn tmp(name: &str) -> String { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); From b170944c3e1af64309fd691a3ea94c8bf534563f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Nov 2026 02:03:17 +0100 Subject: [PATCH 38/77] fix(engine): the Type tool converts only the text #12041 measured Paragraph (box) text and vertical type are on the issue's *Not measured* list, whose rule is that an unmeasured case keeps today's behaviour - and a coverage mask could not give back a box that reflows or a column that stacks its lines, so their editable type layer stays. The commit converts the measured case only: horizontal point text in an Indexed Color or a Bitmap document. The Type Mask tools keep turning both into the selection, as they do today; there the user asked for one. Also pin what happens when an edit that carries no coalesce key - the deliberately per-press Alt+arrow kerning step - comes between the typing and the commit: the commit is a state of its own, named for the type layer, exactly as the Type Mask commit and the plain layer's rename are in that situation today. Nothing is mis-named; folding those states together would delete history the user can undo today and belongs with #12039. Tests: commits_as_selection across shape and orientation; the tool's commit of box and vertical text in both modes; the split states and what each undo takes. --- crates/engine/src/cmd/type_cmds.rs | 28 ++++++-- .../engine/src/cmd/type_indexed_mode_tests.rs | 71 ++++++++++++++++++- crates/ui-egui/src/tools/type_tool/editing.rs | 58 +++++++++++++-- 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index 16d3d29a91..f1f8aa53c4 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -941,10 +941,21 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result bool { - matches!(mode, ColorMode::Indexed | ColorMode::Bitmap) +pub fn commits_as_selection(mode: ColorMode, t: &TextLayer) -> bool { + let measured = matches!(t.shape, TextShape::Point) && t.orientation == Orientation::Horizontal; + measured && matches!(mode, ColorMode::Indexed | ColorMode::Bitmap) } /// Name the step the Type tool's commit made in such a document (#12041). The commit runs the Type /// Mask tools' `type.createSelection` with `removeLayer` - the same conversion, coalesced into the /// one step of the typing session - and that command names the step "Type Mask", while Photoshop's /// history state here is the type layer's own name. +/// +/// An edit that does not carry the session's `coalesce` key (the Alt+arrow kerning step, which is +/// deliberately one state per press) ends the session's step, exactly as it does for the Type Mask +/// tools and for the plain type layer's rename on commit; the conversion is then a state of its own +/// and takes this name too. Photoshop repeats a state name whenever the same thing happens again, +/// so the two states read as the two type-layer steps they are. pub fn name_selection_commit(s: &mut Session) { if let Some(st) = s.active_mut() { st.history.set_current_label(NEW_TYPE_LAYER_LABEL); diff --git a/crates/engine/src/cmd/type_indexed_mode_tests.rs b/crates/engine/src/cmd/type_indexed_mode_tests.rs index d60899c96f..8f6a8058fb 100644 --- a/crates/engine/src/cmd/type_indexed_mode_tests.rs +++ b/crates/engine/src/cmd/type_indexed_mode_tests.rs @@ -13,9 +13,16 @@ //! layer they edit and show on canvas with that command, on the first click already with text in it //! when Preferences > Type > "Fill new type layers with placeholder text" is on (the default), so a //! mode branch there would take away the layer the session types into. +//! +//! The issue measures the horizontal point text you click and type, and lists paragraph text and +//! vertical type under *Not measured*, where its rule is that today's behaviour stays: +//! [`only_the_measured_horizontal_point_text_converts_in_these_modes`] pins that half of the check, +//! and [`an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states`] what happens to the +//! states when an edit outside the session's coalesce group comes between the typing and the commit. -use super::type_cmds::{commits_as_selection, name_selection_commit}; +use super::type_cmds::{commits_as_selection, name_selection_commit, new_text_layer}; use super::*; +use photocraft_doc::text::{Orientation, TextShape}; /// The typing session's coalesce key, as `ui-egui`'s `session_key` gives one. const SESSION: &str = "type-session-12041"; @@ -166,7 +173,8 @@ fn rgb_grayscale_cmyk_and_lab_keep_making_type_layers_at_every_depth() { let mut s = Session::new(); s.execute("file.new", json!({"width": 240, "height": 120, "mode": id, "depth": depth, "background": "transparent"})).unwrap(); assert_eq!(mode(&s), want, "{id} at {depth} bit: the document really is that mode"); - assert!(!commits_as_selection(want), "{id} keeps type layers"); + let point = new_text_layer(&s, &typed(false), "type.create").unwrap(); + assert!(!commits_as_selection(want, &point), "{id} keeps type layers"); let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); // Today's commit: rename the session's layer in the same step, select nothing. @@ -218,3 +226,62 @@ fn a_refused_conversion_leaves_the_typed_text_and_the_document_alone() { assert_eq!(history(&s).last().cloned(), label, "{name}: the refused conversion renames nothing"); } } + +/// The measurement covers horizontal point text. Paragraph (box) text and vertical type are on the +/// issue's *Not measured* list, where its rule is that today's behaviour stays - and a coverage mask +/// could not give a reflowing box or a stacked column back anyway, so their editable layer stays. +#[test] +fn only_the_measured_horizontal_point_text_converts_in_these_modes() { + let s = indexed(); + let point = new_text_layer(&s, &typed(false), "type.create").unwrap(); + assert!(matches!(point.shape, TextShape::Point) && point.orientation == Orientation::Horizontal, "the fixture is horizontal point text"); + + let mut box_params = typed(false); + box_params["box"] = json!([20.0, 20.0, 180.0, 80.0]); + let paragraph = new_text_layer(&s, &box_params, "type.create").unwrap(); + assert!(matches!(paragraph.shape, TextShape::Box { .. }), "the fixture is paragraph text"); + + let mut vertical_params = typed(false); + vertical_params["orientation"] = json!("vertical"); + let vertical = new_text_layer(&s, &vertical_params, "type.create").unwrap(); + assert_eq!(vertical.orientation, Orientation::Vertical, "the fixture is vertical text"); + + for mode in [ColorMode::Indexed, ColorMode::Bitmap] { + assert!(commits_as_selection(mode, &point), "{mode:?}: the measured text becomes the selection"); + assert!(!commits_as_selection(mode, ¶graph), "{mode:?}: paragraph text keeps its type layer"); + assert!(!commits_as_selection(mode, &vertical), "{mode:?}: vertical text keeps its type layer"); + } +} + +/// An edit that does not carry the session's `coalesce` key ends its coalesce group - in the tool +/// that is the deliberately per-press Alt+arrow kerning step. The commit then makes a state of its +/// own, which is what the Type Mask tools' commit and the plain layer's rename do in the same +/// situation today; nothing is mis-named and nothing else moves. +#[test] +fn an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + // The stand-in for that kerning press: a real edit under no coalesce key. + s.execute("type.edit", json!({"layer": layer, "antialias": "sharp"})).unwrap(); + let before = history(&s); + assert_eq!(before.last().map(String::as_str), Some("Edit Type"), "{name}: the unkeyed edit is a state of its own"); + + commit(&mut s, layer); + + let h = history(&s); + assert_eq!(h.len(), before.len() + 1, "{name}: the commit is a further state once the group ended: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: named for the type layer either way"); + assert_eq!(h[h.len() - 2].as_str(), "Edit Type", "{name}: the edit between them keeps its own name"); + assert_eq!(text_layers(&s), 0, "{name}: still no type layer is left"); + assert!(s.active().unwrap().doc.selection.is_some(), "{name}: still the glyphs are selected"); + + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: one undo takes the conversion"); + assert_eq!(text_layers(&s), 1, "{name}: which brings the working layer back, as the mask tools' undo does"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(text_layers(&s), 1, "{name}: the next undo takes the unkeyed edit, not the session"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(text_layers(&s), 0, "{name}: and the third one takes the typing session"); + } +} diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index 20eccbce41..adb70c5574 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -478,12 +478,19 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { // commit makes what a Type Mask tool's commit makes: the glyphs become the selection and the // layer goes, in the typing session's one history step - which keeps the "New Type Layer" name // it was opened with, as Photoshop's state there is named. - // `photocraft_engine::type_cmds::commits_as_selection` says why the check belongs to the commit - // and not to the `type.create` command. Two cases are not measured in these modes and keep - // today's behaviour: an edit of a layer the session did not create (`ed.created`), and a commit - // with no text in it, which is the empty-layer clean-up below. - let leaves_a_selection = ed.created && app.session.active().is_some_and(|st| photocraft_engine::type_cmds::commits_as_selection(st.doc.mode)); - if leaves_a_selection && !text.trim().is_empty() { + // `photocraft_engine::type_cmds::commits_as_selection` holds the ticket's scope - the two modes + // and the measured horizontal point text - and why the check belongs to the commit and not to + // the `type.create` command. Three cases are not measured in these modes and keep today's + // behaviour: an edit of a layer the session did not create (`ed.created`), paragraph (box) and + // vertical text, whose editability a coverage mask could not give back, and a commit with no + // text in it, which is the empty-layer clean-up below. + let session_layer = LayerId(ed.layer); + let converts = app + .session + .active() + .is_some_and(|st| text_layer(&st.doc, session_layer).is_some_and(|t| photocraft_engine::type_cmds::commits_as_selection(st.doc.mode, t))); + let leaves_a_selection = ed.created && !text.trim().is_empty() && converts; + if leaves_a_selection { if let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { // The refused step left the document alone: keep today's type layer, so what was typed // is not lost with a selection that was never made. @@ -558,7 +565,7 @@ mod indexed_commit_tests { use crate::PhotocraftApp; use crate::state::Tool; use crate::tools::type_tool::pointer_up; - use photocraft_doc::{ColorMode, LayerContent}; + use photocraft_doc::{ColorMode, LayerContent, LayerId, TextLayer}; use serde_json::json; /// A new transparent document in `mode`: `rgb` keeps today's behaviour, `indexed` and `bitmap` @@ -587,6 +594,11 @@ mod indexed_commit_tests { app.session.active().unwrap().doc.mode } + fn text_of(app: &PhotocraftApp, id: LayerId) -> Option { + let doc = app.session.active().unwrap().doc.clone(); + super::text_layer(&doc, id).cloned() + } + fn text_layers(app: &PhotocraftApp) -> usize { app.session.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() } @@ -662,4 +674,36 @@ mod indexed_commit_tests { assert_eq!(states(&app), states_before, "{mode}: and no history state"); } } + + /// #12041 measures the horizontal point text you click and type. Paragraph (box) text and + /// vertical type are on its *Not measured* list, whose rule is that they keep today's + /// behaviour - and today's behaviour is the right one here anyway: a coverage mask cannot bring + /// back a box that reflows or a column that runs down the page, so their editable layer stays. + #[test] + fn paragraph_and_vertical_text_keep_their_type_layer_in_indexed_and_bitmap() { + use photocraft_doc::text::{Orientation, TextShape}; + + for mode in ["indexed", "bitmap"] { + // A dragged paragraph box. + let mut boxed = app(mode); + pointer_up(&mut boxed, [40.0, 60.0], [320.0, 180.0]); + insert(&mut boxed, "Hi"); + let box_layer = LayerId(boxed.ui.text_edit.as_ref().unwrap().layer); + assert!(matches!(text_of(&boxed, box_layer).map(|t| t.shape), Some(TextShape::Box { .. })), "{mode}: the setup made paragraph text"); + commit(&mut boxed); + assert_eq!(text_layers(&boxed), 1, "{mode}: paragraph text keeps its editable layer"); + assert!(selection(&boxed).is_none(), "{mode}: and is not turned into a selection"); + + // Vertical point text. + let mut column_app = app(mode); + column_app.ui.tool = Tool::VerticalType; + pointer_up(&mut column_app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut column_app, "Hi"); + let column = LayerId(column_app.ui.text_edit.as_ref().unwrap().layer); + assert!(text_of(&column_app, column).is_some_and(|t| t.orientation == Orientation::Vertical), "{mode}: the setup made vertical text"); + commit(&mut column_app); + assert_eq!(text_layers(&column_app), 1, "{mode}: vertical text keeps its editable layer"); + assert!(selection(&column_app).is_none(), "{mode}: and is not turned into a selection"); + } + } } From b29fdc5a295f2a63245d6cb2523b38e93e4f7f09 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 20:05:43 -0500 Subject: [PATCH 39/77] Open as Smart Object: the opening snapshot shows the placed smart object (#11678) The smart object layer is built into the document before admission, so snapshot 0 holds it and the history is the single state "Open As Smart Object", as File > Open's is one state. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/file_cmds.rs | 1 + .../src/cmd/file_cmds/open_as_smart_object.rs | 46 +++++++++---------- .../file_cmds/open_as_smart_object_tests.rs | 20 ++++++-- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 00efb839e1..d2cdf92d32 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -51,6 +51,7 @@ pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_ pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; +pub use open_as_smart_object::admit_as_smart_object; use open_as_smart_object::open_as_smart_object; use paths::is_file; #[cfg(test)] diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index 0d162b5238..f365170f34 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -1,12 +1,14 @@ //! File › Open as Smart Object… (#11678): a file opens as a new untitled document with the file's //! size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer. //! -//! The layer is built as File › Place Embedded builds one (the same [`Prepared`] layer, inserted -//! by [`apply_place`]), at the file's own size and position `0, 0`. +//! The layer is built as File › Place Embedded builds one (an embedded smart object at the file's own +//! size and position `0, 0`) and put into the document before it is admitted, so the opening +//! snapshot already shows it. -use super::open::{Prepared, apply_place, import_opened}; +use super::open::import_opened; use super::paths::{file_name, read_file, stem, untitled_name}; -use super::{Affine, Arc, Document, EngineError, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; +use super::{Affine, Arc, Document, EngineError, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; +use crate::layer_place::Place; const CMD: &str = "file.openAsSmartObject"; @@ -49,7 +51,6 @@ fn open_one(s: &mut Session, path: &str) -> Result { let src = import_opened(s, path, &bytes)?; // The file as one image: the smart object's rendered contents. let pixels = flattened_checked(&src, src.pixel_format(), CMD)?; - let (w, h) = (f64::from(src.size.width), f64::from(src.size.height)); let layer_name = stem(path); // "-1", the next free number while a document with that name is open. let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); @@ -63,26 +64,25 @@ fn open_one(s: &mut Session, path: &str) -> Result { // Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it. let source = SmartSource::Embedded { file_name: file_name(path), bytes: Arc::new(bytes) }; let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels)); - let prepared = Prepared { - content: LayerContent::Smart(so), - layer_name: layer_name.clone(), - label: LABEL, - scale: 1.0, - bounds: [0.0, 0.0, w, h], - smart: true, - frame: None, - }; + let layer = Layer::new(layer_name, LayerContent::Smart(so)); + let layer_id = layer.id; + crate::layer_place::insert_new(&mut doc, None, layer, Place::AutoEditable); + let index = admit_as_smart_object(s, doc); + Ok(json!({"document": index, "name": name, "layer": layer_id.0})) +} + +/// Admits a document built by Open as Smart Object (one smart object layer) as an untitled +/// document: the opening snapshot already shows the placed layer, is named after it, and the +/// history is the single state "Open As Smart Object", as File › Open's is one state. +pub fn admit_as_smart_object(s: &mut Session, doc: Document) -> usize { + let layer_name = doc.layers.first().map(|l| l.name.clone()); // Untitled: no path, so Save asks where to save. let index = s.add_document(doc, None); - // The opening snapshot is named after the layer, as the reference names it. - if let Some(first) = s.active_mut().and_then(|st| st.snapshots.iter_mut().find(|snap| snap.id == 0)) { - first.name = layer_name; - } - match apply_place(s, prepared) { - Ok(placed) => Ok(json!({"document": index, "name": name, "layer": placed["layer"]})), - Err(e) => { - s.close(index); - Err(e) + if let Some(st) = s.active_mut() { + if let (Some(first), Some(name)) = (st.snapshots.iter_mut().find(|snap| snap.id == 0), layer_name) { + first.name = name; } + st.history.set_current_label(LABEL); } + index } diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 6378c47353..b6dce7b1e5 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -117,17 +117,31 @@ fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() { } #[test] -fn history_is_the_opening_snapshot_then_open_as_smart_object() { +fn history_is_the_single_state_open_as_smart_object() { let dir = tmp("history"); let path = write_png(&dir, "a_test.png", 16, 16); let mut s = Session::new(); s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); let st = s.active().unwrap(); - assert_eq!(st.history.entries().last().map(String::as_str), Some("Open As Smart Object")); - assert_eq!(st.history.entries().len(), 2, "the opening state, then one step"); + assert_eq!(st.history.entries(), ["Open As Smart Object"], "one state, as File > Open has"); assert_eq!(st.snapshots.first().map(|snap| snap.name.as_str()), Some("a_test"), "the opening snapshot is named after the layer"); } +#[test] +fn the_opening_snapshot_shows_the_placed_smart_object() { + let dir = tmp("snapshot"); + let path = write_png(&dir, "a_test.png", 16, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let st = s.active().unwrap(); + let first = st.snapshots.first().unwrap(); + assert_eq!(first.doc.layers.len(), 1, "snapshot 0 already holds the smart object"); + assert!(matches!(first.doc.layers[0].content, LayerContent::Smart(_))); + assert_eq!(first.layers.active, Some(first.doc.layers[0].id)); + assert_eq!(st.active_layer, Some(first.doc.layers[0].id)); + assert_eq!(st.history.entries(), ["Open As Smart Object"]); +} + #[test] fn the_number_is_the_next_free_one_and_starts_again_after_they_close() { let dir = tmp("numbers"); From 22882f690c1775a9447715e524a7849da17bea67 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:24:08 +0100 Subject: [PATCH 40/77] Background Eraser: alpha falls off over the whole Tolerance (#11901) One dab now erases a colour less the further it is from the sampled colour, over the whole Tolerance, instead of fully up to the last quarter of it. The weight is 1 - clamp((d - d0) / (T - d0), 0, 1) with d0 = min(T / 20, 8 levels), measured with single clicks in the reference application. The colour distance is the largest increase plus the largest decrease over the channels, so opposite-sign changes add up. Find Edges keeps its hard cut (#11904). Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 37 +++- .../paint/src/bg_erase_graded_alpha_tests.rs | 177 ++++++++++++++++++ 2 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 crates/paint/src/bg_erase_graded_alpha_tests.rs diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 323a90036a..751d58519b 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -40,9 +40,31 @@ impl Default for BgEraseSettings { } } -/// Largest per-channel difference over the colour channels. +/// Colour distance of `a` from `b` over the colour channels: the largest increase plus the largest +/// decrease (#11901). Changes in the same direction count like the largest of them; changes in +/// opposite directions add up, as the reference application measures them (a red-to-green edge is +/// about twice as far as one channel alone). One channel (Gray) gives the absolute difference. fn dist(a: &[f32], b: &[f32]) -> f32 { - a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) + let (up, down) = a.iter().zip(b).fold((0.0f32, 0.0f32), |(up, down), (x, y)| { + let diff = x - y; + (up.max(diff), down.max(-diff)) + }); + up + down +} + +/// The match weight of one dab for a colour `d` away from the sampled colour (#11901): an exact +/// match is erased fully, then the weight falls off linearly over the whole Tolerance `tol` after +/// a short plateau `d0 = min(tol / 20, 8 levels)`, and colours at or beyond the Tolerance are kept. +/// Measured in the reference application with single clicks (one dab) at Tolerance 10 to 100. +fn graded_weight(d: f32, tol: f32) -> f32 { + if !d.is_finite() || d >= tol { + return if d <= 0.0 { 1.0 } else { 0.0 }; + } + let d0 = (tol / 20.0).min(8.0 / 255.0); + if d <= d0 { + return 1.0; + } + (1.0 - (d - d0) / (tol - d0)).clamp(0.0, 1.0) } /// Erase along `stroke`. The brush opacity is ignored (Photoshop's Background Eraser has none); @@ -77,11 +99,10 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option>> = None; let match_w = move |d: f32| -> f32 { - if d > tol { + if soft { + graded_weight(d, tol) + } else if d > tol { 0.0 - } else if soft && tol > 0.0 { - // Soft falloff over the last quarter of the tolerance. - ((tol - d) / (tol * 0.25)).clamp(0.0, 1.0) } else { 1.0 } @@ -191,6 +212,10 @@ fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize) } } +#[cfg(test)] +#[path = "bg_erase_graded_alpha_tests.rs"] +mod graded_alpha_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/paint/src/bg_erase_graded_alpha_tests.rs b/crates/paint/src/bg_erase_graded_alpha_tests.rs new file mode 100644 index 0000000000..51e1bbc20d --- /dev/null +++ b/crates/paint/src/bg_erase_graded_alpha_tests.rs @@ -0,0 +1,177 @@ +//! Background Eraser graded alpha (#11901): one dab erases a colour less the further it is from the +//! sampled colour, over the whole Tolerance, not fully up to the Tolerance. Expected values are the +//! reference application's single-click measurements from the issue (8-bit RGB, 40 px hard round +//! tip, Sampling Background Swatch with background (200,40,40), alpha at the patch centre). + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba}; + +use crate::bg_erase::{BgEraseSettings, apply_background_eraser}; +use crate::replace::{Limits, Sampling}; +use crate::{BrushSettings, Stroke, StrokePoint}; + +/// The sampled colour of every measurement: (200,40,40). +const SAMPLE: [i32; 3] = [200, 40, 40]; + +/// Allowed deviation from the measured alpha ("within 8 levels"). +const LEVELS: i32 = 8; + +fn rgb(c: [i32; 3]) -> [f32; 4] { + [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0] +} + +fn click(at: (f64, f64)) -> Stroke { + Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(at.0, at.1, 1.0)], + } +} + +fn swatch(tolerance_percent: f32, background: [f32; 4]) -> BgEraseSettings { + BgEraseSettings { + sampling: Sampling::BackgroundSwatch, + limits: Limits::Discontiguous, + tolerance: tolerance_percent / 100.0, + background, + ..Default::default() + } +} + +/// One click in the centre of a 100 px patch of `colour` (straight RGBA) in `fmt`; alpha 0-255 at +/// the centre. +fn one_click(fmt: PixelFormat, colour: [f32; 4], bs: &BgEraseSettings) -> i32 { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 100, 100), &from_rgba(&fmt, colour)); + apply_background_eraser(&mut s, &click((50.0, 50.0)), bs, None); + (s.rgba(50, 50)[3] * 255.0).round() as i32 +} + +/// The measured single-click table: (Tolerance, [(green difference d, alpha)]). +fn single_click_table() -> Vec<(f32, Vec<(i32, i32)>)> { + vec![ + (10.0, vec![(10, 92), (20, 194), (30, 255), (40, 255)]), + (30.0, vec![(10, 21), (20, 56), (30, 91), (40, 126), (50, 161), (60, 196), (80, 255), (100, 255)]), + (50.0, vec![(10, 8), (20, 29), (30, 50), (40, 71), (50, 92), (60, 113), (80, 155), (100, 196), (120, 238), (140, 255)]), + (70.0, vec![(10, 3), (20, 18), (30, 33), (40, 48), (50, 63), (60, 78), (80, 107), (100, 137), (120, 167), (140, 197), (160, 227)]), + (100.0, vec![(10, 2), (20, 12), (30, 23), (40, 33), (50, 43), (60, 54), (80, 74), (100, 95), (120, 116), (140, 136), (160, 157)]), + ] +} + +fn assert_near(got: i32, want: i32, what: &str) { + assert!((got - want).abs() <= LEVELS, "{what}: got alpha {got}, measured {want}"); +} + +#[test] +fn one_click_falls_off_over_the_whole_tolerance() { + let bs = |tol| swatch(tol, rgb(SAMPLE)); + for (tol, row) in single_click_table() { + for (d, want) in row { + let got = one_click(PixelFormat::RGBA8, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &bs(tol)); + assert_near(got, want, &format!("Tolerance {tol}, green +{d}")); + } + } +} + +#[test] +fn same_sign_differences_count_once_and_opposite_signs_add_up() { + // Measured at Tolerance 50, single clicks, sample (200,40,40): (difference per channel, alpha). + let rows: [([i32; 3], i32); 15] = [ + ([0, 20, 0], 29), + ([-20, 0, 0], 29), + ([0, 0, 20], 29), + ([0, 20, 20], 29), + ([0, 40, 20], 71), + ([0, 20, 40], 71), + ([0, 60, 20], 113), + ([-20, 20, 0], 71), + ([-40, 40, 0], 155), + ([-60, 60, 0], 238), + ([10, 10, 10], 8), + ([20, 20, 20], 29), + ([40, 40, 40], 71), + ([-10, -10, -10], 8), + ([-40, -40, -40], 71), + ]; + let bs = swatch(50.0, rgb(SAMPLE)); + for (diff, want) in rows { + let colour = [SAMPLE[0] + diff[0], SAMPLE[1] + diff[1], SAMPLE[2] + diff[2]]; + assert_near(one_click(PixelFormat::RGBA8, rgb(colour), &bs), want, &format!("difference {diff:?}")); + } +} + +#[test] +fn a_blurred_edge_keeps_its_transition_graded() { + // Red (220,30,30) | green (30,180,60) at x 200, blurred with a 4 px Gaussian; the measured red + // channel along y 70 from x 185. A Gaussian blur mixes every channel with the same weights, so + // green and blue follow red's mix fraction. + const RED: [i32; 30] = + [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30]; + const ALPHA: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255]; + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 185, 140), &from_rgba(&fmt, rgb([220, 30, 30]))); + s.fill_rect(Rect::new(215, 0, 400, 140), &from_rgba(&fmt, rgb([30, 180, 60]))); + for (i, r) in RED.iter().enumerate() { + let t = (220 - r) as f32 / 190.0; + let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0]; + let x = 185 + i as i32; + s.fill_rect(Rect::new(x, 0, x + 1, 140), &from_rgba(&fmt, c)); + } + for limits in [Limits::Contiguous, Limits::Discontiguous] { + let mut t = s.clone(); + let bs = BgEraseSettings { sampling: Sampling::Once, limits, tolerance: 0.5, ..Default::default() }; + apply_background_eraser(&mut t, &click((190.0, 70.0)), &bs, None); + for (i, want) in ALPHA.iter().enumerate() { + let x = 185 + i as i32; + let got = (t.rgba(x, 70)[3] * 255.0).round() as i32; + assert_near(got, *want, &format!("{limits:?} x {x}")); + } + for x in 200..215 { + assert_eq!(t.rgba(x, 70)[3], 1.0, "{limits:?} x {x} beyond the Tolerance kept"); + } + } +} + +#[test] +fn an_exact_match_is_erased_and_far_colours_are_kept_at_every_tolerance() { + for tol in [0.0, 1.0, 10.0, 30.0, 50.0, 70.0, 100.0] { + let bs = swatch(tol, rgb(SAMPLE)); + assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 0, "Tolerance {tol}: the sample itself"); + if tol < 100.0 { + // Opposite-sign differences of 255 + 215: beyond every Tolerance below 100 %. + assert_eq!(one_click(PixelFormat::RGBA8, rgb([0, 255, 40]), &bs), 255, "Tolerance {tol}: a far colour"); + } + } + // Tolerance 0 erases nothing but the exact colour. + assert_eq!(one_click(PixelFormat::RGBA8, rgb([200, 41, 40]), &swatch(0.0, rgb(SAMPLE))), 255); +} + +#[test] +fn the_distance_is_measured_in_the_documents_own_channels() { + // 16-bit RGB: the same single-click table as 8-bit. + let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true); + for (tol, row) in single_click_table() { + for (d, want) in row { + let got = one_click(rgb16, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &swatch(tol, rgb(SAMPLE))); + assert_near(got, want, &format!("16-bit Tolerance {tol}, green +{d}")); + } + } + // Gray (8 and 16 bit): the distance is the absolute difference of the gray levels, so a gray + // patch d levels from the swatch erases like a one-channel difference of d in RGB, either way. + for sample in [SampleType::U8, SampleType::U16] { + let gray = PixelFormat::new(ColorMode::Grayscale, sample, true); + let level = |v: i32| [v as f32 / 255.0, v as f32 / 255.0, v as f32 / 255.0, 1.0]; + for (tol, row) in single_click_table() { + for (d, want) in row { + let bs = swatch(tol, level(60)); + if 60 + d <= 255 { + assert_near(one_click(gray, level(60 + d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, +{d}")); + } + if d <= 60 { + assert_near(one_click(gray, level(60 - d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, -{d}")); + } + } + } + } +} From 23f63c685eb3053f6434cdacc4b27afeb32e39c8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:31:23 +0100 Subject: [PATCH 41/77] Background Eraser: Find Edges erases soft edges graded instead of cutting them hard (#11904) Find Edges used to give every pixel within the Tolerance full weight, so on a blurred edge it erased more than Contiguous. It now uses the same graded weight (#11901), which keeps the transition as the reference application does and still gives the Contiguous result on a hard edge. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 17 ++- crates/paint/src/bg_erase_find_edges_tests.rs | 101 ++++++++++++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 crates/paint/src/bg_erase_find_edges_tests.rs diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 751d58519b..2e8da047fc 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -92,21 +92,14 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option Vec { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() }; let swatch = native(bs.background); let fg = native(bs.foreground); let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground); let mut once: Option>> = None; - let match_w = move |d: f32| -> f32 { - if soft { - graded_weight(d, tol) - } else if d > tol { - 0.0 - } else { - 1.0 - } - }; + // Find Edges grades soft edges like Contiguous does instead of cutting them hard (#11904): + // the reference application keeps at least as much of a blurred transition with it. + let match_w = move |d: f32| -> f32 { graded_weight(d, tol) }; let pre = pre.clone(); // The pixels before the stroke under the working copy, read once per working area. Sampling // and matching read these, so a dab landing on pixels earlier dabs already erased still @@ -216,6 +209,10 @@ fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize) #[path = "bg_erase_graded_alpha_tests.rs"] mod graded_alpha_tests; +#[cfg(test)] +#[path = "bg_erase_find_edges_tests.rs"] +mod find_edges_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/paint/src/bg_erase_find_edges_tests.rs b/crates/paint/src/bg_erase_find_edges_tests.rs new file mode 100644 index 0000000000..ee95dd8c2e --- /dev/null +++ b/crates/paint/src/bg_erase_find_edges_tests.rs @@ -0,0 +1,101 @@ +//! Background Eraser Limits Find Edges (#11904): a blurred edge is erased graded, keeping at least +//! as much of the transition as Contiguous does, instead of cut hard at the Tolerance. Expected +//! values are the reference application's measurements from the issue (8-bit RGB, 40 px and 60 px +//! hard round tips, Tolerance 50, Sampling Continuous). + +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba}; + +use crate::bg_erase::{BgEraseSettings, apply_background_eraser}; +use crate::replace::{Limits, Sampling}; +use crate::{BrushSettings, Stroke, StrokePoint}; + +/// Measured red channel along y 70 from x 185 across the 4 px Gaussian red|green edge at x 200. +const RED: [i32; 30] = [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30]; +/// Alpha along y 70 from x 185 after a click at 190,70. +const FIND_EDGES: [i32; 15] = [8, 8, 8, 4, 4, 0, 0, 0, 17, 42, 76, 121, 176, 238, 255]; +const CONTIGUOUS: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255]; + +fn rgb(c: [i32; 3]) -> [f32; 4] { + [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0] +} + +/// The issue's test layer: red | green | blue | pink stripes (y 0-300), a white gap at y 140-160 +/// across the red stripe, and the red|green edge blurred over y 0-130. A Gaussian blur mixes every +/// channel with the same weights, so green and blue follow red's mix fraction. +fn layer() -> Surface { + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + for (x0, c) in [(0, [220, 30, 30]), (200, [30, 180, 60]), (400, [40, 60, 220]), (600, [240, 120, 120])] { + s.fill_rect(Rect::new(x0, 0, x0 + 200, 300), &from_rgba(&fmt, rgb(c))); + } + s.fill_rect(Rect::new(0, 140, 200, 160), &from_rgba(&fmt, rgb([255, 255, 255]))); + for (i, r) in RED.iter().enumerate() { + let t = (220 - r) as f32 / 190.0; + let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0]; + let x = 185 + i as i32; + s.fill_rect(Rect::new(x, 0, x + 1, 130), &from_rgba(&fmt, c)); + } + s +} + +fn click(at: (f64, f64), size: f32, limits: Limits) -> Surface { + let mut s = layer(); + let stroke = Stroke { + brush: BrushSettings { size, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(at.0, at.1, 1.0)], + }; + let bs = BgEraseSettings { sampling: Sampling::Continuous, limits, tolerance: 0.5, ..Default::default() }; + apply_background_eraser(&mut s, &stroke, &bs, None); + s +} + +fn alpha(s: &Surface, x: i32, y: i32) -> i32 { + (s.rgba(x, y)[3] * 255.0).round() as i32 +} + +#[test] +fn find_edges_grades_a_blurred_edge_instead_of_cutting_it() { + let fe = click((190.0, 70.0), 40.0, Limits::FindEdges); + let co = click((190.0, 70.0), 40.0, Limits::Contiguous); + for (i, want) in FIND_EDGES.iter().enumerate() { + let x = 185 + i as i32; + let (f, c) = (alpha(&fe, x, 70), alpha(&co, x, 70)); + assert!((f - want).abs() <= 10, "x {x}: Find Edges alpha {f}, measured {want}"); + assert!(f >= c, "x {x}: Find Edges keeps {f}, less than Contiguous's {c}"); + } + // Past the transition nothing is erased (before, Find Edges cut through x 201). + for x in 200..230 { + assert_eq!(alpha(&fe, x, 70), 255, "x {x}"); + } +} + +#[test] +fn find_edges_on_a_hard_edge_gives_the_contiguous_result() { + // 60 px tip at 100,138 on red just above the white gap: column x 100 erased through y 139, the + // gap and the red below it kept. + let fe = click((100.0, 138.0), 60.0, Limits::FindEdges); + let co = click((100.0, 138.0), 60.0, Limits::Contiguous); + for y in 100..176 { + assert_eq!(alpha(&fe, 100, y), alpha(&co, 100, y), "y {y}"); + } + for y in 110..140 { + assert_eq!(alpha(&fe, 100, y), 0, "red above the gap, y {y}"); + } + for y in 140..176 { + assert_eq!(alpha(&fe, 100, y), 255, "the gap and the red below it, y {y}"); + } +} + +#[test] +fn contiguous_and_discontiguous_keep_their_results() { + // This item changes only Find Edges: the other two limits still give the measured row. + for limits in [Limits::Contiguous, Limits::Discontiguous] { + let s = click((190.0, 70.0), 40.0, limits); + for (i, want) in CONTIGUOUS.iter().enumerate() { + let x = 185 + i as i32; + assert!((alpha(&s, x, 70) - want).abs() <= 8, "{limits:?} x {x}: {} against {want}", alpha(&s, x, 70)); + } + } +} From 2341642056e63f2ded1b42ee20ab554249e57a84 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 20:38:18 -0500 Subject: [PATCH 42/77] Open as Smart Object: import on a worker and complete through File > Open's colour policy and prompts (#11678) The command is a job (inline under execute, a worker under start) that returns File > Open's result shape; the UI tracks it as an OpenTab so finish_open gives it the profile prompt (held, admitted with the smart object label) and after_open gives a raw the Camera Raw open dialog and the import warnings. Import options and the Color Settings policy match File > Open. The paths form is removed: the UI calls the command once per file. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/file_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/open.rs | 2 +- .../src/cmd/file_cmds/open_as_smart_object.rs | 83 ++++++++++--------- .../file_cmds/open_as_smart_object_tests.rs | 76 ++++++++++++++++- crates/ui-egui/src/app/files.rs | 1 + crates/ui-egui/src/dialogs/open_prompt.rs | 7 +- crates/ui-egui/src/file_dialog.rs | 12 ++- crates/ui-egui/src/file_open.rs | 4 +- crates/ui-egui/src/file_open/tests.rs | 24 +++++- crates/ui-egui/src/jobs_ui.rs | 39 +++++++-- crates/ui-egui/src/psd_open_tests.rs | 2 + 11 files changed, 196 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index d2cdf92d32..b1eed032dd 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -293,7 +293,7 @@ pub fn specs() -> Vec { "Open as Smart Object…", &["File"], None, - r##"{"path":str (or "paths":[str], one document each)} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, name, layer} ({documents:[…]} for paths)"##, + r##"{"path":str} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color} (or {held, name, warnings, color, smartObject} while the profile prompt waits)"##, native, open_as_smart_object ), diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 6121180e22..71b7cfcefb 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -74,7 +74,7 @@ fn import_result(name: &str, bytes: &[u8]) -> Result Result { +pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { crate::allocation::checkpoint("importing document")?; let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index f365170f34..b32b6bfbcb 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -5,9 +5,9 @@ //! size and position `0, 0`) and put into the document before it is admitted, so the opening //! snapshot already shows it. -use super::open::import_opened; +use super::open::import_result_with; use super::paths::{file_name, read_file, stem, untitled_name}; -use super::{Affine, Arc, Document, EngineError, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; +use super::{Affine, Arc, Document, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; use crate::layer_place::Place; const CMD: &str = "file.openAsSmartObject"; @@ -15,42 +15,40 @@ const CMD: &str = "file.openAsSmartObject"; /// The history step's name. const LABEL: &str = "Open As Smart Object"; -fn bad(msg: impl Into) -> EngineError { - EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } -} - -/// The files to open: `paths` (several selected files) or `path` (one); whether it was one. -fn paths_param(p: &Value) -> Result<(Vec, bool)> { - match p.get("paths") { - None | Some(Value::Null) => Ok((vec![str_param(p, "path", CMD)?.to_string()], true)), - Some(Value::Array(list)) if p.get("path").is_none_or(Value::is_null) => { - let paths: Option> = list.iter().map(|v| v.as_str().filter(|v| !v.is_empty()).map(str::to_string)).collect(); - match paths { - Some(paths) if !paths.is_empty() => Ok((paths, false)), - _ => Err(bad("\"paths\" must be a non-empty list of file paths")), - } - } - Some(Value::Array(_)) => Err(bad("give \"path\" or \"paths\", not both")), - Some(_) => Err(bad("\"paths\" must be a list of file paths")), - } -} - -/// Opens each file as its own document, in order. A file that cannot be read stops the command -/// with an error; the documents opened before it stay open. +/// Opens the file as a document holding one embedded smart object. The read, decode and flatten +/// run on the job's worker (inline under `execute`); the document is built, given File › Open's +/// colour policy and admitted, or held for its profile prompt, when the job is applied. The reply +/// has the shape of File › Open's job: `{document, docId, name, layer, warnings, color}`, or +/// `{held, name, warnings, color, smartObject}` while the profile prompt waits. pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result { - let (paths, single) = paths_param(p)?; - let mut opened = Vec::new(); - for path in &paths { - opened.push(open_one(s, path)?); - } - Ok(if single { opened.swap_remove(0) } else { json!({"documents": opened}) }) + let path = str_param(p, "path", CMD)?.to_string(); + let name = file_name(&path); + let opts = crate::jobs::import_options(s.prefs()); + crate::jobs::run( + s, + &format!("Opening {name}"), + false, + move |ctx| { + ctx.progress(0.0, "Reading"); + let bytes = read_file(&path)?; + ctx.check()?; + ctx.progress(0.05, "Decoding"); + let r = import_result_with(&name, &bytes, &opts)?; + ctx.check()?; + ctx.progress(0.9, "Flattening"); + // The file as one image: the smart object's rendered contents. + let pixels = flattened_checked(&r.document, r.document.pixel_format(), CMD)?; + Ok((path, name, Arc::new(bytes), r, pixels)) + }, + |s, (path, name, bytes, r, pixels)| apply(s, &path, &name, bytes, r, pixels), + ) } -fn open_one(s: &mut Session, path: &str) -> Result { - let bytes = read_file(path)?; - let src = import_opened(s, path, &bytes)?; - // The file as one image: the smart object's rendered contents. - let pixels = flattened_checked(&src, src.pixel_format(), CMD)?; +fn apply(s: &mut Session, path: &str, file: &str, bytes: Arc>, r: photocraft_io::ImportResult, pixels: photocraft_raster::Surface) -> Result { + let warnings = r.warnings; + let mut src = r.document; + super::tag_exr_colorspace(s, file, &mut src); + super::raw_defaults(s, &warnings, &mut src); let layer_name = stem(path); // "-1", the next free number while a document with that name is open. let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); @@ -62,13 +60,24 @@ fn open_one(s: &mut Session, path: &str) -> Result { doc.duotone = src.duotone.clone(); // Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it. - let source = SmartSource::Embedded { file_name: file_name(path), bytes: Arc::new(bytes) }; + let source = SmartSource::Embedded { file_name: file.to_string(), bytes }; let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels)); let layer = Layer::new(layer_name, LayerContent::Smart(so)); let layer_id = layer.id; crate::layer_place::insert_new(&mut doc, None, layer, Place::AutoEditable); + + // File › Open's completion: a raw has no embedded profile to ask about; otherwise the profile + // prompt holds the document, and without a question the Color Settings policy is applied. + let raw = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + let question = s.color.open_question(&doc).filter(|_| !raw); + if question.is_some() { + let held = s.hold_open(doc, false); + return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "smartObject": true})); + } + let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag); let index = admit_as_smart_object(s, doc); - Ok(json!({"document": index, "name": name, "layer": layer_id.0})) + let id = s.documents().get(index).map(|st| st.doc.id.0); + Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color})) } /// Admits a document built by Open as Smart Object (one smart object layer) as an untitled diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index b6dce7b1e5..2117d8e5e1 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -101,6 +101,8 @@ fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() { let dir = tmp("psd"); let path = write_psd16(&dir, "c.layered.psd", 40, 30); let mut s = Session::new(); + // The files were measured in the working spaces, where nothing asks; Preserve keeps Display P3. + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); let file = imported(&path); assert_eq!(file.resolution_dpi, 300.0); @@ -175,8 +177,9 @@ fn several_files_open_one_document_each() { let a = write_png(&dir, "a_test.png", 8, 8); let b = write_psd16(&dir, "d_other.psd", 12, 10); let mut s = Session::new(); - let r = s.execute("file.openAsSmartObject", json!({"paths": [a, b]})).unwrap(); - assert_eq!(r["documents"].as_array().map(Vec::len), Some(2), "{r}"); + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + s.execute("file.openAsSmartObject", json!({"path": a})).unwrap(); + s.execute("file.openAsSmartObject", json!({"path": b})).unwrap(); assert_eq!(names(&s), ["a_test-1", "d_other-1"]); for st in s.documents() { assert_eq!(st.doc.layers.len(), 1); @@ -202,9 +205,76 @@ fn a_missing_or_unreadable_file_is_an_error_not_a_crash() { #[test] fn bad_params_are_errors() { let mut s = Session::new(); - for params in [json!({}), json!({"path": 7}), json!({"path": null}), json!({"paths": "a.png"}), json!({"paths": []}), json!({"paths": [1]})] { + for params in [json!({}), json!({"path": 7}), json!({"path": null})] { let r = s.execute("file.openAsSmartObject", params.clone()); assert!(r.is_err(), "{params}: {r:?}"); } assert!(s.documents().is_empty()); } + +fn job(r: crate::jobs::Started) -> crate::jobs::JobId { + match r { + crate::jobs::Started::Job(id) => id, + crate::jobs::Started::Done(v) => panic!("expected a background job, ran inline: {v}"), + } +} + +/// Poll until job `id` ends (or 60 s pass); returns its event. +fn wait_event(s: &mut Session, id: crate::jobs::JobId) -> crate::jobs::JobEvent { + let t = std::time::Instant::now(); + loop { + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + return e; + } + assert!(t.elapsed() < std::time::Duration::from_secs(60), "job {id:?} did not finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +#[test] +fn the_import_runs_as_a_job_when_started() { + let dir = tmp("job"); + let path = write_png(&dir, "a_test.png", 32, 24); + let mut s = Session::new(); + let id = job(s.start("file.openAsSmartObject", json!({"path": path})).unwrap()); + assert!(s.documents().is_empty(), "nothing is admitted before the job is applied"); + let e = wait_event(&mut s, id); + let crate::jobs::JobOutcome::Done(v) = &e.outcome else { panic!("{e:?}") }; + assert!(v["docId"].is_u64(), "{v}"); + assert_eq!(v["name"], "a_test-1", "{v}"); + assert_eq!(s.documents().len(), 1); + embedded(&s); +} + +#[test] +fn the_colour_policy_matches_file_open() { + let dir = tmp("policy"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let settings = json!({"askOnMismatch": false, "policyRgb": "convert"}); + let mut open = Session::new(); + open.execute("edit.colorSettings", settings.clone()).unwrap(); + open.execute("file.openAs", json!({"path": path})).unwrap(); + let mut so = Session::new(); + so.execute("edit.colorSettings", settings).unwrap(); + so.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let (a, b) = (active(&open), active(&so)); + assert_eq!(a.icc_profile, b.icc_profile, "Convert is applied as File > Open applies it"); + assert_eq!((a.mode, a.depth, a.size), (b.mode, b.depth, b.size)); + assert_eq!(a.resolution_dpi, b.resolution_dpi); +} + +#[test] +fn ask_when_opening_holds_the_document_like_file_open() { + let dir = tmp("ask"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["color"]["ask"], true, "{r}"); + assert_eq!(r["smartObject"], true, "{r}"); + assert!(r["held"].is_u64(), "{r}"); + assert!(s.documents().is_empty(), "held for the profile prompt, not admitted"); + let (doc, _) = s.take_held_open(r["held"].as_u64().unwrap()).unwrap(); + assert_eq!(doc.name, "c.layered-1"); + assert_eq!(doc.layers.len(), 1); + assert!(matches!(doc.layers[0].content, LayerContent::Smart(_))); +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 26ac96f722..8264895665 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -137,6 +137,7 @@ impl PhotocraftApp { order, question, policy_applied: false, + smart_object: false, }, ); Ok(warnings) diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 78691112c0..145ae4ce84 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -43,6 +43,9 @@ pub struct Held { /// The Color Settings policy was already applied (a background open added it before it had /// to wait); otherwise opening applies it. pub policy_applied: bool, + /// Held by Open as Smart Object: the document holds the smart object layer already, and is + /// admitted by [`photocraft_engine::file_cmds::admit_as_smart_object`]. + pub smart_object: bool, } /// Dialog field for the high-memory confirmation prompt. @@ -323,7 +326,7 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result) -> Result Result { let initial_dir = last_used_dir(&self.ui.recent_files); self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, |app, answer| { - let FileDialogAnswer::Paths(mut paths) = answer else { return Err(UNEXPECTED.into()) }; + let FileDialogAnswer::Paths(paths) = answer else { return Err(UNEXPECTED.into()) }; + // Each file is a tracked open job like File › Open's, in name order. No `show_if_open`: + // the file opens as another document even when it is already open. + let mut paths: Vec = paths.iter().map(|p| crate::file_open::absolute(p)).collect(); paths.sort_by(|a, b| crate::file_open::natural_cmp(a, b)); - for path in &paths { - if let Err(e) = crate::jobs_ui::run(app, "file.openAsSmartObject", json!({"path": path})) { + let batch = crate::jobs_ui::begin_batch(app); + for (seq, path) in paths.iter().enumerate() { + let order = crate::jobs_ui::OpenOrder { batch, seq }; + if let Err(e) = crate::jobs_ui::start_open_as_smart_object(app, path, Some(order)) { app.open_failed(&crate::file_open::display_name(path), &e); } } + crate::jobs_ui::end_batch(app); app.sync_views(); Ok(Value::Null) }) diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 865f65d7ef..8262f6ae9a 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -594,12 +594,12 @@ fn digit_run(it: &mut std::iter::Peekable) -> String { } #[cfg(not(target_arch = "wasm32"))] -fn absolute(p: &str) -> String { +pub(crate) fn absolute(p: &str) -> String { std::path::absolute(p).map(|a| a.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string()) } #[cfg(target_arch = "wasm32")] -fn absolute(p: &str) -> String { +pub(crate) fn absolute(p: &str) -> String { p.to_string() } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 3fdf448099..38e07dfa85 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -1709,6 +1709,28 @@ fn open_as_smart_object_dialog_opens_one_document_per_selected_file() { assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); - assert!(app.ui.status_error, "the unreadable file is reported"); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File > Open as Smart Object... runs each file as a tracked background job like File > Open +/// does: a tab per file, the documents landing in name order, untitled, failures reported. +#[test] +fn open_as_smart_object_opens_in_the_background_in_name_order() { + let ctx = egui::Context::default(); + let (dir, paths) = files("open-as-so-bg", &["two.png", "one.png", "bad.png"], &png_bytes(8, 6)); + std::fs::write(&paths[2], b"not an image").unwrap(); + let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]); + app.background_jobs = true; + + menus::invoke(&mut app, &ctx, "file.openAsSmartObject", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.jobs.opens.len(), 3, "a tab for each file while it opens"); + assert!(app.session.documents().is_empty(), "nothing is on the UI thread's critical path"); + + tick_until(&mut app, &ctx, |app| app.jobs.opens.is_empty()); + assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); + assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index c4ff5397c2..3900826166 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -286,11 +286,37 @@ pub fn start_open( // once. A preflight keeps the slot and set order in its memory prompt. Started::Done(v) => finish_open(app, name, path.as_deref(), &v, slot, order), Started::Job(job) => { - app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot, order }); - app.jobs.focus = Some(job); - app.ui.chrome.home = None; - app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", name)]); - app.ui.status_error = false; + track_open(app, OpenTab { job, name: name.to_string(), path, slot, order }); + Ok(()) + } + } +} + +/// A file opening in the background gets its tab: focused, with the status line naming it. +fn track_open(app: &mut PhotocraftApp, tab: OpenTab) { + app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &tab.name)]); + app.jobs.focus = Some(tab.job); + app.jobs.opens.push(tab); + app.ui.chrome.home = None; + app.ui.status_error = false; +} + +/// File › Open as Smart Object… for one file: `file.openAsSmartObject` as a tracked open job, so +/// the result goes through File › Open's completion ([`finish_open`]: the profile prompt, the +/// Camera Raw open dialog for a raw, the import warnings). The document is untitled, so +/// Open Recent is untouched. Inline (background jobs off, or the web) it finishes at once. +pub(crate) fn start_open_as_smart_object(app: &mut PhotocraftApp, path: &str, order: Option) -> Result<(), String> { + let name = crate::file_open::display_name(path); + let params = json!({"path": path}); + let started = if app.background_jobs { + app.session.start("file.openAsSmartObject", params).map_err(|e| e.to_string())? + } else { + Started::Done(app.session.execute("file.openAsSmartObject", params).map_err(|e| e.to_string())?) + }; + match started { + Started::Done(v) => finish_open(app, &name, None, &v, None, order), + Started::Job(job) => { + track_open(app, OpenTab { job, name, path: None, slot: None, order }); Ok(()) } } @@ -608,7 +634,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { finish_save(app, ctx, save, &e.label, e.outcome); return; } - if e.command == OPEN_JOB { + if e.command == OPEN_JOB || app.jobs.opens.iter().any(|o| o.job == e.id) { let Some(i) = app.jobs.opens.iter().position(|o| o.job == e.id) else { return }; let tab = app.jobs.opens.remove(i); match e.outcome { @@ -703,6 +729,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu order, question, policy_applied, + smart_object: v.get("smartObject").and_then(Value::as_bool).unwrap_or(false), }; // Held back for the profile prompt (Ask When Opening). if let Some(id) = v.get("held").and_then(Value::as_u64) { diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 02c18313f5..eff12bedbe 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -569,6 +569,7 @@ fn profile_cancel_cancels_whole_queue_including_memory_prompts() { order: None, question: Some(json!({"missing": false, "working": "sRGB", "embedded": "Adobe RGB"})), policy_applied: false, + smart_object: false, }, ); @@ -600,6 +601,7 @@ fn mixed_prompts_follow_arrival_order_fifo() { order: None, question: None, policy_applied: false, + smart_object: false, }, ); From 6d3fe6f96f4ee9bb670bf3903a44fc22de0c64d2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:51:01 +0100 Subject: [PATCH 43/77] Background Eraser: partly erased pixels lose the sampled colour's tint (#11902) A dab that erases a pixel only partly now also removes the sampled colour's share from it: the kept colour becomes (c - sampled x (1 - keep)) / keep per channel, clipped to the channel range, so composited over the sampled colour it gives back the original. Later dabs, in the same stroke and in later strokes, match the pixel's current, decontaminated colour; sampling and the Contiguous fill still read the pixels before the stroke. With the graded alpha of #11901 this gives the reference application's drag results. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 57 ++++- .../src/bg_erase_decontamination_tests.rs | 212 ++++++++++++++++++ 2 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 crates/paint/src/bg_erase_decontamination_tests.rs diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 2e8da047fc..1cbe4827dd 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -4,6 +4,7 @@ //! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth), //! so nothing is converted through RGB; only alpha changes. +use photocraft_color::SampleType; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba}; use serde::{Deserialize, Serialize}; @@ -96,6 +97,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option>> = None; // Find Edges grades soft edges like Contiguous does instead of cutting them hard (#11904): // the reference application keeps at least as much of a blurred transition with it. @@ -131,27 +133,40 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option 0.0 && !protected(&c[..nc]) { + weight[i] = match_w(dist(&c[..nc], &sampled)); } - weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled)); } } if limits != Limits::Discontiguous { - keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); + keep_connected(&mut reach, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); + for (wv, rv) in weight.iter_mut().zip(&reach) { + if *rv <= 0.0 { + *wv = 0.0; + } + } } for y in r.y0..r.y1 { for x in r.x0..r.x1 { @@ -159,16 +174,32 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option [f32; 4] { + [200.0 / 255.0, (40.0 + 20.0 * i as f32) / 255.0, 40.0 / 255.0, 1.0] +} + +/// Eight 100 px blocks side by side, 900 x 300; the reference's drag row y 450 is y 150 here. +fn blocks(fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); + for i in 0..8 { + let x = 100 * i as i32; + s.fill_rect(Rect::new(x, 0, x + 100, 300), &from_rgba(&fmt, block(i))); + } + s +} + +fn stroke(points: &[(f64, f64)]) -> Stroke { + Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: points.iter().map(|&(x, y)| StrokePoint::new(x, y, 1.0)).collect(), + } +} + +fn once(tolerance_percent: f32) -> BgEraseSettings { + BgEraseSettings { sampling: Sampling::Once, limits: Limits::Discontiguous, tolerance: tolerance_percent / 100.0, ..Default::default() } +} + +fn swatch(tolerance_percent: f32) -> BgEraseSettings { + BgEraseSettings { sampling: Sampling::BackgroundSwatch, background: block(0), ..once(tolerance_percent) } +} + +/// RGBA 0-255 at block `i`'s centre. +fn rgba(s: &Surface, i: usize) -> [i32; 4] { + let p = s.rgba(100 * i as i32 + 50, 150); + p.map(|v| (v * 255.0).round() as i32) +} + +/// The reference's drag from block 0's centre to `to_x`, Sampling Once. +fn drag(fmt: PixelFormat, tolerance_percent: f32, to_x: f64) -> Surface { + let mut s = blocks(fmt); + apply_background_eraser(&mut s, &stroke(&[(50.0, 150.0), (to_x, 150.0)]), &once(tolerance_percent), None); + s +} + +/// Separate single-click strokes on every block centre, `clicks` times, Background Swatch. +fn clicks(fmt: PixelFormat, tolerance_percent: f32, clicks: usize) -> Surface { + let mut s = blocks(fmt); + for _ in 0..clicks { + for i in 0..8 { + apply_background_eraser(&mut s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &swatch(tolerance_percent), None); + } + } + s +} + +fn assert_alpha_row(s: &Surface, want: &[i32], what: &str) { + let got: Vec = (0..want.len()).map(|i| rgba(s, i)[3]).collect(); + for (i, (g, w)) in got.iter().zip(want).enumerate() { + assert!((g - w).abs() <= 8, "{what}: block {i} alpha {g}, measured {w} (row {got:?})"); + } +} + +#[test] +fn the_eight_block_drag_gives_the_measured_rgba() { + const WANT: [[i32; 4]; 8] = [ + [200, 40, 40, 0], + [200, 216, 40, 29], + [200, 184, 40, 71], + [200, 175, 40, 113], + [200, 172, 40, 155], + [200, 170, 40, 196], + [200, 169, 40, 238], + [200, 180, 40, 255], + ]; + let s = drag(PixelFormat::RGBA8, 50.0, 750.0); + for (i, want) in WANT.iter().enumerate() { + let got = rgba(&s, i); + // Block 0 is fully erased: only its alpha is defined. + let channels = if want[3] == 0 { 3..4 } else { 0..4 }; + for c in channels { + assert!((got[c] - want[c]).abs() <= 8, "block {i}: got {got:?}, measured {want:?}"); + } + } +} + +#[test] +fn the_drag_table_of_11901_holds_with_decontamination() { + let rows: [(f32, [i32; 8]); 5] = [ + (10.0, [0, 194, 255, 255, 255, 255, 255, 255]), + (20.0, [0, 90, 197, 255, 255, 255, 255, 255]), + (30.0, [0, 56, 126, 196, 255, 255, 255, 255]), + (50.0, [0, 30, 75, 114, 156, 198, 238, 255]), + (70.0, [0, 18, 48, 78, 107, 137, 167, 197]), + ]; + for (tol, want) in rows { + assert_alpha_row(&drag(PixelFormat::RGBA8, tol, 750.0), &want, &format!("drag at Tolerance {tol}")); + } + // At Tolerance 100 the decontaminated colours stay inside the Tolerance, so every dab erases + // a bit more; the drag on to 790 is the measured row (block 7 is no reference at 750). + assert_alpha_row(&drag(PixelFormat::RGBA8, 100.0, 790.0), &[0, 7, 19, 32, 44, 56, 68, 81], "drag at Tolerance 100"); +} + +#[test] +fn later_strokes_match_the_decontaminated_colour() { + let fmt = PixelFormat::RGBA8; + assert_alpha_row(&clicks(fmt, 100.0, 1), &[0, 12, 33, 54, 74, 95, 116, 136], "Tolerance 100, 1 click"); + assert_alpha_row(&clicks(fmt, 100.0, 2), &[0, 10, 28, 45, 62, 80, 97, 114], "Tolerance 100, 2 clicks"); + assert_alpha_row(&clicks(fmt, 100.0, 3), &[0, 8, 23, 38, 52, 67, 81, 96], "Tolerance 100, 3 clicks"); + // At Tolerance 50 the decontaminated colours are outside the Tolerance: a second click leaves + // the block centres as they were. + let one = clicks(fmt, 50.0, 1); + assert_alpha_row(&one, &[0, 29, 71, 113, 155, 196, 238, 255], "Tolerance 50, 1 click"); + let two = clicks(fmt, 50.0, 2); + for i in 1..8 { + assert_eq!(rgba(&two, i), rgba(&one, i), "Tolerance 50, 2 clicks: block {i}"); + } +} + +#[test] +fn partly_erased_pixels_composite_back_to_their_original_colour() { + // Composited over the sampled colour, each partly erased block centre gives back its original + // colour within 2 levels, after one click and after the drag. + for s in [clicks(PixelFormat::RGBA8, 50.0, 1), drag(PixelFormat::RGBA8, 50.0, 750.0)] { + for i in 1..7 { + let [r, g, b, a] = rgba(&s, i); + assert!(a > 0 && a < 255, "block {i} partly erased, alpha {a}"); + let over = |c: i32, s: i32| (c * a + s * (255 - a)) as f32 / 255.0; + let orig = block(i).map(|v| v * 255.0); + for (k, (got, want)) in [over(r, 200), over(g, 40), over(b, 40)].iter().zip(orig).enumerate() { + assert!((got - want).abs() <= 2.0, "block {i} channel {k}: composite {got}, original {want}"); + } + } + } +} + +#[test] +fn kept_and_erased_pixels_and_the_channel_range() { + let s = drag(PixelFormat::RGBA8, 50.0, 750.0); + // Fully kept: the colour exactly; outside the brush: untouched. + assert_eq!(rgba(&s, 7), [200, 180, 40, 255]); + assert_eq!(s.rgba(350, 10), blocks(PixelFormat::RGBA8).rgba(350, 10)); + // Fully erased: alpha 0. + assert_eq!(rgba(&s, 0)[3], 0); + // At Tolerance 100 the decontaminated green clips at 255 rather than wrapping, and 32-bit + // float results stay finite and within 0..1 for in-range colours. + let t = clicks(PixelFormat::RGBA8, 100.0, 1); + assert_eq!(rgba(&t, 7)[1], 255, "clipped, not wrapped"); + let f = clicks(PixelFormat::RGBA32F, 100.0, 3); + for x in (0..900).step_by(7) { + for v in f.rgba(x, 150) { + assert!(v.is_finite() && (0.0..=1.0).contains(&v), "x {x}: {v}"); + } + } +} + +#[test] +fn it_works_in_the_documents_own_channels() { + // 16-bit RGB: the same RGBA as 8-bit. + let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true); + let a = drag(PixelFormat::RGBA8, 50.0, 750.0); + let b = drag(rgb16, 50.0, 750.0); + for i in 0..8 { + let (x, y) = (rgba(&a, i), rgba(&b, i)); + for c in 0..4 { + assert!((x[c] - y[c]).abs() <= 2 || x[3] == 0, "16-bit block {i}: {y:?} against 8-bit {x:?}"); + } + } + // Gray (8 and 16 bit): the gray level is decontaminated the same way, so the composite over + // the sampled gray gives back the original level, and a second click at Tolerance 50 changes + // nothing at the block centres. + for sample in [SampleType::U8, SampleType::U16] { + let gray = PixelFormat::new(ColorMode::Grayscale, sample, true); + let level = |i: usize| (40.0 + 20.0 * i as f32) / 255.0; + let mut s = Surface::new(gray); + for i in 0..8 { + let x = 100 * i as i32; + s.fill_rect(Rect::new(x, 0, x + 100, 300), &[level(i), 1.0]); + } + let bs = BgEraseSettings { background: [level(0), level(0), level(0), 1.0], ..swatch(50.0) }; + let click_all = |s: &mut Surface| { + for i in 0..8 { + apply_background_eraser(s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &bs, None); + } + }; + click_all(&mut s); + let first = s.clone(); + for i in 1..7 { + let p = s.pixel(100 * i as i32 + 50, 150); + let (v, a) = (p[0], p[1]); + assert!(a > 0.0 && a < 1.0, "{sample:?} block {i} partly erased, alpha {a}"); + let over = v * a + level(0) * (1.0 - a); + assert!((over - level(i)).abs() <= 2.0 / 255.0, "{sample:?} block {i}: composite {over}, original {}", level(i)); + } + click_all(&mut s); + for i in 1..8 { + let at = (100 * i + 50, 150); + assert_eq!(s.pixel(at.0, at.1), first.pixel(at.0, at.1), "{sample:?} block {i}: a second click at Tolerance 50 changes nothing"); + } + } +} From 7a1cd03a5801b5e29da8056733125a9d17641c0c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:56:31 +0100 Subject: [PATCH 44/77] Brush Settings: name the Spacing slider for accessibility (#5224) The standalone Spacing track had no accessible name or slider role, unlike the slider_row it replaced. It is now a slider named "Spacing" with the current percentage, as CodeRabbit noted. Co-Authored-By: augrclk Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/brush_sections.rs | 9 ++++++++- crates/ui-egui/src/panels/brush_sections/tests.rs | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index 69a08182ad..44440a32bd 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -67,7 +67,14 @@ fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { .changed(); }); }); - changed |= ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::slider(ui, &mut percent, 1.0..=1000.0, None)).inner.changed(); + changed |= ui + .add_enabled_ui(b.spacing_enabled, |ui| { + let s = widgets::slider(ui, &mut percent, 1.0..=1000.0, None); + s.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Spacing"))); + s + }) + .inner + .changed(); if changed { b.spacing = (percent.round() / 100.0).clamp(0.01, 10.0); } diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index 94e9c85f4e..9fdaf985f0 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -354,6 +354,9 @@ fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); assert_eq!(field.accesskit_node().is_disabled(), !enabled); + let slider = h.get_by_role_and_label(Role::Slider, "Spacing"); + assert_eq!(slider.accesskit_node().numeric_value(), Some(43.0), "{kind:?}: the track is a named slider"); + assert_eq!(slider.accesskit_node().is_disabled(), !enabled); assert_eq!(h.state(), &b, "rendering does not round the saved spacing"); } } From 022cf4f125d1b22ec3e7679ce2ffc38980c11e13 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 21:59:52 -0500 Subject: [PATCH 45/77] Open as Smart Object: keep the damage flag, the Smart Object flag and unique names CodeRabbit round 2 on #12212: - 4504550937 (b): a PSD that recovered from damaged data is held for File > Open's confirmation (the app passes confirmDamage; headless callers open it as before). - 4504550942: a document held for its prompt counts when numbering, and the number is re-taken when each document is admitted, so two held files with one stem no longer share a name. - 4504550943 is not changed here (needs colour transforms at regeneration), as are 4504550937 (a), (c) and 4504550953. - 4504550946: the successful reply carries smartObject, so a document queued behind another open prompt is admitted with the Open As Smart Object history label. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/file_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/open.rs | 10 +++- .../src/cmd/file_cmds/open_as_smart_object.rs | 37 ++++++++---- .../file_cmds/open_as_smart_object_tests.rs | 56 +++++++++++++++++++ crates/engine/src/jobs.rs | 2 +- crates/engine/src/session/documents.rs | 5 ++ crates/ui-egui/src/jobs_ui.rs | 3 +- 7 files changed, 100 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index b1eed032dd..b1ec4df7d6 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -293,7 +293,7 @@ pub fn specs() -> Vec { "Open as Smart Object…", &["File"], None, - r##"{"path":str} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color} (or {held, name, warnings, color, smartObject} while the profile prompt waits)"##, + r##"{"path":str,"confirmDamage":bool=false (a damaged PSD waits for an answer, as File > Open's does)} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color, smartObject} (or {held, name, warnings, color, damaged, smartObject} while a prompt waits)"##, native, open_as_smart_object ), diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 71b7cfcefb..282eae0e4e 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -75,8 +75,14 @@ fn import_result(name: &str, bytes: &[u8]) -> Result Result { + import_result_for_open(name, bytes, opts).map(|(r, _)| r) +} + +/// [`import_result_with`], also telling whether a PSD/PSB had damaged data it recovered from, as +/// File › Open's job learns it (`photocraft_io::import_for_open`), so an interactive caller can ask. +pub(super) fn import_result_for_open(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<(photocraft_io::ImportResult, bool)> { crate::allocation::checkpoint("importing document")?; - let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; + let (r, damaged) = photocraft_io::import_for_open(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. // Open has its own warning-preserving path; never silently place or process a thumbnail. if r.preview_only { @@ -84,7 +90,7 @@ pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io: "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" ))); } - Ok(r) + Ok((r, damaged)) } // ---------- close / revert / save a copy / open as ---------- diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index b32b6bfbcb..517252b25a 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -5,7 +5,7 @@ //! size and position `0, 0`) and put into the document before it is admitted, so the opening //! snapshot already shows it. -use super::open::import_result_with; +use super::open::import_result_for_open; use super::paths::{file_name, read_file, stem, untitled_name}; use super::{Affine, Arc, Document, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; use crate::layer_place::Place; @@ -24,6 +24,8 @@ pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result let path = str_param(p, "path", CMD)?.to_string(); let name = file_name(&path); let opts = crate::jobs::import_options(s.prefs()); + // An interactive caller (the app) asks before opening a PSD that had damaged data, as File > Open does. + let confirm_damage = p.get("confirmDamage").and_then(Value::as_bool).unwrap_or(false); crate::jobs::run( s, &format!("Opening {name}"), @@ -33,25 +35,33 @@ pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result let bytes = read_file(&path)?; ctx.check()?; ctx.progress(0.05, "Decoding"); - let r = import_result_with(&name, &bytes, &opts)?; + let (r, damaged) = import_result_for_open(&name, &bytes, &opts)?; ctx.check()?; ctx.progress(0.9, "Flattening"); // The file as one image: the smart object's rendered contents. let pixels = flattened_checked(&r.document, r.document.pixel_format(), CMD)?; - Ok((path, name, Arc::new(bytes), r, pixels)) + Ok((path, name, Arc::new(bytes), r, pixels, confirm_damage && damaged)) }, - |s, (path, name, bytes, r, pixels)| apply(s, &path, &name, bytes, r, pixels), + |s, (path, name, bytes, r, pixels, damaged)| apply(s, &path, &name, bytes, r, pixels, damaged), ) } -fn apply(s: &mut Session, path: &str, file: &str, bytes: Arc>, r: photocraft_io::ImportResult, pixels: photocraft_raster::Surface) -> Result { +fn apply( + s: &mut Session, + path: &str, + file: &str, + bytes: Arc>, + r: photocraft_io::ImportResult, + pixels: photocraft_raster::Surface, + damaged: bool, +) -> Result { let warnings = r.warnings; let mut src = r.document; super::tag_exr_colorspace(s, file, &mut src); super::raw_defaults(s, &warnings, &mut src); let layer_name = stem(path); // "-1", the next free number while a document with that name is open. - let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); + let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n) || s.held_open_named(n)); let mut doc = Document::new(name.clone(), src.size, src.mode, src.depth); doc.resolution_dpi = src.resolution_dpi; @@ -70,21 +80,28 @@ fn apply(s: &mut Session, path: &str, file: &str, bytes: Arc>, r: photoc // prompt holds the document, and without a question the Color Settings policy is applied. let raw = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); let question = s.color.open_question(&doc).filter(|_| !raw); - if question.is_some() { + if damaged || question.is_some() { let held = s.hold_open(doc, false); - return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "smartObject": true})); + return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "damaged": damaged, "smartObject": true})); } let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag); let index = admit_as_smart_object(s, doc); let id = s.documents().get(index).map(|st| st.doc.id.0); - Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color})) + Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color, "smartObject": true})) } /// Admits a document built by Open as Smart Object (one smart object layer) as an untitled /// document: the opening snapshot already shows the placed layer, is named after it, and the /// history is the single state "Open As Smart Object", as File › Open's is one state. -pub fn admit_as_smart_object(s: &mut Session, doc: Document) -> usize { +pub fn admit_as_smart_object(s: &mut Session, mut doc: Document) -> usize { let layer_name = doc.layers.first().map(|l| l.name.clone()); + // The number was free when the file was read, but a document held for its prompt did not count + // then: take the next free one now, as each document is admitted. + if let Some(base) = &layer_name + && s.documents().iter().any(|d| d.doc.name == doc.name) + { + doc.name = untitled_name(base, |n| s.documents().iter().any(|d| d.doc.name == n)); + } // Untitled: no path, so Save asks where to save. let index = s.add_document(doc, None); if let Some(st) = s.active_mut() { diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 2117d8e5e1..230c75afe8 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -278,3 +278,59 @@ fn ask_when_opening_holds_the_document_like_file_open() { assert_eq!(doc.layers.len(), 1); assert!(matches!(doc.layers[0].content, LayerContent::Smart(_))); } + +#[test] +fn the_reply_flags_a_smart_object_so_a_queued_document_keeps_its_history_label() { + let dir = tmp("flag"); + let path = write_png(&dir, "a_test.png", 16, 12); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["smartObject"], true, "{r}"); +} + +#[test] +fn a_damaged_psd_is_held_for_confirmation_when_asked_and_opens_otherwise() { + let dir = tmp("damaged"); + let path = join(&dir, "d.psd"); + std::fs::write(&path, crate::jobs::psd_open_fixture::make_psd(true)).unwrap(); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + let r = s.execute("file.openAsSmartObject", json!({"path": path, "confirmDamage": true})).unwrap(); + assert_eq!(r["damaged"], true, "{r}"); + assert_eq!(r["smartObject"], true, "{r}"); + assert!(r["held"].is_u64(), "{r}"); + assert!(s.documents().is_empty(), "held for the damage prompt, not admitted"); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert!(r["docId"].is_u64(), "a headless caller opens the recovered content: {r}"); +} + +#[test] +fn a_document_held_for_its_prompt_does_not_share_its_name_with_the_next() { + let dir = tmp("held-name"); + let path = write_psd16(&dir, "a_test.psd", 20, 20); + let mut s = Session::new(); + let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(first["name"], "a_test-1"); + assert_eq!(second["name"], "a_test-2", "the held document's name is taken: {second}"); + // Both held docs leave the session for the app's queue; admitting them one by one numbers each. + let (a, _) = s.take_held_open(first["held"].as_u64().unwrap()).unwrap(); + let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap(); + admit_as_smart_object(&mut s, a); + admit_as_smart_object(&mut s, b); + assert_eq!(names(&s), ["a_test-1", "a_test-2"]); + let mut t = Session::new(); + let (c, _) = { + let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + t.take_held_open(r["held"].as_u64().unwrap()).unwrap() + }; + let (d, _) = { + t.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap(); + let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + t.take_held_open(r["held"].as_u64().unwrap()).unwrap() + }; + assert_eq!(c.name, d.name, "taken out of the session, both are named a_test-1 when read"); + admit_as_smart_object(&mut t, c); + admit_as_smart_object(&mut t, d); + assert_eq!(names(&t), ["a_test-1", "a_test-2"], "the second takes the next free number when admitted"); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 1390e89902..2504bcc3d9 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -2148,7 +2148,7 @@ mod tests; #[cfg(test)] #[path = "../../io/tests/support/recovery_psd.rs"] -mod psd_open_fixture; +pub(crate) mod psd_open_fixture; #[cfg(test)] mod psd_open_confirmation_tests { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 49f8205079..b5b82a4753 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -193,6 +193,11 @@ impl Session { self.next_held_open } + /// Whether a document held back for its prompt already has `name`. + pub(crate) fn held_open_named(&self, name: &str) -> bool { + self.held_opens.iter().any(|(_, d, _)| d.name == name) + } + /// Takes a document a background open held back for the profile prompt: the document and /// whether its source is read-only. `None` when there is no such document (already taken). pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> { diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 3900826166..7459f54be6 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -307,7 +307,8 @@ fn track_open(app: &mut PhotocraftApp, tab: OpenTab) { /// Open Recent is untouched. Inline (background jobs off, or the web) it finishes at once. pub(crate) fn start_open_as_smart_object(app: &mut PhotocraftApp, path: &str, order: Option) -> Result<(), String> { let name = crate::file_open::display_name(path); - let params = json!({"path": path}); + // `confirmDamage`: a damaged PSD waits for the user's answer, as in File › Open. + let params = json!({"path": path, "confirmDamage": true}); let started = if app.background_jobs { app.session.start("file.openAsSmartObject", params).map_err(|e| e.to_string())? } else { From 80a254050b6799bc66be900bbf6e3f644448ab5c Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:11:31 +0100 Subject: [PATCH 46/77] PDF: editable import step 1 - paths as shape layers Add an experimental, opt-in PDF path importer on craft-pdf's interpret Device. Fills and strokes become ShapeLayers in document pixels with their real colours, widths and dash patterns; text, images, clipping paths, transparency groups, patterns, soft masks and annotations keep the raster page with a warning, so a page is never half imported. - ImportOptions::pdf_editable (off by default) and --pdf-editable. - pdf-editable Cargo feature in photocraft-io; the CLI enables it. - Shape caches are rendered within a 256 MiB page budget. - Cancellation is forwarded through a 5 ms watcher; hostile pages are caught and fall back instead of panicking. Tests cover fill/stroke colours and dashes, composite parity with the raster page, text fallback and cancellation. --- Cargo.lock | 1 + apps/photocraft-cli/Cargo.toml | 2 +- apps/photocraft-cli/src/args.rs | 3 + apps/photocraft-cli/src/document.rs | 1 + crates/io/Cargo.toml | 6 + crates/io/src/lib.rs | 7 + crates/io/src/pdf_editable.rs | 514 ++++++++++++++++++++++++++++ crates/io/src/pdf_import.rs | 22 +- 8 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 crates/io/src/pdf_editable.rs diff --git a/Cargo.lock b/Cargo.lock index 434e4b6aa8..186be182eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5618,6 +5618,7 @@ dependencies = [ "crc32fast", "flate2", "imagesize", + "kurbo 0.13.1", "lopdf", "miniz_oxide 0.9.1", "photocraft-affinity", diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 4ddde07142..7640483f44 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -33,7 +33,7 @@ required-features = ["gpu"] photocraft-automation = { path = "../../crates/automation" } photocraft-doc = { workspace = true } photocraft-engine = { workspace = true } -photocraft-io = { workspace = true } +photocraft-io = { workspace = true, features = ["pdf-editable"] } photocraft-raster = { workspace = true } photocraft-format = { path = "../../crates/format" } photocraft-codecs = { workspace = true } diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index a5acb4557b..c450c94442 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -125,6 +125,9 @@ pub(crate) struct ConvertArgs { /// PDF pages to import, 1-based: "3" or "2-7" (default all) #[arg(long)] pub(crate) pdf_pages: Option, + /// Import PDF vector paths as editable shape layers (experimental; text still rasterises) + #[arg(long)] + pub(crate) pdf_editable: bool, #[command(flatten)] pub(crate) gpu: GpuArgs, #[command(flatten)] diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index ab9e3cfd35..5541da11a6 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -46,6 +46,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write psd_high_memory_approved: a.approve_high_memory, pdf_dpi: a.pdf_dpi.unwrap_or(300.0), pdf_pages: a.pdf_pages.as_deref().map(parse_pdf_pages).transpose()?, + pdf_editable: a.pdf_editable, ..Default::default() }; let mut o = files::open_with_options(input, &import_opts)?; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 36975e57eb..923d5da891 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -16,6 +16,9 @@ corpus = [] heif = ["photocraft-codecs/heif"] # Test support for synthetic memory injection, estimate charges and verification across crates. testgen = [] +# Editable PDF import (experimental): painted paths become shape layers. Text and the other +# unsupported constructs keep the raster page. +pdf-editable = ["dep:kurbo"] [dependencies] photocraft-geom = { workspace = true } @@ -53,6 +56,9 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste ttf-parser = { version = "0.25", default-features = false } # PDF import (pdf_import.rs): craft-pdf rasterizes pages in pure Rust; no shared library ships. craft-pdf = { workspace = true } +# Editable PDF import (pdf_editable.rs): craft-pdf's Device trait uses kurbo paths and transforms. +# This is the same kurbo 0.13 that craft-pdf already links; naming it adds no crate to the graph. +kurbo = { version = "0.13", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 195a58b01a..40dd1c0755 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -65,6 +65,8 @@ pub use ora::losses as ora_losses; pub mod pattern_map; mod pcraft_cache; pub mod pdf; +#[cfg(all(feature = "pdf-editable", not(target_arch = "wasm32")))] +mod pdf_editable; mod pdf_import; mod pdn; pub use pcraft_cache::PcraftExportCache; @@ -612,6 +614,10 @@ pub struct ImportOptions { pub pdf_dpi: f32, /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). pub pdf_pages: Option<(u32, u32)>, + /// Import PDF painted paths as editable [`photocraft_doc::ShapeLayer`]s instead of a page + /// raster (experimental). Text, images, clipping paths and the other unsupported constructs + /// make that page fall back to the raster importer with a warning. Off by default. + pub pdf_editable: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -628,6 +634,7 @@ impl Default for ImportOptions { flat_max_pixel_bytes: 0, pdf_dpi: 300.0, pdf_pages: None, + pdf_editable: false, psd_high_memory_approved: false, psd_approved_estimate: None, } diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs new file mode 100644 index 0000000000..1344bf0822 --- /dev/null +++ b/crates/io/src/pdf_editable.rs @@ -0,0 +1,514 @@ +//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers. +//! +//! The first step is deliberately narrow: solid fills and strokes become [`ShapeLayer`]s in +//! document pixels. Text, images, clipping paths, transparency groups, patterns, soft masks and +//! annotations mark the page unsupported, and the caller falls back to the raster importer for +//! that page with a warning. Nothing is ever half imported. +//! +//! This is the app-owned `Device` route: Hayro's interpreter walks the page and reports each +//! paint with its real colour and geometry, so shape layers need no page raster underneath. That +//! is what makes moving or deleting a layer behave, and what avoids the black-path and +//! doubled-text failures of the old PDFium import. + +use std::panic::AssertUnwindSafe; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use craft_pdf::interpret::color::Color as HayroColor; +use craft_pdf::interpret::font::Glyph; +use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + interpret_page, +}; +use craft_pdf::syntax::page::Page; +use kurbo::{Affine as KurboAffine, BezPath, Cap, Join, PathEl}; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; +use photocraft_doc::{Fill, Layer, LayerContent}; +use photocraft_geom::{Affine, Point, Rect}; +use photocraft_raster::Interrupt; + +/// Most layers one editable page may create; past it the page falls back to raster. +const MAX_LAYERS: usize = 20_000; +/// Most path knots one editable page may hold; past it the page falls back to raster. +const MAX_KNOTS: usize = 1_000_000; +/// Pixel bytes of shape caches one editable page may retain before the rest render on demand. +const MAX_CACHE_BYTES: u64 = 256 << 20; +/// How often the watcher copies the interrupt into craft-pdf's cancellation flag. +const POLL: Duration = Duration::from_millis(5); + +/// Why an editable page could not be built. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditableError { + /// The caller cancelled the import. + Cancelled, + /// The page uses something this first step cannot represent; rasterise it instead. + Unsupported(&'static str), +} + +/// Builds one page's shape layers in document coordinates inside `rect`. +/// +/// `rect` is the page's artboard in document pixels; the page's displayed size comes from +/// `Page::render_dimensions()` (CropBox, rotation and UserUnit already applied), so a point maps +/// to `rect` by the per-axis scale `rect.size / render_dimensions`. Nothing here re-applies the +/// CropBox offset or rotation. +pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, EditableError> { + ctl.check().map_err(|_| EditableError::Cancelled)?; + let (width_pt, height_pt) = page.render_dimensions(); + if !width_pt.is_finite() || !height_pt.is_finite() || width_pt <= 0.0 || height_pt <= 0.0 { + return Err(EditableError::Unsupported("the page has no area")); + } + let sx = f64::from(rect.width()) / f64::from(width_pt); + let sy = f64::from(rect.height()) / f64::from(height_pt); + let doc = Affine::translate(f64::from(rect.x0), f64::from(rect.y0)).mul(&Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let cancelled = Arc::new(AtomicBool::new(false)); + let finished = Arc::new(AtomicBool::new(false)); + let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; + let cache = InterpreterCache::new(); + let mut device = PathDevice { doc, clip: rect, fmt, ctl: *ctl, layers: Vec::new(), knots: 0, cached: 0, unsupported: None, cancelled: false }; + std::thread::scope(|scope| { + let flag = Arc::clone(&cancelled); + let done = Arc::clone(&finished); + let ctl = *ctl; + scope.spawn(move || { + while !done.load(Ordering::Relaxed) { + if ctl.cancelled() { + flag.store(true, Ordering::Relaxed); + } + std::thread::sleep(POLL); + } + }); + // A hostile page must not take the process down; the raster path already guards Hayro. + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + let bbox = kurbo::Rect::new(0.0, 0.0, f64::from(width_pt), f64::from(height_pt)); + let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), settings); + interpret_page(page, &mut context, &mut device); + })); + if result.is_err() { + device.unsupported = Some("the page could not be interpreted"); + } + finished.store(true, Ordering::Relaxed); + }); + if device.cancelled || ctl.cancelled() { + return Err(EditableError::Cancelled); + } + if let Some(reason) = device.unsupported { + return Err(EditableError::Unsupported(reason)); + } + Ok(device.layers) +} + +/// Collects filled and stroked paths; anything else marks the page unsupported. +struct PathDevice<'a> { + /// Page points to document pixels: translation to the artboard plus the per-axis scale. + doc: Affine, + /// The page's artboard in document pixels; shape caches and strokes are clipped to it. + clip: Rect, + /// Document pixel format the shape caches are rendered in. + fmt: PixelFormat, + ctl: Interrupt<'a>, + layers: Vec, + knots: usize, + /// Bytes of shape caches retained so far. + cached: u64, + unsupported: Option<&'static str>, + cancelled: bool, +} + +impl PathDevice<'_> { + /// Stops collecting at the first thing this step cannot represent. + fn unsupported(&mut self, reason: &'static str) { + if self.unsupported.is_none() { + self.unsupported = Some(reason); + } + } + + /// Adds one shape layer unless a page cap is reached. Renders its pixel cache within the + /// page's cache budget so the layer is visible before the engine refreshes it. + fn push_shape(&mut self, path: Path, fill: Option, stroke: Option) { + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if path.is_empty() { + return; + } + if self.layers.len() >= MAX_LAYERS { + self.unsupported("too many objects"); + return; + } + let reach = stroke.as_ref().map_or(0.0, |stroke| f64::from(stroke.width)); + let b = path_bounds(&path); + let area = Rect::new((b.0 - reach).floor() as i32, (b.1 - reach).floor() as i32, (b.2 + reach).ceil() as i32, (b.3 + reach).ceil() as i32) + .intersect(&self.clip); + let bytes = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.fmt.bytes_per_pixel() as u64); + let mut shape = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; + if area.width() > 0 && area.height() > 0 && self.cached.saturating_add(bytes) <= MAX_CACHE_BYTES { + shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, self.clip)); + self.cached = self.cached.saturating_add(bytes); + } + self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); + } +} + +impl<'a> Device<'a> for PathDevice<'_> { + fn set_soft_mask(&mut self, mask: Option>) { + if mask.is_some() { + self.unsupported("soft masks"); + } + } + + fn set_blend_mode(&mut self, blend_mode: BlendMode) { + if blend_mode != BlendMode::Normal { + self.unsupported("blend modes"); + } + } + + fn draw_path(&mut self, path: &BezPath, transform: KurboAffine, paint: &Paint<'a>, draw_mode: &PathDrawMode) { + if self.unsupported.is_some() { + return; + } + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + let color = match paint { + Paint::Color(color) => to_color(color), + Paint::Pattern(_) => { + self.unsupported("patterns"); + return; + } + }; + let full = self.doc.mul(&knots_affine(&transform)); + let Some(path) = to_path(path, &full, &mut self.knots) else { + self.unsupported("too many path points"); + return; + }; + match draw_mode { + PathDrawMode::Fill(rule) => { + let mut path = path; + path.fill_rule = match rule { + craft_pdf::interpret::FillRule::NonZero => FillRule::NonZero, + craft_pdf::interpret::FillRule::EvenOdd => FillRule::EvenOdd, + }; + self.push_shape(path, Some(Fill::Solid(color)), None); + } + PathDrawMode::Stroke(stroke) => { + let Some(stroke) = to_stroke(stroke, color, full.determinant().abs().sqrt()) else { + self.unsupported("stroke properties"); + return; + }; + self.push_shape(path, None, Some(stroke)); + } + } + } + + fn push_clip_path(&mut self, _clip_path: &ClipPath) { + self.unsupported("clipping paths"); + } + + fn push_transparency_group(&mut self, _opacity: f32, mask: Option>, blend_mode: BlendMode) { + // A group needs its own blend/opacity/mask handling; until that exists the page is + // rasterised rather than flattened into the wrong stacking order. + let _ = (mask, blend_mode); + self.unsupported("transparency groups"); + } + + fn draw_glyph(&mut self, _glyph: &Glyph<'a>, _transform: KurboAffine, _glyph_transform: KurboAffine, _paint: &Paint<'a>, _draw_mode: &GlyphDrawMode) { + self.unsupported("text is not editable yet"); + } + + fn draw_image(&mut self, _image: Image<'a, '_>, _transform: KurboAffine) { + self.unsupported("images"); + } + + fn pop_clip_path(&mut self) {} + + fn pop_transparency_group(&mut self) {} + + fn begin_annotations(&mut self) { + self.unsupported("annotations"); + } +} + +/// A PDF colour as a straight-alpha PhotoCraft colour. +fn to_color(color: &HayroColor) -> Color { + let rgba = color.to_rgba(); + let [r, g, b, a] = rgba.components(); + let a = a.clamp(0.0, 1.0); + if a <= 0.0 { + return Color::rgba(0.0, 0.0, 0.0, 0.0); + } + // `AlphaColor` components are premultiplied; PhotoCraft colours are straight. + Color::rgba((r / a).clamp(0.0, 1.0), (g / a).clamp(0.0, 1.0), (b / a).clamp(0.0, 1.0), a) +} + +/// A kurbo affine as PhotoCraft's affine (same `[a, b, c, d, e, f]` layout). +fn knots_affine(transform: &KurboAffine) -> Affine { + let [a, b, c, d, e, f] = transform.as_coeffs(); + Affine { m: [a, b, c, d, e, f] } +} + +/// The anchor bounds of a path, used only to estimate a shape cache's size. +fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { + let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + b.0 = b.0.min(knot.anchor.x); + b.1 = b.1.min(knot.anchor.y); + b.2 = b.2.max(knot.anchor.x); + b.3 = b.3.max(knot.anchor.y); + } + b +} + +/// A Hayro stroke as PhotoCraft's stroke: width in pixels, dashes in multiples of the width. +fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f64) -> Option { + if !stroke.line_width.is_finite() || stroke.line_width <= 0.0 || !scale.is_finite() || scale <= 0.0 { + return None; + } + let width = stroke.line_width * scale as f32; + if !width.is_finite() || width <= 0.0 { + return None; + } + let dashes = stroke.dash_array.iter().map(|dash| dash / stroke.line_width).collect(); + Some(ShapeStroke { + width, + paint: Fill::Solid(color), + opacity: 1.0, + align: StrokeAlign::Center, + cap: match stroke.line_cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }, + join: match stroke.line_join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }, + miter_limit: stroke.miter_limit, + dashes, + dash_offset: stroke.dash_offset / stroke.line_width, + }) +} + +/// Converts a kurbo path to PhotoCraft's cubic knot model. `None` once `knots` passes the cap. +fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option { + let mut subpaths = Vec::new(); + let mut current: Option = None; + let mut last = Point::new(0.0, 0.0); + for element in bez.elements() { + match *element { + PathEl::MoveTo(p) => { + if let Some(subpath) = current.take() { + subpaths.push(subpath); + } + current = Some(Subpath { closed: false, knots: vec![Knot::corner(p.x, p.y)], op: PathOp::Combine }); + last = Point::new(p.x, p.y); + } + PathEl::LineTo(p) => { + if let Some(subpath) = current.as_mut() { + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = knot.anchor; + } + subpath.knots.push(Knot::corner(p.x, p.y)); + } + last = Point::new(p.x, p.y); + } + PathEl::QuadTo(control, p) => { + if let Some(subpath) = current.as_mut() { + let start = subpath.knots.last().map_or(last, |knot| knot.anchor); + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = Point::new(start.x + 2.0 / 3.0 * (control.x - start.x), start.y + 2.0 / 3.0 * (control.y - start.y)); + } + subpath.knots.push(Knot { + anchor: Point::new(p.x, p.y), + in_ctrl: Point::new(p.x + 2.0 / 3.0 * (control.x - p.x), p.y + 2.0 / 3.0 * (control.y - p.y)), + out_ctrl: Point::new(p.x, p.y), + smooth: false, + }); + } + last = Point::new(p.x, p.y); + } + PathEl::CurveTo(c1, c2, p) => { + if let Some(subpath) = current.as_mut() { + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = Point::new(c1.x, c1.y); + } + subpath.knots.push(Knot { anchor: Point::new(p.x, p.y), in_ctrl: Point::new(c2.x, c2.y), out_ctrl: Point::new(p.x, p.y), smooth: false }); + } + last = Point::new(p.x, p.y); + } + PathEl::ClosePath => { + if let Some(mut subpath) = current.take() { + subpath.closed = true; + if subpath.knots.len() > 1 { + let first = subpath.knots[0].anchor; + let last_anchor = subpath.knots.last().map_or(first, |knot| knot.anchor); + if (first.x - last_anchor.x).abs() < 1e-9 && (first.y - last_anchor.y).abs() < 1e-9 { + if let Some(knot) = subpath.knots.pop() { + subpath.knots[0].in_ctrl = knot.in_ctrl; + } + } else { + subpath.knots[0].in_ctrl = first; + } + } + subpaths.push(subpath); + } + } + } + } + if let Some(subpath) = current { + subpaths.push(subpath); + } + *knots = knots.saturating_add(subpaths.iter().map(|subpath| subpath.knots.len()).sum::()); + if *knots > MAX_KNOTS { + return None; + } + for subpath in &mut subpaths { + for knot in &mut subpath.knots { + *knot = knot.transform(transform); + } + } + Some(Path::new(subpaths)) +} + +#[cfg(test)] +mod tests { + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + + use super::*; + use crate::{ImportOptions, IoError, import_with_options_and_interrupt}; + + /// A one-page 200 x 100 pt PDF whose content stream is `content`. + fn pdf(content: &str) -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the fixture"); + bytes + } + + /// The artboard group's children. + fn children(document: &photocraft_doc::Document) -> &[Layer] { + match &document.layers.first().expect("a page group").content { + LayerContent::Group(group) => &group.children, + _ => panic!("the page is not a group"), + } + } + + /// A solid fill or stroke colour. + fn solid(fill: &Fill) -> Color { + match fill { + Fill::Solid(color) => *color, + _ => panic!("not a solid paint"), + } + } + + /// Path bounds in document pixels. + fn bounds(path: &Path) -> (f64, f64, f64, f64) { + let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + b.0 = b.0.min(knot.anchor.x); + b.1 = b.1.min(knot.anchor.y); + b.2 = b.2.max(knot.anchor.x); + b.3 = b.3.max(knot.anchor.y); + } + b + } + + #[test] + fn painted_paths_become_shape_layers_with_their_colours_and_dashes() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f 0 0 1 RG 3 w [9 4] 0 d 100 30 m 180 30 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("paths.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable import"); + let children = children(&imported.document); + assert_eq!(children.len(), 2, "one fill and one stroke layer"); + let LayerContent::Shape(fill) = &children[0].content else { panic!("the first child is not a shape") }; + assert_eq!(solid(fill.fill.as_ref().expect("a fill")), Color::rgb(1.0, 0.0, 0.0)); + assert!(fill.stroke.is_none()); + let (x0, y0, x1, y1) = bounds(&fill.path); + assert!((x0 - 20.0).abs() < 0.01 && (x1 - 80.0).abs() < 0.01, "x bounds {x0}..{x1}"); + assert!((y0 - 40.0).abs() < 0.01 && (y1 - 80.0).abs() < 0.01, "PDF y up becomes document y down: {y0}..{y1}"); + + let LayerContent::Shape(stroke) = &children[1].content else { panic!("the second child is not a shape") }; + assert!(stroke.fill.is_none()); + let stroke = stroke.stroke.as_ref().expect("a stroke"); + assert_eq!(solid(&stroke.paint), Color::rgb(0.0, 0.0, 1.0)); + assert!((stroke.width - 3.0).abs() < 0.01, "stroke width {}", stroke.width); + assert_eq!(stroke.dashes.len(), 2); + assert!((stroke.dashes[0] - 3.0).abs() < 0.01 && (stroke.dashes[1] - 4.0 / 3.0).abs() < 0.01, "dashes {:?}", stroke.dashes); + } + + #[test] + fn the_editable_composite_matches_the_raster_page() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f 0 0 1 RG 3 w [9 4] 0 d 100 30 m 180 30 l S"); + let raster = import_with_options_and_interrupt("paths.pdf", &bytes, &ImportOptions { pdf_dpi: 72.0, ..ImportOptions::default() }, &Interrupt::NONE) + .expect("a raster import"); + let editable = import_with_options_and_interrupt( + "paths.pdf", + &bytes, + &ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }, + &Interrupt::NONE, + ) + .expect("an editable import"); + let fmt = editable.document.pixel_format(); + let a = photocraft_compose::flatten_to_surface(&editable.document, fmt, None); + let b = photocraft_compose::flatten_to_surface(&raster.document, fmt, None); + let rect = editable.document.bounds(); + let av = a.read_region(rect); + let bv = b.read_region(rect); + assert_eq!(av.len(), bv.len()); + let mut sum = 0.0f64; + let mut max = 0.0f32; + for (x, y) in av.iter().zip(&bv) { + let d = (x - y).abs(); + sum += f64::from(d); + max = max.max(d); + } + let mean = sum / av.len() as f64; + assert!(mean < 0.01, "mean pixel difference {mean:.4} (max {max:.3})"); + } + + #[test] + fn text_falls_back_to_the_raster_page_with_a_warning() { + let bytes = pdf("BT /F1 24 Tf 10 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("a fallback import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("text is not editable yet") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); + } + + #[test] + fn a_cancelled_editable_import_returns_cancelled() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f"); + let cancel = || true; + let ctl = Interrupt::cancel_only(&cancel); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + assert!(matches!(import_with_options_and_interrupt("paths.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); + } +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index d55676ac16..3907751046 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -147,6 +147,27 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS { return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI"))); } + let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); + #[cfg(feature = "pdf-editable")] + if opts.pdf_editable { + match crate::pdf_editable::page(page, rect, ctl) { + Ok(layers) => { + let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); + x = x.saturating_add(width).saturating_add(GAP); + ctl.progress((index + 1) as f32 / count as f32); + continue; + } + Err(crate::pdf_editable::EditableError::Cancelled) => return Err(IoError::Cancelled), + Err(crate::pdf_editable::EditableError::Unsupported(reason)) => { + warnings.push(format!("PDF page {page_number}: {reason}; the page was rasterised")); + } + } + } + #[cfg(not(feature = "pdf-editable"))] + if opts.pdf_editable { + warnings.push(format!("PDF page {page_number}: editable PDF import is not built into this binary; the page was rasterised")); + } // A cache per page, deliberately: a shared one would keep decoded images and fonts alive // across up to 16 pages outside the pixel budget, and would hide an image-decode warning // on a later page after the page that first hit it. @@ -195,7 +216,6 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let band_height = (rows.len() / row_bytes) as u32; surface.write_region(Rect::from_xywh(x as i32, top, width, band_height), &data); } - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface)); let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); From a18268d0d3da9773e8b80bfc949341abd2e3befc Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:40:23 +0100 Subject: [PATCH 47/77] PDF: editable text pass and review fixes - Text: build one TextLayer per line/style run from the Device's glyph draws. Derive the family from the PostScript name (subset tag, style suffix, MT/PS, camel spacing); italic from the font flags/attributes or name; bold from the weight or name; pair fill+stroke glyphs as fake bold (Tr 2/6) instead of a second run. - Require an embedded font with a PostScript name; standard-14/Type 1 substitutes and Type 3 text fall back to the raster page. - Register embedded font bytes in the text engine, use the family the font reports, and render text caches on import. Tests compare the text composite with the raster page. - Review fixes: accept a page-sized no-op clip; draw zero-width strokes as 1 px hairlines and use 1 px as the dash base; reject non-uniform stroke transforms; repeat odd dash arrays and keep zero dashes; ExtGState ca/CA already reach the colour alpha (tested); note that catch_unwind only guards unwinding profiles. --- crates/io/src/pdf_editable.rs | 731 +++++++++++++++++++++++++++++++++- crates/io/src/pdf_import.rs | 5 +- crates/text/src/lib.rs | 11 + 3 files changed, 729 insertions(+), 18 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 1344bf0822..8d1beab04d 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -16,7 +16,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use craft_pdf::interpret::color::Color as HayroColor; -use craft_pdf::interpret::font::Glyph; +use craft_pdf::interpret::font::{FontData, Glyph, OutlineFontData}; use craft_pdf::interpret::{ BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, @@ -24,8 +24,9 @@ use craft_pdf::interpret::{ use craft_pdf::syntax::page::Page; use kurbo::{Affine as KurboAffine, BezPath, Cap, Join, PathEl}; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::text::{CharStyle, TextRun}; use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; -use photocraft_doc::{Fill, Layer, LayerContent}; +use photocraft_doc::{Fill, Layer, LayerContent, TextLayer}; use photocraft_geom::{Affine, Point, Rect}; use photocraft_raster::Interrupt; @@ -37,6 +38,10 @@ const MAX_KNOTS: usize = 1_000_000; const MAX_CACHE_BYTES: u64 = 256 << 20; /// How often the watcher copies the interrupt into craft-pdf's cancellation flag. const POLL: Duration = Duration::from_millis(5); +/// Largest gap (in em) inside one run before a space is inserted. +const WORD_GAP: f64 = 0.2; +/// Largest baseline offset (in em) for glyphs to share one line. +const LINE_TOLERANCE: f64 = 0.35; /// Why an editable page could not be built. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -53,7 +58,7 @@ pub enum EditableError { /// `Page::render_dimensions()` (CropBox, rotation and UserUnit already applied), so a point maps /// to `rect` by the per-axis scale `rect.size / render_dimensions`. Nothing here re-applies the /// CropBox offset or rotation. -pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, EditableError> { +pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt, text: &mut photocraft_text::TextEngine) -> Result, EditableError> { ctl.check().map_err(|_| EditableError::Cancelled)?; let (width_pt, height_pt) = page.render_dimensions(); if !width_pt.is_finite() || !height_pt.is_finite() || width_pt <= 0.0 || height_pt <= 0.0 { @@ -67,7 +72,22 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, let finished = Arc::new(AtomicBool::new(false)); let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; let cache = InterpreterCache::new(); - let mut device = PathDevice { doc, clip: rect, fmt, ctl: *ctl, layers: Vec::new(), knots: 0, cached: 0, unsupported: None, cancelled: false }; + let mut device = PathDevice { + doc, + clip: rect, + fmt, + dpi: 72.0 * sx, + ctl: *ctl, + layers: Vec::new(), + glyphs: Vec::new(), + strokes: Vec::new(), + fonts: Vec::new(), + clips: Vec::new(), + knots: 0, + cached: 0, + unsupported: None, + cancelled: false, + }; std::thread::scope(|scope| { let flag = Arc::clone(&cancelled); let done = Arc::clone(&finished); @@ -80,7 +100,8 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, std::thread::sleep(POLL); } }); - // A hostile page must not take the process down; the raster path already guards Hayro. + // A hostile page must not take the process down. This only guards profiles that unwind; + // a `panic = "abort"` build aborts here just as the raster path would. let result = std::panic::catch_unwind(AssertUnwindSafe(|| { let bbox = kurbo::Rect::new(0.0, 0.0, f64::from(width_pt), f64::from(height_pt)); let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), settings); @@ -97,10 +118,157 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, if let Some(reason) = device.unsupported { return Err(EditableError::Unsupported(reason)); } - Ok(device.layers) + if let Err(reason) = pair_fake_bold(&mut device.glyphs, device.strokes) { + return Err(EditableError::Unsupported(reason)); + } + let mut layers = device.layers; + layers.extend(text_layers(&device.glyphs)); + render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts, text); + Ok(layers) } -/// Collects filled and stroked paths; anything else marks the page unsupported. +/// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. +fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), &'static str> { + for stroke in strokes { + let mut matched = false; + for glyph in glyphs.iter_mut() { + let tolerance = 0.01 * glyph.size_px + 0.01; + if glyph.text == stroke.text + && glyph.font.key == stroke.font.key + && (glyph.origin.x - stroke.origin.x).abs() <= tolerance + && (glyph.origin.y - stroke.origin.y).abs() <= tolerance + { + glyph.fake_bold = true; + matched = true; + break; + } + } + if !matched { + return Err("stroked text"); + } + } + Ok(()) +} + +/// Builds one point-text layer per line and style run. +fn text_layers(glyphs: &[GlyphRun]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < glyphs.len() { + let first = &glyphs[i]; + let (dx, dy) = (first.angle.cos(), first.angle.sin()); + let (nx, ny) = (-dy, dx); + let baseline = first.origin.x * nx + first.origin.y * ny; + let start = i; + let mut text = String::new(); + let mut last = first; + while i < glyphs.len() { + let glyph = &glyphs[i]; + if !same_style(glyph, first) { + break; + } + let line = glyph.origin.x * nx + glyph.origin.y * ny; + if (line - baseline).abs() > LINE_TOLERANCE * first.size_px { + break; + } + if i > start { + let along = (glyph.origin.x - first.origin.x) * dx + (glyph.origin.y - first.origin.y) * dy; + let prev = (last.origin.x - first.origin.x) * dx + (last.origin.y - first.origin.y) * dy; + let gap = along - (prev + last.advance); + if gap > 3.0 * first.size_px { + break; + } + if gap > WORD_GAP * first.size_px && !text.ends_with(' ') { + text.push(' '); + } + } + text.push_str(&glyph.text); + last = glyph; + i += 1; + } + let text = text.trim().to_string(); + if !text.is_empty() { + out.push(text_layer(text, first, out.len())); + } + if i == start { + i += 1; + } + } + out +} + +/// Whether `b` belongs to the same line and style run as `a`. +fn same_style(a: &GlyphRun, b: &GlyphRun) -> bool { + let weight = |glyph: &GlyphRun| if glyph.fake_bold { 700 } else { glyph.font.weight }; + a.font.key == b.font.key + && a.font.family == b.font.family + && a.font.italic == b.font.italic + && weight(a) == weight(b) + && (a.size_pt - b.size_pt).abs() < 0.05 + && (a.angle - b.angle).abs() < 0.02 + && (a.color.c[0] - b.color.c[0]).abs() < 0.004 + && (a.color.c[1] - b.color.c[1]).abs() < 0.004 + && (a.color.c[2] - b.color.c[2]).abs() < 0.004 + && (a.color.alpha - b.color.alpha).abs() < 0.004 +} + +/// One line's point-text layer. +fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { + let weight = if run.fake_bold { 700 } else { run.font.weight }; + let font_style = match (weight >= 600, run.font.italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + let style = CharStyle { + font_family: run.font.family.clone(), + font_style: font_style.to_string(), + postscript_name: Some(run.font.postscript.clone()), + weight: u16::try_from(weight).unwrap_or(400), + italic: run.font.italic, + size_pt: run.size_pt as f32, + color: run.color, + ..CharStyle::default() + }; + let layer = TextLayer { + text: text.clone(), + font_family: run.font.family.clone(), + size_pt: run.size_pt as f32, + color: run.color, + transform: Affine::translate(run.origin.x, run.origin.y).mul(&Affine::rotate(run.angle)), + runs: vec![TextRun { len: text.len(), style }], + ..TextLayer::default() + }; + Layer::new(format!("Text {}", number + 1), LayerContent::Text(layer)) +} + +/// Registers each embedded font with the text engine, renames the runs to the family the font +/// itself reports, and renders the caches so the page is complete before the engine refreshes it. +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, FontData)], engine: &mut photocraft_text::TextEngine) { + let mut families = std::collections::HashMap::new(); + for (_, postscript, data) in fonts { + let names = engine.register_font_data(data.as_ref().as_ref().to_vec()); + if let Some(name) = names.first() { + families.insert(postscript.clone(), name.clone()); + } + } + for layer in layers { + if let LayerContent::Text(text) = &mut layer.content { + if let Some(family) = text.runs.first().and_then(|run| run.style.postscript_name.as_ref()).and_then(|postscript| families.get(postscript)) { + text.font_family.clone_from(family); + if let Some(run) = text.runs.first_mut() { + run.style.font_family.clone_from(family); + } + } + if !text.text.trim().is_empty() { + let _ = engine.render_layer(text, dpi as f32, fmt); + } + } + } +} + +/// Collects filled and stroked paths and glyph runs; anything else marks the page unsupported. struct PathDevice<'a> { /// Page points to document pixels: translation to the artboard plus the per-axis scale. doc: Affine, @@ -108,8 +276,18 @@ struct PathDevice<'a> { clip: Rect, /// Document pixel format the shape caches are rendered in. fmt: PixelFormat, + /// Document DPI, for text-layer caches. + dpi: f64, ctl: Interrupt<'a>, layers: Vec, + /// Filled glyphs in content order. + glyphs: Vec, + /// Stroked glyphs, paired with a fill at the same place to recognise fake bold. + strokes: Vec, + /// Embedded fonts met on the page: cache key, PostScript name and bytes. + fonts: Vec<(u128, String, FontData)>, + /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. + clips: Vec, knots: usize, /// Bytes of shape caches retained so far. cached: u64, @@ -117,6 +295,35 @@ struct PathDevice<'a> { cancelled: bool, } +/// One drawn glyph with the style the text layer needs. +struct GlyphRun { + text: String, + /// Baseline origin in document pixels. + origin: Point, + size_pt: f64, + /// Em size in document pixels. + size_px: f64, + /// Baseline direction in radians (view space, y down). + angle: f64, + /// Advance in document pixels. + advance: f64, + font: FontInfo, + color: Color, + /// Drawn both filled and stroked: word-processor fake bold. + fake_bold: bool, +} + +/// The font style a run carries. +#[derive(Clone)] +struct FontInfo { + /// Hayro's font cache key, to group glyphs of one font. + key: u128, + family: String, + postscript: String, + weight: u32, + italic: bool, +} + impl PathDevice<'_> { /// Stops collecting at the first thing this step cannot represent. fn unsupported(&mut self, reason: &'static str) { @@ -125,6 +332,18 @@ impl PathDevice<'_> { } } + /// Whether `clip` is an axis-aligned rectangle that contains the whole artboard, so it + /// changes nothing in document space and can be ignored. + fn no_op_clip(&self, clip: &ClipPath) -> bool { + let Some((x0, y0, x1, y1)) = axis_aligned_rect(&clip.path) else { return false }; + let a = self.doc.apply(Point::new(x0, y0)); + let b = self.doc.apply(Point::new(x1, y1)); + let (dx0, dx1) = (a.x.min(b.x), a.x.max(b.x)); + let (dy0, dy1) = (a.y.min(b.y), a.y.max(b.y)); + let board = self.clip; + dx0 <= f64::from(board.x0) + 0.5 && dy0 <= f64::from(board.y0) + 0.5 && dx1 >= f64::from(board.x1) - 0.5 && dy1 >= f64::from(board.y1) - 0.5 + } + /// Adds one shape layer unless a page cap is reached. Renders its pixel cache within the /// page's cache budget so the layer is visible before the engine refreshes it. fn push_shape(&mut self, path: Path, fill: Option, stroke: Option) { @@ -196,6 +415,10 @@ impl<'a> Device<'a> for PathDevice<'_> { self.push_shape(path, Some(Fill::Solid(color)), None); } PathDrawMode::Stroke(stroke) => { + if !uniform_transform(&transform) { + self.unsupported("non-uniform stroke transform"); + return; + } let Some(stroke) = to_stroke(stroke, color, full.determinant().abs().sqrt()) else { self.unsupported("stroke properties"); return; @@ -205,7 +428,12 @@ impl<'a> Device<'a> for PathDevice<'_> { } } - fn push_clip_path(&mut self, _clip_path: &ClipPath) { + fn push_clip_path(&mut self, clip_path: &ClipPath) { + if self.no_op_clip(clip_path) { + self.clips.push(true); + return; + } + self.clips.push(false); self.unsupported("clipping paths"); } @@ -216,15 +444,96 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("transparency groups"); } - fn draw_glyph(&mut self, _glyph: &Glyph<'a>, _transform: KurboAffine, _glyph_transform: KurboAffine, _paint: &Paint<'a>, _draw_mode: &GlyphDrawMode) { - self.unsupported("text is not editable yet"); + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: KurboAffine, glyph_transform: KurboAffine, paint: &Paint<'a>, draw_mode: &GlyphDrawMode) { + if self.unsupported.is_some() { + return; + } + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if matches!(draw_mode, GlyphDrawMode::Invisible) { + return; + } + if matches!(draw_mode, GlyphDrawMode::Clip) { + self.unsupported("text used as a clipping path"); + return; + } + let text = match glyph.as_unicode() { + Some(craft_pdf::interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), + Some(craft_pdf::interpret::hayro_cmap::BfString::String(s)) => s, + None => return, + }; + if text.is_empty() || text.chars().all(|c| c.is_control()) { + return; + } + let Glyph::Outline(outline) = glyph else { + self.unsupported("Type 3 text"); + return; + }; + let Some(data) = outline.font_data() else { + self.unsupported("text without an embedded font"); + return; + }; + if !data.embedded { + self.unsupported("text in a substituted font"); + return; + } + let Some(postscript) = data.postscript_name.clone().filter(|name| !name.is_empty()) else { + self.unsupported("text without a font name"); + return; + }; + let color = match paint { + Paint::Color(color) => to_color(color), + Paint::Pattern(_) => { + self.unsupported("text with a pattern paint"); + return; + } + }; + let t = transform * glyph_transform; + let [a, b, c, d, e, f] = t.as_coeffs(); + // A glyph space of 1000 units per em, so the determinant's square root is 1/1000 of the + // em size. Upright text has a positive a and a negative d (the view flips y). + let size_pt = (a * d - b * c).abs().sqrt() * 1000.0; + if !size_pt.is_finite() || size_pt <= 0.0 { + self.unsupported("text without a usable size"); + return; + } + // Skewed or mirrored text cannot be a point text layer; leave the page to raster. + if (a.abs() - d.abs()).abs() > 0.01 * a.abs().max(1.0e-9) || (b.abs() - c.abs()).abs() > 0.01 * a.abs().max(1.0e-9) { + self.unsupported("skewed text"); + return; + } + let origin = self.doc.apply(Point::new(e, f)); + let size_px = size_pt * self.doc.determinant().abs().sqrt(); + let angle = b.atan2(a); + if !angle.is_finite() || !size_px.is_finite() || size_px <= 0.0 { + self.unsupported("text without a usable transform"); + return; + } + let advance = f64::from(outline.advance_width().unwrap_or(500.0)) / 1000.0 * size_px; + let weight = font_weight(&data, &postscript); + let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); + let key = outline.font_cache_key(); + if !self.fonts.iter().any(|(existing, _, _)| *existing == key) { + self.fonts.push((key, postscript.clone(), data.data.clone())); + } + let font = FontInfo { key, family: family_from_postscript(&postscript), postscript, weight, italic }; + let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false }; + if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { + self.strokes.push(glyph); + } else { + self.glyphs.push(glyph); + } } fn draw_image(&mut self, _image: Image<'a, '_>, _transform: KurboAffine) { self.unsupported("images"); } - fn pop_clip_path(&mut self) {} + fn pop_clip_path(&mut self) { + let _ = self.clips.pop(); + } fn pop_transparency_group(&mut self) {} @@ -233,6 +542,144 @@ impl<'a> Device<'a> for PathDevice<'_> { } } +/// The weight a font is given to a text layer, from its metadata and name. +fn font_weight(data: &OutlineFontData, postscript: &str) -> u32 { + if name_has(postscript, &["bold", "semibold", "demibold", "extrabold", "ultrabold", "black", "heavy"]) { + return data.weight.unwrap_or(700).max(700); + } + data.weight.unwrap_or(400) +} + +/// Whether `name` contains any of the lowercase style words. +fn name_has(name: &str, words: &[&str]) -> bool { + let lower = name.to_ascii_lowercase(); + words.iter().any(|word| lower.contains(word)) +} + +/// A family name from a `/BaseFont`/PostScript name, per the rules PdfCraft and pdfcer use: +/// drop a six-letter subset tag, a trailing style suffix and `MT`/`PS`, then space camel case. +/// The raw name stays in `CharStyle::postscript_name`, because some systems match that spelling. +fn family_from_postscript(name: &str) -> String { + let mut s = name; + if s.len() > 7 && s.as_bytes()[6] == b'+' && s[..6].bytes().all(|b| b.is_ascii_uppercase()) { + s = &s[7..]; + } + if let Some((head, tail)) = s.rsplit_once('-') + && is_style_word(tail) + { + s = head; + } + loop { + if let Some(rest) = s.strip_suffix("PSMT") { + s = rest; + } else if let Some(rest) = s.strip_suffix("MT") { + s = rest; + } else if let Some(rest) = s.strip_suffix("PS") { + s = rest; + } else { + break; + } + } + let spaced = space_camel(s); + if spaced.is_empty() { name.to_string() } else { spaced } +} + +/// Whether a suffix is a style word (after dropping `MT`/`PS` from it). +fn is_style_word(word: &str) -> bool { + let mut w = word; + loop { + if let Some(rest) = w.strip_suffix("MT") { + w = rest; + } else if let Some(rest) = w.strip_suffix("PS") { + w = rest; + } else { + break; + } + } + matches!( + w.to_ascii_lowercase().as_str(), + "bold" + | "italic" + | "oblique" + | "bolditalic" + | "boldoblique" + | "regular" + | "medium" + | "light" + | "black" + | "semibold" + | "demibold" + | "heavy" + | "thin" + | "book" + | "roman" + | "condensed" + | "narrow" + | "extended" + ) +} + +/// `TimesNewRoman` → `Times New Roman`; acronym runs and hyphens stay as they are. +fn space_camel(name: &str) -> String { + let chars: Vec = name.chars().collect(); + let mut out = String::with_capacity(name.len() + 4); + for (i, ch) in chars.iter().copied().enumerate() { + let prev = i.checked_sub(1).and_then(|j| chars.get(j)).copied(); + let next = chars.get(i + 1).copied(); + let boundary = ch.is_ascii_uppercase() && prev.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit()) + || ch.is_ascii_uppercase() && prev.is_some_and(|p| p.is_ascii_uppercase()) && next.is_some_and(|n| n.is_ascii_lowercase()); + if boundary && !out.is_empty() && !out.ends_with(' ') && !out.ends_with('-') { + out.push(' '); + } + out.push(ch); + } + out +} + +/// The bounds of a path that is one axis-aligned rectangle, else `None`. +fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { + let elements = path.elements(); + if elements.len() != 5 { + return None; + } + let mut points = Vec::with_capacity(4); + for element in elements { + match *element { + PathEl::MoveTo(p) | PathEl::LineTo(p) => points.push((p.x, p.y)), + PathEl::ClosePath => {} + PathEl::QuadTo(..) | PathEl::CurveTo(..) => return None, + } + } + if points.len() != 4 { + return None; + } + let x0 = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); + let x1 = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); + let y0 = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); + let y1 = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); + if !(x1 > x0 && y1 > y0) { + return None; + } + let on_edge = |v: f64, lo: f64, hi: f64| (v - lo).abs() < 1.0e-6 || (v - hi).abs() < 1.0e-6; + if points.iter().any(|(x, y)| !on_edge(*x, x0, x1) || !on_edge(*y, y0, y1)) { + return None; + } + Some((x0, y0, x1, y1)) +} + +/// Whether a path transform keeps circles circular: both axes the same length and perpendicular. +fn uniform_transform(transform: &KurboAffine) -> bool { + let [a, b, c, d, _, _] = transform.as_coeffs(); + let x = (a * a + b * b).sqrt(); + let y = (c * c + d * d).sqrt(); + if !x.is_finite() || !y.is_finite() || x <= 0.0 || y <= 0.0 { + return false; + } + let same = (x - y).abs() <= 0.01 * x.max(y); + let square = (a * c + b * d).abs() <= 0.01 * x * y; + same && square +} + /// A PDF colour as a straight-alpha PhotoCraft colour. fn to_color(color: &HayroColor) -> Color { let rgba = color.to_rgba(); @@ -265,14 +712,21 @@ fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { /// A Hayro stroke as PhotoCraft's stroke: width in pixels, dashes in multiples of the width. fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f64) -> Option { - if !stroke.line_width.is_finite() || stroke.line_width <= 0.0 || !scale.is_finite() || scale <= 0.0 { + if !stroke.line_width.is_finite() || stroke.line_width < 0.0 || !scale.is_finite() || scale <= 0.0 { return None; } - let width = stroke.line_width * scale as f32; + // A zero line width is the thinnest line the device can draw (ISO 32000-2 §8.4.3.2); the + // raster path draws it one document pixel wide, so do the same and use that as the dash base. + let width = if stroke.line_width > 0.0 { stroke.line_width * scale as f32 } else { 1.0 }; if !width.is_finite() || width <= 0.0 { return None; } - let dashes = stroke.dash_array.iter().map(|dash| dash / stroke.line_width).collect(); + let mut dashes: Vec = stroke.dash_array.iter().map(|dash| dash * scale as f32 / width).collect(); + // An odd PDF dash array repeats, so [3] means 3 on, 3 off rather than a single entry. + if dashes.len() % 2 == 1 { + let copy = dashes.clone(); + dashes.extend(copy); + } Some(ShapeStroke { width, paint: Fill::Solid(color), @@ -290,7 +744,7 @@ fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f6 }, miter_limit: stroke.miter_limit, dashes, - dash_offset: stroke.dash_offset / stroke.line_width, + dash_offset: stroke.dash_offset * scale as f32 / width, }) } @@ -384,6 +838,11 @@ mod tests { /// A one-page 200 x 100 pt PDF whose content stream is `content`. fn pdf(content: &str) -> Vec { + pdf_with_resources(content, dictionary! {}) + } + + /// [`pdf`] with page resources (for example an ExtGState). + fn pdf_with_resources(content: &str, resources: lopdf::Dictionary) -> Vec { let mut pdf = PdfDocument::with_version("1.5"); let pages_id = pdf.new_object_id(); let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); @@ -392,7 +851,7 @@ mod tests { "Parent" => pages_id, "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), "Contents" => content_id, - "Resources" => dictionary! {}, + "Resources" => resources, }); pdf.objects.insert( pages_id, @@ -409,6 +868,64 @@ mod tests { bytes } + /// A one-page 200 x 100 pt PDF whose content stream is `content` and whose page uses an + /// embedded TrueType font as `/F1`. + fn text_pdf(content: &str) -> Vec { + text_pdf_named(content, "Inter-Regular") + } + + /// [`text_pdf`] with the font named `name` (the same Inter bytes, so tests can exercise the + /// name-based style rules). + fn text_pdf_named(content: &str, name: &str) -> Vec { + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let font_file = pdf.add_object(Stream::new(dictionary! { "Length1" => INTER.len() as i64 }, INTER.to_vec())); + let descriptor = pdf.add_object(dictionary! { + "Type" => "FontDescriptor", + "FontName" => Object::Name(name.as_bytes().to_vec()), + "Flags" => 32, + "FontBBox" => Object::Array(vec![Object::Integer(-100), Object::Integer(-200), Object::Integer(1000), Object::Integer(900)]), + "ItalicAngle" => 0, + "Ascent" => 900, + "Descent" => -200, + "CapHeight" => 700, + "StemV" => 80, + "FontFile2" => font_file, + }); + let font = pdf.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "TrueType", + "BaseFont" => Object::Name(name.as_bytes().to_vec()), + "FirstChar" => 32, + "LastChar" => 126, + "Widths" => Object::Array(vec![Object::Integer(600); 95]), + "FontDescriptor" => descriptor, + "Encoding" => "WinAnsiEncoding", + }); + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the text fixture"); + bytes + } + /// The artboard group's children. fn children(document: &photocraft_doc::Document) -> &[Layer] { match &document.layers.first().expect("a page group").content { @@ -417,6 +934,17 @@ mod tests { } } + /// The first text layer of `document`. + fn text_layer(document: &photocraft_doc::Document) -> &TextLayer { + children(document) + .iter() + .find_map(|layer| match &layer.content { + LayerContent::Text(text) => Some(text), + _ => None, + }) + .expect("a text layer") + } + /// A solid fill or stroke colour. fn solid(fill: &Fill) -> Color { match fill { @@ -496,13 +1024,182 @@ mod tests { let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("a fallback import"); assert!( - imported.warnings.iter().any(|warning| warning.contains("text is not editable yet") && warning.contains("rasterised")), + imported.warnings.iter().any(|warning| warning.contains("text without an embedded font") && warning.contains("rasterised")), "{:?}", imported.warnings ); assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); } + #[test] + fn embedded_text_becomes_a_text_layer_with_its_style() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let text = text_layer(&imported.document); + assert_eq!(text.text, "Hi"); + assert_eq!(text.font_family, "Inter"); + assert!((f64::from(text.size_pt) - 24.0).abs() < 0.1, "size {}", text.size_pt); + assert_eq!(text.color, Color::rgb(1.0, 0.0, 0.0)); + let origin = text.transform.apply(Point::new(0.0, 0.0)); + assert!((origin.x - 20.0).abs() < 0.5 && (origin.y - 50.0).abs() < 0.5, "origin {origin:?}"); + assert!(text.cache.is_some(), "the text cache is rendered on import"); + let style = &text.runs[0].style; + assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular")); + assert_eq!(style.weight, 400); + assert!(!style.italic); + } + + #[test] + fn font_names_set_bold_and_italic() { + for (name, weight, italic) in [("Inter-Bold", 700, false), ("Inter-Italic", 400, true), ("Inter-BoldItalic", 700, true)] { + let bytes = text_pdf_named("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", name); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let style = &text_layer(&imported.document).runs[0].style; + assert_eq!(style.weight, weight, "{name}"); + assert_eq!(style.italic, italic, "{name}"); + assert_eq!(style.font_family, "Inter", "{name}"); + } + } + + #[test] + fn a_page_sized_clip_does_not_force_a_raster_page() { + let bytes = pdf("q 0 0 200 100 re W n 1 0 0 rg 20 20 60 40 re f Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("clip.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(!imported.warnings.iter().any(|warning| warning.contains("clipping")), "{:?}", imported.warnings); + assert!(children(&imported.document).iter().any(|layer| matches!(layer.content, LayerContent::Shape(_)))); + } + + #[test] + fn a_zero_width_stroke_is_a_one_pixel_hairline() { + let bytes = pdf("0 0 1 RG 0 w 20 30 m 180 30 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("hairline.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let LayerContent::Shape(shape) = &children(&imported.document)[0].content else { panic!("not a shape") }; + let stroke = shape.stroke.as_ref().expect("a stroke"); + assert!((stroke.width - 1.0).abs() < 0.01, "width {}", stroke.width); + } + + #[test] + fn a_non_uniform_stroke_transform_falls_back() { + let bytes = pdf("q 2 0 0 1 0 0 cm 0 0 1 RG 4 w 20 30 m 80 30 l S Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("skew.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("non-uniform stroke transform") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); + } + + #[test] + fn a_uniform_stroke_transform_scales_the_width() { + let bytes = pdf("q 2 0 0 2 0 0 cm 0 0 1 RG 4 w 20 30 m 80 30 l S Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("scaled.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let LayerContent::Shape(shape) = &children(&imported.document)[0].content else { panic!("not a shape") }; + let stroke = shape.stroke.as_ref().expect("a stroke"); + assert!((stroke.width - 8.0).abs() < 0.01, "width {}", stroke.width); + } + + #[test] + fn extgstate_fill_and_stroke_opacity_reach_the_layers() { + let resources = dictionary! { + "ExtGState" => dictionary! { + "GS0" => dictionary! { "ca" => 0.5, "CA" => 0.5 }, + }, + }; + let bytes = pdf_with_resources("/GS0 gs 1 0 0 rg 20 20 60 40 re f 0 0 1 RG 4 w 20 80 m 180 80 l S", resources); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("opacity.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + let LayerContent::Shape(fill) = &layers[0].content else { panic!("not a shape") }; + assert!((solid(fill.fill.as_ref().expect("a fill")).alpha - 0.5).abs() < 0.01); + let LayerContent::Shape(stroke) = &layers[1].content else { panic!("not a shape") }; + assert!((solid(&stroke.stroke.as_ref().expect("a stroke").paint).alpha - 0.5).abs() < 0.01); + } + + #[test] + fn odd_and_zero_dash_arrays_are_normalised() { + let mut stroke = craft_pdf::interpret::StrokeProps { + line_width: 2.0, + line_cap: Cap::Butt, + line_join: Join::Miter, + miter_limit: 10.0, + dash_array: Default::default(), + dash_offset: 0.0, + }; + stroke.dash_array.push(3.0); + let out = to_stroke(&stroke, Color::BLACK, 1.0).expect("a stroke"); + assert_eq!(out.dashes, vec![1.5, 1.5], "an odd array repeats"); + stroke.dash_array.clear(); + stroke.dash_array.push(0.0); + stroke.dash_array.push(4.0); + let out = to_stroke(&stroke, Color::BLACK, 1.0).expect("a stroke"); + assert_eq!(out.dashes, vec![0.0, 2.0], "zero-length dashes stay"); + } + + #[test] + fn fill_then_stroke_text_is_fake_bold_not_a_second_run() { + let bytes = text_pdf("BT /F1 24 Tf 2 Tr 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let texts: Vec<_> = children(&imported.document) + .iter() + .filter_map(|layer| match &layer.content { + LayerContent::Text(text) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 1, "one run, not a duplicate stroke run"); + assert_eq!(texts[0].text, "Hi"); + assert_eq!(texts[0].runs[0].style.weight, 700); + assert_eq!(texts[0].color, Color::rgb(1.0, 0.0, 0.0)); + } + + #[test] + fn font_family_rules_follow_the_base_font() { + assert_eq!(family_from_postscript("ABCDEF+TimesNewRomanPSMT"), "Times New Roman"); + assert_eq!(family_from_postscript("Arial-BoldMT"), "Arial"); + assert_eq!(family_from_postscript("Helvetica-Oblique"), "Helvetica"); + assert_eq!(family_from_postscript("MS-Gothic"), "MS-Gothic"); + assert_eq!(family_from_postscript("SimSun"), "Sim Sun"); + assert_eq!(family_from_postscript("Inter-Regular"), "Inter"); + assert_eq!(family_from_postscript("Roboto-Black"), "Roboto"); + } + + #[test] + fn the_editable_text_composite_matches_the_raster_page() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let raster = import_with_options_and_interrupt("text.pdf", &bytes, &ImportOptions { pdf_dpi: 72.0, ..ImportOptions::default() }, &Interrupt::NONE) + .expect("a raster import"); + let editable = import_with_options_and_interrupt( + "text.pdf", + &bytes, + &ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }, + &Interrupt::NONE, + ) + .expect("an editable import"); + let fmt = editable.document.pixel_format(); + let a = photocraft_compose::flatten_to_surface(&editable.document, fmt, None); + let b = photocraft_compose::flatten_to_surface(&raster.document, fmt, None); + let rect = editable.document.bounds(); + let av = a.read_region(rect); + let bv = b.read_region(rect); + let mut sum = 0.0f64; + let mut max = 0.0f32; + for (x, y) in av.iter().zip(&bv) { + let d = (x - y).abs(); + sum += f64::from(d); + max = max.max(d); + } + let mean = sum / av.len() as f64; + assert!(mean < 0.02, "mean pixel difference {mean:.4} (max {max:.3})"); + } + #[test] fn a_cancelled_editable_import_returns_cancelled() { let bytes = pdf("1 0 0 rg 20 20 60 40 re f"); diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 3907751046..0191c823a8 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -135,6 +135,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = dpi as f32; + #[cfg(feature = "pdf-editable")] + let mut text_engine: Option = None; let mut x: u32 = 0; for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { ctl.check().map_err(|_| IoError::Cancelled)?; @@ -150,7 +152,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); #[cfg(feature = "pdf-editable")] if opts.pdf_editable { - match crate::pdf_editable::page(page, rect, ctl) { + let engine = text_engine.get_or_insert_with(photocraft_text::TextEngine::with_system_fonts); + match crate::pdf_editable::page(page, rect, ctl, engine) { Ok(layers) => { let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4a4219e143..d0ec86f4d1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -142,6 +142,17 @@ impl TextEngine { layer.cache = Some(r.surface); r.rect } + + /// Registers font bytes (for example a font embedded in an imported PDF) for later layout. + /// Returns the family names the font provides, best first. + pub fn register_font_data(&mut self, bytes: Vec) -> Vec { + self.fonts.register_font_data(bytes) + } + + /// Whether `family` is available to this engine. + pub fn has_family(&mut self, family: &str) -> bool { + self.fonts.has_family(family) + } } static SHARED: warm::Warm> = warm::Warm::new(); From d1e95f57a81aaee5234bae6e3cc96bf1e84d4cc3 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:47:40 +0100 Subject: [PATCH 48/77] PDF: share embedded fonts and tighten text checks - Register imported fonts in photocraft_text::shared() so the app can re-lay text after edits or DPI changes. Prefer an installed full family, and register an embedded face under its PostScript name (key-suffixed when two faces share it) so a subset cannot shadow it. - Warn that text using PDF-embedded fonts may change font when edited or reopened; the document format does not carry the bytes yet. - Reject mirrored text and b == -c matrices; scale the tolerance by the em size so 90-degree text stays editable. Tests for rotated and mirrored text. - Use pdfcer's 0.30 em line tolerance. --- crates/io/src/pdf_editable.rs | 93 ++++++++++++++++++++++++++++------- crates/io/src/pdf_import.rs | 12 +++-- crates/text/src/fonts.rs | 6 +++ crates/text/src/lib.rs | 5 ++ 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 8d1beab04d..31228be45f 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -41,7 +41,7 @@ const POLL: Duration = Duration::from_millis(5); /// Largest gap (in em) inside one run before a space is inserted. const WORD_GAP: f64 = 0.2; /// Largest baseline offset (in em) for glyphs to share one line. -const LINE_TOLERANCE: f64 = 0.35; +const LINE_TOLERANCE: f64 = 0.30; /// Why an editable page could not be built. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -58,7 +58,7 @@ pub enum EditableError { /// `Page::render_dimensions()` (CropBox, rotation and UserUnit already applied), so a point maps /// to `rect` by the per-axis scale `rect.size / render_dimensions`. Nothing here re-applies the /// CropBox offset or rotation. -pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt, text: &mut photocraft_text::TextEngine) -> Result, EditableError> { +pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, EditableError> { ctl.check().map_err(|_| EditableError::Cancelled)?; let (width_pt, height_pt) = page.render_dimensions(); if !width_pt.is_finite() || !height_pt.is_finite() || width_pt <= 0.0 || height_pt <= 0.0 { @@ -123,7 +123,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt, text: &mut photocraft_ } let mut layers = device.layers; layers.extend(text_layers(&device.glyphs)); - render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts, text); + render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts); Ok(layers) } @@ -245,13 +245,28 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { /// Registers each embedded font with the text engine, renames the runs to the family the font /// itself reports, and renders the caches so the page is complete before the engine refreshes it. -fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, FontData)], engine: &mut photocraft_text::TextEngine) { +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, String, FontData)]) { + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + let mut counts = std::collections::HashMap::new(); + for (_, postscript, _, _) in fonts { + *counts.entry(postscript.as_str()).or_insert(0usize) += 1; + } let mut families = std::collections::HashMap::new(); - for (_, postscript, data) in fonts { - let names = engine.register_font_data(data.as_ref().as_ref().to_vec()); - if let Some(name) = names.first() { - families.insert(postscript.clone(), name.clone()); - } + for (key, postscript, derived, data) in fonts { + // Prefer an installed full family; a subset only holds the glyphs the page uses. The + // Device has the font program's PostScript name, not the PDF `/BaseFont`, so register + // the embedded face under that name (with a key suffix when two faces share it) rather + // than shadowing the real family. + let family = if engine.has_family(derived) { + derived.clone() + } else { + let alias = + if counts.get(postscript.as_str()).copied().unwrap_or(0) > 1 { format!("{postscript} {:08x}", *key as u32) } else { postscript.clone() }; + let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); + names.first().cloned().unwrap_or(alias) + }; + families.insert(postscript.clone(), family); } for layer in layers { if let LayerContent::Text(text) = &mut layer.content { @@ -284,8 +299,8 @@ struct PathDevice<'a> { glyphs: Vec, /// Stroked glyphs, paired with a fill at the same place to recognise fake bold. strokes: Vec, - /// Embedded fonts met on the page: cache key, PostScript name and bytes. - fonts: Vec<(u128, String, FontData)>, + /// Embedded fonts met on the page: cache key, PostScript name, derived family and bytes. + fonts: Vec<(u128, String, String, FontData)>, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, @@ -499,9 +514,11 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("text without a usable size"); return; } - // Skewed or mirrored text cannot be a point text layer; leave the page to raster. - if (a.abs() - d.abs()).abs() > 0.01 * a.abs().max(1.0e-9) || (b.abs() - c.abs()).abs() > 0.01 * a.abs().max(1.0e-9) { - self.unsupported("skewed text"); + // Skewed, mirrored or sheared text cannot be a point text layer; leave the page to raster. + // After the view's y flip an upright or rotated run has a ≈ -d and b ≈ c. + let tolerance = 0.01 * (a * d - b * c).abs().sqrt(); + if (a + d).abs() > tolerance || (b - c).abs() > tolerance { + self.unsupported("skewed or mirrored text"); return; } let origin = self.doc.apply(Point::new(e, f)); @@ -515,10 +532,11 @@ impl<'a> Device<'a> for PathDevice<'_> { let weight = font_weight(&data, &postscript); let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); let key = outline.font_cache_key(); - if !self.fonts.iter().any(|(existing, _, _)| *existing == key) { - self.fonts.push((key, postscript.clone(), data.data.clone())); + let family = family_from_postscript(&postscript); + if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { + self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } - let font = FontInfo { key, family: family_from_postscript(&postscript), postscript, weight, italic }; + let font = FontInfo { key, family, postscript, weight, italic }; let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false }; if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { self.strokes.push(glyph); @@ -1038,7 +1056,7 @@ mod tests { let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); let text = text_layer(&imported.document); assert_eq!(text.text, "Hi"); - assert_eq!(text.font_family, "Inter"); + assert!(text.font_family.starts_with("Inter"), "family {}", text.font_family); assert!((f64::from(text.size_pt) - 24.0).abs() < 0.1, "size {}", text.size_pt); assert_eq!(text.color, Color::rgb(1.0, 0.0, 0.0)); let origin = text.transform.apply(Point::new(0.0, 0.0)); @@ -1048,6 +1066,43 @@ mod tests { assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular")); assert_eq!(style.weight, 400); assert!(!style.italic); + assert!(imported.warnings.iter().any(|warning| warning.contains("fonts embedded in the PDF")), "{:?}", imported.warnings); + } + + #[test] + fn imported_text_relayouts_with_the_shared_engine() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + assert!(engine.has_family(&text.font_family), "the app engine must know the imported family"); + let rect = engine.render_layer(&mut text, 72.0, PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + assert!(rect.width() > 0 && rect.height() > 0, "the layer re-lays out"); + assert!(text.cache.is_some()); + } + + #[test] + fn rotated_text_stays_editable() { + let bytes = text_pdf("BT /F1 24 Tf 0 1 -1 0 100 20 Tm (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("rotated.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(!imported.warnings.iter().any(|warning| warning.contains("skewed or mirrored")), "{:?}", imported.warnings); + assert_eq!(text_layer(&imported.document).text, "Hi"); + } + + #[test] + fn mirrored_text_falls_back() { + let bytes = text_pdf("BT /F1 24 Tf -1 0 0 1 100 50 Tm (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("mirror.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("skewed or mirrored text") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); } #[test] @@ -1059,7 +1114,7 @@ mod tests { let style = &text_layer(&imported.document).runs[0].style; assert_eq!(style.weight, weight, "{name}"); assert_eq!(style.italic, italic, "{name}"); - assert_eq!(style.font_family, "Inter", "{name}"); + assert!(style.font_family.starts_with("Inter"), "{name}: {}", style.font_family); } } diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 0191c823a8..b55da30bec 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -136,7 +136,7 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = dpi as f32; #[cfg(feature = "pdf-editable")] - let mut text_engine: Option = None; + let mut editable_text = false; let mut x: u32 = 0; for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { ctl.check().map_err(|_| IoError::Cancelled)?; @@ -152,9 +152,11 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); #[cfg(feature = "pdf-editable")] if opts.pdf_editable { - let engine = text_engine.get_or_insert_with(photocraft_text::TextEngine::with_system_fonts); - match crate::pdf_editable::page(page, rect, ctl, engine) { + match crate::pdf_editable::page(page, rect, ctl) { Ok(layers) => { + if layers.iter().any(|layer| matches!(&layer.content, LayerContent::Text(_))) { + editable_text = true; + } let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); x = x.saturating_add(width).saturating_add(GAP); @@ -225,6 +227,10 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - x = x.saturating_add(width).saturating_add(GAP); ctl.progress((index + 1) as f32 / count as f32); } + #[cfg(feature = "pdf-editable")] + if editable_text { + warnings.push("PDF text uses fonts embedded in the PDF; it may change font when edited or reopened".into()); + } Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 416ae7834c..489f2eef5e 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -295,6 +295,12 @@ impl FontDb { self.register_blob(Blob::new(Arc::new(bytes)), None) } + /// [`Self::register_font_data`] with the family name overridden, so an embedded subset can be + /// registered under a name that cannot shadow an installed full family. Returns the names added. + pub fn register_font_data_as(&mut self, bytes: Vec, family: &str) -> Vec { + self.register_blob(Blob::new(Arc::new(bytes)), Some(family)) + } + /// Registers embedded font data without copying it. fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec { self.register_blob(Blob::new(Arc::new(bytes)), None) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d0ec86f4d1..75f3348b08 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -149,6 +149,11 @@ impl TextEngine { self.fonts.register_font_data(bytes) } + /// [`Self::register_font_data`] under `family`, which cannot shadow an installed family. + pub fn register_font_data_as(&mut self, bytes: Vec, family: &str) -> Vec { + self.fonts.register_font_data_as(bytes, family) + } + /// Whether `family` is available to this engine. pub fn has_family(&mut self, family: &str) -> bool { self.fonts.has_family(family) From d91ea1911e8811bc22924a7981455f059aaca612 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:52:37 +0100 Subject: [PATCH 49/77] PDF: content-key embedded font aliases - Always suffix an embedded face's alias with a hash of its bytes, so the same PostScript name from different documents cannot collide in the process-wide shared engine; a face imported twice reuses its entry. Registration is skipped when the alias or an installed full family is already present. - Release the shared text engine lock between layers so a text-heavy import does not block the UI's layout for the whole cache loop. - Test: two documents with one font name but different bytes get distinct aliases and both families are present in the shared engine. --- crates/io/src/pdf_editable.rs | 101 ++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 31228be45f..4f5cafc436 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -247,26 +247,27 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { /// itself reports, and renders the caches so the page is complete before the engine refreshes it. fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, String, FontData)]) { let shared = photocraft_text::shared(); - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let mut counts = std::collections::HashMap::new(); - for (_, postscript, _, _) in fonts { - *counts.entry(postscript.as_str()).or_insert(0usize) += 1; - } let mut families = std::collections::HashMap::new(); - for (key, postscript, derived, data) in fonts { - // Prefer an installed full family; a subset only holds the glyphs the page uses. The - // Device has the font program's PostScript name, not the PDF `/BaseFont`, so register - // the embedded face under that name (with a key suffix when two faces share it) rather - // than shadowing the real family. - let family = if engine.has_family(derived) { - derived.clone() - } else { - let alias = - if counts.get(postscript.as_str()).copied().unwrap_or(0) > 1 { format!("{postscript} {:08x}", *key as u32) } else { postscript.clone() }; - let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); - names.first().cloned().unwrap_or(alias) - }; - families.insert(postscript.clone(), family); + { + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + for (key, postscript, derived, data) in fonts { + // Prefer an installed full family; a subset only holds the glyphs the page uses. The + // Device has the font program's PostScript name, not the PDF `/BaseFont`; a + // content-keyed alias keeps two documents' subsets of one name apart for the life of + // the shared engine, and the same face imported twice reuses its registration. + let family = if engine.has_family(derived) { + derived.clone() + } else { + let alias = font_alias(postscript, *key); + if engine.has_family(&alias) { + alias + } else { + let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); + names.first().cloned().unwrap_or(alias) + } + }; + families.insert(postscript.clone(), family); + } } for layer in layers { if let LayerContent::Text(text) = &mut layer.content { @@ -277,12 +278,29 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: & } } if !text.text.trim().is_empty() { + // Re-lock per layer so a text-heavy import does not hold the UI's engine for the + // whole cache-render loop. + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); let _ = engine.render_layer(text, dpi as f32, fmt); } } } } +/// A content key for an embedded face, used to group its glyphs and to keep aliases unique. +fn font_content_key(data: &OutlineFontData) -> u128 { + let hash = blake3::hash(data.data.as_ref().as_ref()); + let mut key = [0u8; 16]; + key.copy_from_slice(&hash.as_bytes()[..16]); + u128::from_le_bytes(key) +} + +/// The family alias an embedded face is registered under: its PostScript name plus a content +/// key, so subsets with the same name from different documents cannot replace each other. +fn font_alias(postscript: &str, key: u128) -> String { + format!("{postscript} {key:016x}") +} + /// Collects filled and stroked paths and glyph runs; anything else marks the page unsupported. struct PathDevice<'a> { /// Page points to document pixels: translation to the artboard plus the per-axis scale. @@ -531,7 +549,7 @@ impl<'a> Device<'a> for PathDevice<'_> { let advance = f64::from(outline.advance_width().unwrap_or(500.0)) / 1000.0 * size_px; let weight = font_weight(&data, &postscript); let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); - let key = outline.font_cache_key(); + let key = font_content_key(&data); let family = family_from_postscript(&postscript); if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); @@ -854,6 +872,9 @@ mod tests { use super::*; use crate::{ImportOptions, IoError, import_with_options_and_interrupt}; + /// The embedded test font. + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + /// A one-page 200 x 100 pt PDF whose content stream is `content`. fn pdf(content: &str) -> Vec { pdf_with_resources(content, dictionary! {}) @@ -895,10 +916,14 @@ mod tests { /// [`text_pdf`] with the font named `name` (the same Inter bytes, so tests can exercise the /// name-based style rules). fn text_pdf_named(content: &str, name: &str) -> Vec { - const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + text_pdf_bytes(content, name, INTER) + } + + /// [`text_pdf`] with the font named `name` and the given font bytes. + fn text_pdf_bytes(content: &str, name: &str, font_bytes: &[u8]) -> Vec { let mut pdf = PdfDocument::with_version("1.5"); let pages_id = pdf.new_object_id(); - let font_file = pdf.add_object(Stream::new(dictionary! { "Length1" => INTER.len() as i64 }, INTER.to_vec())); + let font_file = pdf.add_object(Stream::new(dictionary! { "Length1" => font_bytes.len() as i64 }, font_bytes.to_vec())); let descriptor = pdf.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => Object::Name(name.as_bytes().to_vec()), @@ -1093,6 +1118,38 @@ mod tests { assert_eq!(text_layer(&imported.document).text, "Hi"); } + #[test] + fn font_aliases_include_the_content_key() { + assert_ne!(font_alias("Inter-Regular", 1), font_alias("Inter-Regular", 2)); + assert!(font_alias("Inter-Regular", 0x12ab).contains("Inter-Regular")); + } + + #[test] + fn two_documents_with_one_font_name_keep_distinct_aliases() { + // Rename the bundled Inter family to one that is not installed, so the embedded face + // takes the alias path; same PostScript name and family, different bytes (a trailing byte + // changes the content key), as two different subsets of one font would. + let mut base = INTER.to_vec(); + for at in 0..base.len().saturating_sub(5) { + if &base[at..at + 5] == b"Inter" { + base[at..at + 5].copy_from_slice(b"Xntr"); + } + } + let mut other = base.clone(); + other.push(0); + let a = text_pdf_bytes("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", "Xntr-Regular", &base); + let b = text_pdf_bytes("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", "Xntr-Regular", &other); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let first = import_with_options_and_interrupt("a.pdf", &a, &options, &Interrupt::NONE).expect("first import"); + let second = import_with_options_and_interrupt("b.pdf", &b, &options, &Interrupt::NONE).expect("second import"); + let fa = text_layer(&first.document).font_family.clone(); + let fb = text_layer(&second.document).font_family.clone(); + assert_ne!(fa, fb, "the two embedded faces must not share an alias"); + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + assert!(engine.has_family(&fa) && engine.has_family(&fb)); + } + #[test] fn mirrored_text_falls_back() { let bytes = text_pdf("BT /F1 24 Tf -1 0 0 1 100 50 Tm (Hi) Tj ET"); From 92ccd8b7d682061082a8cafd1de15c3f7f1d751f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 23:04:32 -0500 Subject: [PATCH 50/77] Open as Smart Object: a damage-only hold is admitted as a smart object; the name is taken again at admission (#12212 review) Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- .../src/cmd/file_cmds/open_as_smart_object.rs | 9 +++--- .../file_cmds/open_as_smart_object_tests.rs | 15 +++++++++ crates/ui-egui/src/dialogs/open_prompt.rs | 6 ++++ crates/ui-egui/src/psd_open_tests.rs | 31 +++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index 517252b25a..f89cc5aef1 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -95,11 +95,10 @@ fn apply( /// history is the single state "Open As Smart Object", as File › Open's is one state. pub fn admit_as_smart_object(s: &mut Session, mut doc: Document) -> usize { let layer_name = doc.layers.first().map(|l| l.name.clone()); - // The number was free when the file was read, but a document held for its prompt did not count - // then: take the next free one now, as each document is admitted. - if let Some(base) = &layer_name - && s.documents().iter().any(|d| d.doc.name == doc.name) - { + // The number was free when the file was read, but documents held for their prompts did not count + // then, and one of them may have been cancelled since: take the first free one now, as each + // document is admitted. + if let Some(base) = &layer_name { doc.name = untitled_name(base, |n| s.documents().iter().any(|d| d.doc.name == n)); } // Untitled: no path, so Save asks where to save. diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 230c75afe8..52493876db 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -334,3 +334,18 @@ fn a_document_held_for_its_prompt_does_not_share_its_name_with_the_next() { admit_as_smart_object(&mut t, d); assert_eq!(names(&t), ["a_test-1", "a_test-2"], "the second takes the next free number when admitted"); } + +#[test] +fn a_cancelled_hold_frees_its_number_for_the_one_admitted_after_it() { + let dir = tmp("cancelled-hold"); + let path = write_psd16(&dir, "photo.psd", 20, 20); + let mut s = Session::new(); + let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!((first["name"].as_str(), second["name"].as_str()), (Some("photo-1"), Some("photo-2"))); + // photo-1 is cancelled while photo-2 waits; photo-2 is admitted with nothing open. + s.take_held_open(first["held"].as_u64().unwrap()).unwrap(); + let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap(); + admit_as_smart_object(&mut s, b); + assert_eq!(names(&s), ["photo-1"], "the first free number is taken at admission, with no collision"); +} diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 145ae4ce84..a34eae8ecd 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -335,6 +335,12 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["dmg-1", "dmg-2"]); + for st in app.session.documents() { + assert_eq!(st.history.entries(), ["Open As Smart Object"]); + } + let _ = std::fs::remove_dir_all(&dir); +} From 7c36a5c9e935e3960482833312f7d7311aaef814 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 23:48:22 -0500 Subject: [PATCH 51/77] Open as Smart Object: the reply names the document as admitted (#12212 review) When an earlier document is held for its profile prompt and the next open skips the prompt, admission numbers the second document photo-1 again, but the reply reported the name read before admission (photo-2). The reply now reads the name from the admitted document. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- .../src/cmd/file_cmds/open_as_smart_object.rs | 2 ++ .../cmd/file_cmds/open_as_smart_object_tests.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index f89cc5aef1..57bd8ce39f 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -87,6 +87,8 @@ fn apply( let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag); let index = admit_as_smart_object(s, doc); let id = s.documents().get(index).map(|st| st.doc.id.0); + // Admission numbers the document again, so the reply reads the name it was admitted under. + let name = s.documents().get(index).map_or(name, |st| st.doc.name.clone()); Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color, "smartObject": true})) } diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 52493876db..c205af3d71 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -349,3 +349,19 @@ fn a_cancelled_hold_frees_its_number_for_the_one_admitted_after_it() { admit_as_smart_object(&mut s, b); assert_eq!(names(&s), ["photo-1"], "the first free number is taken at admission, with no collision"); } + +#[test] +fn the_reply_names_the_document_as_admitted() { + let dir = tmp("reply-name"); + let path = write_psd16(&dir, "photo.psd", 20, 20); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap(); + let held = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(held["name"], "photo-1", "{held}"); + assert!(held["held"].is_u64(), "{held}"); + // The next open skips the prompt: photo-1 is held, so the number read was 2, but admission takes the first free one. + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(names(&s), ["photo-1"], "{r}"); + assert_eq!(r["name"], "photo-1", "the reply carries the name the document was admitted under: {r}"); +} From a3778b44766aea05e01cd3e3e62e206c0eb16fb0 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:13:29 +0100 Subject: [PATCH 52/77] PDF: address editable-import review findings - Colours: AlphaColor components are straight, not premultiplied; use them directly instead of dividing by alpha (the opacity test now checks RGB, not just alpha). - Text: key families by font content key, not PostScript name, so two faces with one name cannot share a family. Keep text layers at the path index where their glyphs were painted instead of appending them after all paths. - Correctness: reject bow-tie clip paths, start a new subpath after close, fall back on unparseable/unsupported glyph mappings, truncated content, failed font registration, and shape-cache budget exhaustion. - Cancellation: check between text-cache renders as well as between objects. - Include Bezier control points in the cache-size estimate; cache the content hash per Hayro font identity. - Docs: ImportOptions/CLI help now describe editable text and the embedded-font caveat. Tests: text cache is not blank, paint order, bow-tie clip, line after close, cancellation between objects. --- apps/photocraft-cli/src/args.rs | 3 +- crates/io/src/lib.rs | 9 +- crates/io/src/pdf_editable.rs | 287 ++++++++++++++++++++++++-------- 3 files changed, 227 insertions(+), 72 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index c450c94442..2baf000039 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -125,7 +125,8 @@ pub(crate) struct ConvertArgs { /// PDF pages to import, 1-based: "3" or "2-7" (default all) #[arg(long)] pub(crate) pdf_pages: Option, - /// Import PDF vector paths as editable shape layers (experimental; text still rasterises) + /// Import PDF paths and embedded-font text as editable layers (experimental; unsupported + /// pages fall back to raster, and embedded fonts may change when edited or reopened) #[arg(long)] pub(crate) pdf_editable: bool, #[command(flatten)] diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 40dd1c0755..c796b7cffa 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -614,9 +614,12 @@ pub struct ImportOptions { pub pdf_dpi: f32, /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). pub pdf_pages: Option<(u32, u32)>, - /// Import PDF painted paths as editable [`photocraft_doc::ShapeLayer`]s instead of a page - /// raster (experimental). Text, images, clipping paths and the other unsupported constructs - /// make that page fall back to the raster importer with a warning. Off by default. + /// Import PDF painted paths and embedded-font text as editable [`photocraft_doc::ShapeLayer`]s + /// and text layers instead of a page raster (experimental). Images, complex clips/masks, + /// patterns, non-normal blends and unembedded/Type 3 text make that page fall back to the + /// raster importer with a warning. Text that uses fonts embedded in the PDF may change font + /// when edited or reopened, because the document format does not carry the font bytes. Off by + /// default. pub pdf_editable: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 4f5cafc436..1b91a4ff61 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -10,16 +10,17 @@ //! is what makes moving or deleting a layer behave, and what avoids the black-path and //! doubled-text failures of the old PDFium import. +use std::collections::HashMap; use std::panic::AssertUnwindSafe; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::time::Duration; use craft_pdf::interpret::color::Color as HayroColor; use craft_pdf::interpret::font::{FontData, Glyph, OutlineFontData}; use craft_pdf::interpret::{ - BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, - interpret_page, + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, SoftMask, + TransformExt, interpret_page, }; use craft_pdf::syntax::page::Page; use kurbo::{Affine as KurboAffine, BezPath, Cap, Join, PathEl}; @@ -70,7 +71,20 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); let cancelled = Arc::new(AtomicBool::new(false)); let finished = Arc::new(AtomicBool::new(false)); - let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; + let warned = Arc::new(AtomicU8::new(0)); + let sink = Arc::clone(&warned); + let settings = InterpreterSettings { + cancelled: Some(Arc::clone(&cancelled)), + warning_sink: Arc::new(move |warning| { + let bit = match warning { + InterpreterWarning::ImageDecodeFailure => 1, + InterpreterWarning::UnsupportedFont => 2, + InterpreterWarning::ContentTruncated => 4, + }; + sink.fetch_or(bit, Ordering::Relaxed); + }), + ..InterpreterSettings::default() + }; let cache = InterpreterCache::new(); let mut device = PathDevice { doc, @@ -82,6 +96,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, glyphs: Vec::new(), strokes: Vec::new(), fonts: Vec::new(), + font_keys: HashMap::new(), clips: Vec::new(), knots: 0, cached: 0, @@ -115,15 +130,25 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, if device.cancelled || ctl.cancelled() { return Err(EditableError::Cancelled); } + let warned = warned.load(Ordering::Relaxed); + if warned & 4 != 0 { + return Err(EditableError::Unsupported("the page content was truncated")); + } + if warned & 2 != 0 { + return Err(EditableError::Unsupported("a font could not be used")); + } + if warned & 1 != 0 { + return Err(EditableError::Unsupported("an image could not be decoded")); + } if let Some(reason) = device.unsupported { return Err(EditableError::Unsupported(reason)); } if let Err(reason) = pair_fake_bold(&mut device.glyphs, device.strokes) { return Err(EditableError::Unsupported(reason)); } - let mut layers = device.layers; - layers.extend(text_layers(&device.glyphs)); - render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts); + let families = register_fonts(&device.fonts)?; + let mut layers = merge_text_layers(device.layers, text_layers(&device.glyphs, &families)); + render_text_caches(&mut layers, device.dpi, device.fmt, ctl)?; Ok(layers) } @@ -150,12 +175,14 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), Ok(()) } -/// Builds one point-text layer per line and style run. -fn text_layers(glyphs: &[GlyphRun]) -> Vec { +/// Builds one point-text layer per line and style run, with the layer index it must be inserted +/// at to preserve paint order relative to paths. +fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap) -> Vec<(usize, Layer)> { let mut out = Vec::new(); let mut i = 0; while i < glyphs.len() { let first = &glyphs[i]; + let family = families.get(&first.font.key).cloned().unwrap_or_else(|| first.font.family.clone()); let (dx, dy) = (first.angle.cos(), first.angle.sin()); let (nx, ny) = (-dy, dx); let baseline = first.origin.x * nx + first.origin.y * ny; @@ -164,7 +191,7 @@ fn text_layers(glyphs: &[GlyphRun]) -> Vec { let mut last = first; while i < glyphs.len() { let glyph = &glyphs[i]; - if !same_style(glyph, first) { + if glyph.order != first.order || !same_style(glyph, first) { break; } let line = glyph.origin.x * nx + glyph.origin.y * ny; @@ -188,7 +215,7 @@ fn text_layers(glyphs: &[GlyphRun]) -> Vec { } let text = text.trim().to_string(); if !text.is_empty() { - out.push(text_layer(text, first, out.len())); + out.push((first.order, text_layer(text, first, out.len(), &family))); } if i == start { i += 1; @@ -212,8 +239,8 @@ fn same_style(a: &GlyphRun, b: &GlyphRun) -> bool { && (a.color.alpha - b.color.alpha).abs() < 0.004 } -/// One line's point-text layer. -fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { +/// One line's point-text layer, with the family the shared engine registered. +fn text_layer(text: String, run: &GlyphRun, number: usize, family: &str) -> Layer { let weight = if run.fake_bold { 700 } else { run.font.weight }; let font_style = match (weight >= 600, run.font.italic) { (true, true) => "Bold Italic", @@ -222,18 +249,19 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { (false, false) => "Regular", }; let style = CharStyle { - font_family: run.font.family.clone(), + font_family: family.to_string(), font_style: font_style.to_string(), postscript_name: Some(run.font.postscript.clone()), weight: u16::try_from(weight).unwrap_or(400), italic: run.font.italic, + faux_bold: run.fake_bold, size_pt: run.size_pt as f32, color: run.color, ..CharStyle::default() }; let layer = TextLayer { text: text.clone(), - font_family: run.font.family.clone(), + font_family: family.to_string(), size_pt: run.size_pt as f32, color: run.color, transform: Affine::translate(run.origin.x, run.origin.y).mul(&Affine::rotate(run.angle)), @@ -243,48 +271,70 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { Layer::new(format!("Text {}", number + 1), LayerContent::Text(layer)) } -/// Registers each embedded font with the text engine, renames the runs to the family the font -/// itself reports, and renders the caches so the page is complete before the engine refreshes it. -fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, String, FontData)]) { +/// Registers each embedded face with the shared engine under its content-keyed family and returns +/// the family each font key must use. +fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result, EditableError> { let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); let mut families = std::collections::HashMap::new(); - { - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - for (key, postscript, derived, data) in fonts { - // Prefer an installed full family; a subset only holds the glyphs the page uses. The - // Device has the font program's PostScript name, not the PDF `/BaseFont`; a - // content-keyed alias keeps two documents' subsets of one name apart for the life of - // the shared engine, and the same face imported twice reuses its registration. - let family = if engine.has_family(derived) { - derived.clone() + for (key, postscript, derived, data) in fonts { + // Prefer an installed full family; a subset only holds the glyphs the page uses. The + // Device has the font program's PostScript name, not the PDF `/BaseFont`; a content-keyed + // alias keeps two documents' subsets of one name apart for the life of the shared engine, + // and the same face imported twice reuses its registration. + let family = if engine.has_family(derived) { + derived.clone() + } else { + let alias = font_alias(postscript, *key); + if engine.has_family(&alias) { + alias } else { - let alias = font_alias(postscript, *key); - if engine.has_family(&alias) { - alias - } else { - let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); - names.first().cloned().unwrap_or(alias) + let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); + if names.is_empty() && !engine.has_family(&alias) { + return Err(EditableError::Unsupported("a font could not be registered")); } - }; - families.insert(postscript.clone(), family); - } + names.first().cloned().unwrap_or(alias) + } + }; + families.insert(*key, family); } + Ok(families) +} + +/// Renders the text caches so the page is complete before the engine refreshes it. +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &Interrupt) -> Result<(), EditableError> { + let shared = photocraft_text::shared(); for layer in layers { - if let LayerContent::Text(text) = &mut layer.content { - if let Some(family) = text.runs.first().and_then(|run| run.style.postscript_name.as_ref()).and_then(|postscript| families.get(postscript)) { - text.font_family.clone_from(family); - if let Some(run) = text.runs.first_mut() { - run.style.font_family.clone_from(family); - } - } - if !text.text.trim().is_empty() { - // Re-lock per layer so a text-heavy import does not hold the UI's engine for the - // whole cache-render loop. - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let _ = engine.render_layer(text, dpi as f32, fmt); - } + if ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + if let LayerContent::Text(text) = &mut layer.content + && !text.text.trim().is_empty() + { + // Re-lock per layer so a text-heavy import does not hold the UI's engine for the + // whole cache-render loop. + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + let _ = engine.render_layer(text, dpi as f32, fmt); } } + Ok(()) +} + +/// Puts text layers back at the path index where their glyphs were painted. +fn merge_text_layers(layers: Vec, mut texts: Vec<(usize, Layer)>) -> Vec { + texts.sort_by_key(|(order, _)| *order); + let mut out = Vec::with_capacity(layers.len() + texts.len()); + let mut texts = texts.into_iter().peekable(); + for (index, layer) in layers.into_iter().enumerate() { + while texts.peek().is_some_and(|(order, _)| *order <= index) { + if let Some((_, text)) = texts.next() { + out.push(text); + } + } + out.push(layer); + } + out.extend(texts.map(|(_, layer)| layer)); + out } /// A content key for an embedded face, used to group its glyphs and to keep aliases unique. @@ -319,6 +369,8 @@ struct PathDevice<'a> { strokes: Vec, /// Embedded fonts met on the page: cache key, PostScript name, derived family and bytes. fonts: Vec<(u128, String, String, FontData)>, + /// Hayro's cheap font identity → our content hash, so bytes are hashed once per face. + font_keys: HashMap, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, @@ -341,6 +393,9 @@ struct GlyphRun { /// Advance in document pixels. advance: f64, font: FontInfo, + /// How many layers had been drawn when this glyph was painted; text runs split at a path + /// between glyphs so paint order is preserved. + order: usize, color: Color, /// Drawn both filled and stroked: word-processor fake bold. fake_bold: bool, @@ -397,7 +452,11 @@ impl PathDevice<'_> { .intersect(&self.clip); let bytes = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.fmt.bytes_per_pixel() as u64); let mut shape = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; - if area.width() > 0 && area.height() > 0 && self.cached.saturating_add(bytes) <= MAX_CACHE_BYTES { + if area.width() > 0 && area.height() > 0 { + if self.cached.saturating_add(bytes) > MAX_CACHE_BYTES { + self.unsupported("shape cache budget"); + return; + } shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, self.clip)); self.cached = self.cached.saturating_add(bytes); } @@ -495,7 +554,11 @@ impl<'a> Device<'a> for PathDevice<'_> { let text = match glyph.as_unicode() { Some(craft_pdf::interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), Some(craft_pdf::interpret::hayro_cmap::BfString::String(s)) => s, - None => return, + None => { + // No Unicode means the text layer could not be edited faithfully. + self.unsupported("text without a Unicode mapping"); + return; + } }; if text.is_empty() || text.chars().all(|c| c.is_control()) { return; @@ -549,13 +612,14 @@ impl<'a> Device<'a> for PathDevice<'_> { let advance = f64::from(outline.advance_width().unwrap_or(500.0)) / 1000.0 * size_px; let weight = font_weight(&data, &postscript); let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); - let key = font_content_key(&data); + let identity = outline.font_cache_key(); + let key = *self.font_keys.entry(identity).or_insert_with(|| font_content_key(&data)); let family = family_from_postscript(&postscript); if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } let font = FontInfo { key, family, postscript, weight, italic }; - let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false }; + let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false, order: self.layers.len() }; if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { self.strokes.push(glyph); } else { @@ -700,6 +764,18 @@ fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { if points.iter().any(|(x, y)| !on_edge(*x, x0, x1) || !on_edge(*y, y0, y1)) { return None; } + // A bow-tie traversal also visits the four corners, so require its shoelace area to match + // the bounding box (a real rectangle covers it exactly). + let mut twice_area = 0.0; + for i in 0..4 { + let (ax, ay) = points[i]; + let (bx, by) = points[(i + 1) % 4]; + twice_area += ax * by - bx * ay; + } + let box_area = (x1 - x0) * (y1 - y0); + if (twice_area.abs() * 0.5 - box_area).abs() > box_area * 1.0e-3 { + return None; + } Some((x0, y0, x1, y1)) } @@ -719,13 +795,10 @@ fn uniform_transform(transform: &KurboAffine) -> bool { /// A PDF colour as a straight-alpha PhotoCraft colour. fn to_color(color: &HayroColor) -> Color { let rgba = color.to_rgba(); + // `AlphaColor` components are straight (not premultiplied), despite the raster path being + // premultiplied: `Color::to_rgba8` reads them as straight too. let [r, g, b, a] = rgba.components(); - let a = a.clamp(0.0, 1.0); - if a <= 0.0 { - return Color::rgba(0.0, 0.0, 0.0, 0.0); - } - // `AlphaColor` components are premultiplied; PhotoCraft colours are straight. - Color::rgba((r / a).clamp(0.0, 1.0), (g / a).clamp(0.0, 1.0), (b / a).clamp(0.0, 1.0), a) + Color::rgba(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), a.clamp(0.0, 1.0)) } /// A kurbo affine as PhotoCraft's affine (same `[a, b, c, d, e, f]` layout). @@ -738,10 +811,12 @@ fn knots_affine(transform: &KurboAffine) -> Affine { fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - b.0 = b.0.min(knot.anchor.x); - b.1 = b.1.min(knot.anchor.y); - b.2 = b.2.max(knot.anchor.x); - b.3 = b.3.max(knot.anchor.y); + for point in [knot.anchor, knot.in_ctrl, knot.out_ctrl] { + b.0 = b.0.min(point.x); + b.1 = b.1.min(point.y); + b.2 = b.2.max(point.x); + b.3 = b.3.max(point.y); + } } b } @@ -792,7 +867,9 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option for element in bez.elements() { match *element { PathEl::MoveTo(p) => { - if let Some(subpath) = current.take() { + if let Some(subpath) = current.take() + && subpath.knots.len() > 1 + { subpaths.push(subpath); } current = Some(Subpath { closed: false, knots: vec![Knot::corner(p.x, p.y)], op: PathOp::Combine }); @@ -833,6 +910,7 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option } PathEl::ClosePath => { if let Some(mut subpath) = current.take() { + let restart = subpath.knots.first().map(|knot| knot.anchor); subpath.closed = true; if subpath.knots.len() > 1 { let first = subpath.knots[0].anchor; @@ -844,13 +922,21 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option } else { subpath.knots[0].in_ctrl = first; } + subpaths.push(subpath); + } + // A close starts a new subpath at the closed subpath's start point; a + // following line/curve continues from there (PDF 32000-1 §8.5.2.1). + if let Some(start) = restart { + current = Some(Subpath { closed: false, knots: vec![Knot::corner(start.x, start.y)], op: PathOp::Combine }); + last = start; } - subpaths.push(subpath); } } } } - if let Some(subpath) = current { + if let Some(subpath) = current + && subpath.knots.len() > 1 + { subpaths.push(subpath); } *knots = knots.saturating_add(subpaths.iter().map(|subpath| subpath.knots.len()).sum::()); @@ -1224,12 +1310,14 @@ mod tests { "GS0" => dictionary! { "ca" => 0.5, "CA" => 0.5 }, }, }; - let bytes = pdf_with_resources("/GS0 gs 1 0 0 rg 20 20 60 40 re f 0 0 1 RG 4 w 20 80 m 180 80 l S", resources); + let bytes = pdf_with_resources("/GS0 gs 0.2 0.4 0.6 rg 20 20 60 40 re f 0 0 1 RG 4 w 20 80 m 180 80 l S", resources); let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("opacity.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); let layers = children(&imported.document); let LayerContent::Shape(fill) = &layers[0].content else { panic!("not a shape") }; - assert!((solid(fill.fill.as_ref().expect("a fill")).alpha - 0.5).abs() < 0.01); + let fill = solid(fill.fill.as_ref().expect("a fill")); + assert!((fill.alpha - 0.5).abs() < 0.01); + assert!((fill.c[0] - 0.2).abs() < 0.01 && (fill.c[1] - 0.4).abs() < 0.01 && (fill.c[2] - 0.6).abs() < 0.01, "{fill:?}"); let LayerContent::Shape(stroke) = &layers[1].content else { panic!("not a shape") }; assert!((solid(&stroke.stroke.as_ref().expect("a stroke").paint).alpha - 0.5).abs() < 0.01); } @@ -1310,6 +1398,69 @@ mod tests { } let mean = sum / av.len() as f64; assert!(mean < 0.02, "mean pixel difference {mean:.4} (max {max:.3})"); + // The cache must actually contain text: a blank layer can still pass a mean comparison. + let channels = fmt.channels(); + let width = rect.width() as usize; + let mut red = 0; + for y in 30..60usize { + for x in 20..45usize { + let at = (y * width + x) * channels; + if av[at] > 0.4 && av[at + 1] < 0.4 && av[at + 2] < 0.4 { + red += 1; + } + } + } + assert!(red > 0, "the editable text cache is blank"); + } + + #[test] + fn text_keeps_paint_order_relative_to_paths() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("order.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + assert!(matches!(layers[0].content, LayerContent::Text(_)), "text was painted first"); + assert!(matches!(layers[1].content, LayerContent::Shape(_)), "the covering path comes after"); + } + + #[test] + fn a_bow_tie_path_is_not_a_no_op_clip() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((10.0, 10.0)); + path.line_to((10.0, 0.0)); + path.line_to((0.0, 10.0)); + path.close_path(); + assert!(axis_aligned_rect(&path).is_none()); + } + + #[test] + fn a_line_after_close_starts_a_new_subpath() { + let mut bez = BezPath::new(); + bez.move_to((0.0, 0.0)); + bez.line_to((10.0, 0.0)); + bez.line_to((10.0, 10.0)); + bez.close_path(); + bez.line_to((20.0, 0.0)); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[1].knots[0].anchor, Point::new(0.0, 0.0)); + assert_eq!(path.subpaths[1].knots.last().expect("a knot").anchor, Point::new(20.0, 0.0)); + } + + #[test] + fn cancelling_between_objects_stops_the_editable_page() { + let mut content = String::from("1 0 0 rg\n"); + for i in 0..20 { + content.push_str(&format!("{} 20 20 20 re f\n", i * 8)); + } + let bytes = pdf(&content); + let calls = std::sync::atomic::AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 3; + let ctl = Interrupt::cancel_only(&cancel); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + assert!(matches!(import_with_options_and_interrupt("objects.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); } #[test] From 4f629dc4ddfa9a0b73d2b08f5854ae9ed6846458 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 00:57:48 -0500 Subject: [PATCH 53/77] Patch: in Destination mode the selection moves with the patch (#12119) A plain rectangular selection is translated by the drag offset in the same history state as the pixel change. Source, Content-Aware, feathered and non-rectangular selections keep today's behaviour. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 35 +++++++++-- .../patch_destination_selection_tests.rs | 58 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 1cfcd3bfca..b6bf3b18d6 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -211,16 +211,39 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal) } +/// Whether `sel` is a hard-edged rectangle exactly filling `area` (the only Destination selection +/// measured to move with the patch). +fn is_hard_rect(sel: &Surface, area: Rect) -> bool { + sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && sel.content_bounds() == area && coverage(sel, area).iter().all(|c| *c >= 1.0) +} + pub(super) fn patch(s: &mut Session, p: &Value) -> Result { if flag(p, "contentAware", false) { return patch_content_aware(s, p); } let plan = plan(s, p)?; let label = if plan.destination { "Patch (Destination)" } else { "Patch" }; - let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { - let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; - Ok(patch_surface(surf, sel, &plan, lock, 1)) - })?; + let dmg = if plan.destination { + // Destination: a plain rectangular selection moves with the patch, in the same history state. + let dmg = s.edit(label, |doc, _| { + let pre = doc.clone(); + let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; + let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; + let dmg = patch_surface(surf, sel, &plan, lock, 1); + if is_hard_rect(sel, plan.area) { + let (dx, dy) = plan.offset; + doc.selection = Some(sel.translated(dx, dy, sel.content_bounds())); + } + Ok(dmg) + })?; + s.hint_damage(dmg); + dmg + } else { + run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { + let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; + Ok(patch_surface(surf, sel, &plan, lock, 1)) + })? + }; Ok(json!({ "damage": damage_json(dmg), "offset": [plan.offset.0, plan.offset.1] })) } @@ -303,3 +326,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result { }, ) } + +#[cfg(test)] +#[path = "patch_destination_selection_tests.rs"] +mod patch_destination_selection_tests; diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs new file mode 100644 index 0000000000..3aa7879af0 --- /dev/null +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -0,0 +1,58 @@ +//! Patch in Destination mode moves a plain rectangular selection with the patch (#12119). + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "depth": 8, "mode": "rgb"})).unwrap(); + s.edit("setup", |doc, active| { + let b = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let v = if (x / 8 + y / 8) % 2 == 0 { 0.2 } else { 0.8 }; + let px = if x >= 400 { [0.1, 0.6, 0.2, 1.0] } else { [v, v, v, 1.0] }; + data.extend(photocraft_raster::from_rgba(&fmt, px)); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .unwrap(); + s +} + +fn bounds(s: &mut Session) -> Value { + s.execute("document.inspect", json!({})).unwrap()["selectionBounds"].clone() +} + +fn px(s: &Session, x: i32, y: i32) -> [f32; 4] { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) +} + +#[test] +fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { + let mut s = session(); + s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + let before = px(&s, 600, 420); + s.execute("paint.patch", json!({"mode": "destination", "offset": [460, 280]})).unwrap(); + assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); + assert_ne!(px(&s, 600, 420), before, "pixels changed too"); + // One history state: a single undo brings back the pixels and the old selection. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_eq!(px(&s, 600, 420), before); +} + +#[test] +fn source_and_content_aware_keep_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + s.execute("paint.patch", json!({"offset": [260, 0]})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + s.execute("paint.patch", json!({"offset": [260, 0], "contentAware": true})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); +} From 11607cfaade1cda9f4fc6ab39f607b543da4db9c Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 01:10:52 -0500 Subject: [PATCH 54/77] Patch: an arrow badge over the selection shows that a drag moves the patch (#12130) The Patch tool's cursor shows a new single right-pointing arrow badge over the selection (no Shift or Alt), also while a drag that started inside is in progress. Content-Aware Move keeps no badge and the Curvature Pen keeps its four-arrow badge. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/tool_feedback.rs | 12 +++- .../ui-egui/src/patch_cursor_badge_tests.rs | 56 +++++++++++++++++++ crates/ui-egui/src/tools/retouch_ui/tests.rs | 3 +- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/patch_cursor_badge_tests.rs diff --git a/crates/ui-egui/src/canvas/tool_feedback.rs b/crates/ui-egui/src/canvas/tool_feedback.rs index 6c9c890571..0b26dc5a25 100644 --- a/crates/ui-egui/src/canvas/tool_feedback.rs +++ b/crates/ui-egui/src/canvas/tool_feedback.rs @@ -19,6 +19,8 @@ pub enum Badge { Intersect, /// Four arrows: a press drags the point under the pointer (the Curvature Pen over an anchor). Move, + /// One right-pointing arrow: a press drags the patch (the Patch tool over the selection). + Arrow, } impl Badge { @@ -124,7 +126,7 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option Vec<[Pos2; 2]> { } v } + Badge::Arrow => { + let a = h * 0.6; + vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c + vec2(h, 0.0), c + vec2(h - a, a)], [c + vec2(h, 0.0), c + vec2(h - a, -a)]] + } Badge::Intersect => { let d = h * 0.8; vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]] @@ -319,3 +325,7 @@ mod tests { assert!(e.len() >= 16 && e.iter().all(|p| p.x >= -0.01 && p.x <= 100.01 && p.y >= -0.01 && p.y <= 50.01)); } } + +#[cfg(test)] +#[path = "../patch_cursor_badge_tests.rs"] +mod patch_cursor_badge_tests; diff --git a/crates/ui-egui/src/patch_cursor_badge_tests.rs b/crates/ui-egui/src/patch_cursor_badge_tests.rs new file mode 100644 index 0000000000..9612a51e4c --- /dev/null +++ b/crates/ui-egui/src/patch_cursor_badge_tests.rs @@ -0,0 +1,56 @@ +//! The Patch tool's cursor shows a right-pointing arrow over the selection (#12130). + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::Drag; +use crate::state::Tool; +use crate::tool_feedback::{Badge, badge, badge_glyph}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap(); + app +} + +const INSIDE: [f64; 2] = [30.0, 30.0]; +const OUTSIDE: [f64; 2] = [80.0, 80.0]; + +#[test] +fn arrow_over_the_selection_without_modifiers() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); + assert_ne!(Badge::Arrow, Badge::Move, "a new single-arrow glyph"); + let glyph = badge_glyph(Badge::Arrow, egui::pos2(0.0, 0.0), 4.0); + let tip = glyph.iter().flatten().map(|p| p.x).fold(f32::MIN, f32::max); + assert!(glyph.iter().flatten().all(|p| p.x <= tip) && glyph.len() == 3, "one shaft and two barbs pointing right"); +} + +#[test] +fn the_arrow_stays_during_a_drag_started_inside() { + let mut app = app(); + app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(OUTSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); +} + +#[test] +fn outside_and_with_shift_or_alt_the_badges_are_as_before() { + let mut app = app(); + app.hover_doc = Some(OUTSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), None); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::SHIFT), Some(Badge::Add)); + assert_eq!(badge(&app, Tool::Patch, Modifiers::ALT), Some(Badge::Subtract)); +} + +#[test] +fn content_aware_move_and_the_curvature_pen_keep_their_cursors() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::ContentAwareMove, Modifiers::NONE), None); + assert_eq!(badge_glyph(Badge::Move, egui::pos2(0.0, 0.0), 4.0).len(), 10, "four-arrow glyph unchanged"); +} diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index 375f8a410a..e68c8b99cf 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -587,7 +587,8 @@ fn patch_cursor_shows_the_selection_mode_badge() { app.ui.selection_mode = 1; assert_eq!(badge(&app, tool, none), Some(Badge::Add), "{tool:?}: the options-bar mode outside the selection"); app.hover_doc = Some([20.0, 30.0]); - assert_eq!(badge(&app, tool, none), None, "{tool:?}: a press over the selection drags the patch"); + let over = (tool == Tool::Patch).then_some(Badge::Arrow); + assert_eq!(badge(&app, tool, none), over, "{tool:?}: a press over the selection drags the patch (Patch shows an arrow, #12130)"); assert_eq!(badge(&app, tool, shift), Some(Badge::Add), "{tool:?}: ⇧ over the selection adds"); } } From b17c98f59539e3b792a79c9c737a16b97c4b3d68 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:17:09 +0100 Subject: [PATCH 55/77] PDF: second review round on editable import - A text cache that renders empty now falls back the page instead of leaving blank text. - Fake-bold strokes pair only with fill glyphs at the same paint order. - Retain single-point closed subpaths (round-cap dots) without emitting duplicate phantom points; test added. - Device methods documented; paint-order test covers text on both sides of a path. --- crates/io/src/pdf_editable.rs | 54 ++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 1b91a4ff61..f718ac7c22 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -160,6 +160,7 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), let tolerance = 0.01 * glyph.size_px + 0.01; if glyph.text == stroke.text && glyph.font.key == stroke.font.key + && glyph.order == stroke.order && (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { @@ -314,7 +315,10 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &In // Re-lock per layer so a text-heavy import does not hold the UI's engine for the // whole cache-render loop. let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let _ = engine.render_layer(text, dpi as f32, fmt); + let rect = engine.render_layer(text, dpi as f32, fmt); + if rect.width() == 0 || rect.height() == 0 { + return Err(EditableError::Unsupported("a text cache could not be rendered")); + } } } Ok(()) @@ -465,18 +469,21 @@ impl PathDevice<'_> { } impl<'a> Device<'a> for PathDevice<'_> { + /// A soft mask changes compositing in ways a plain layer cannot express. fn set_soft_mask(&mut self, mask: Option>) { if mask.is_some() { self.unsupported("soft masks"); } } + /// Only normal blending is editable. fn set_blend_mode(&mut self, blend_mode: BlendMode) { if blend_mode != BlendMode::Normal { self.unsupported("blend modes"); } } + /// Turns a filled/stroked path into a shape layer, rejecting paints and strokes it cannot keep. fn draw_path(&mut self, path: &BezPath, transform: KurboAffine, paint: &Paint<'a>, draw_mode: &PathDrawMode) { if self.unsupported.is_some() { return; @@ -520,6 +527,7 @@ impl<'a> Device<'a> for PathDevice<'_> { } } + /// Accepts an axis-aligned clip that contains the artboard; other clips fall back. fn push_clip_path(&mut self, clip_path: &ClipPath) { if self.no_op_clip(clip_path) { self.clips.push(true); @@ -529,6 +537,7 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("clipping paths"); } + /// Transparency groups are not represented by the layer model. fn push_transparency_group(&mut self, _opacity: f32, mask: Option>, blend_mode: BlendMode) { // A group needs its own blend/opacity/mask handling; until that exists the page is // rasterised rather than flattened into the wrong stacking order. @@ -536,6 +545,7 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("transparency groups"); } + /// Records a filled glyph as text, or its stroke copy for fake-bold pairing. fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: KurboAffine, glyph_transform: KurboAffine, paint: &Paint<'a>, draw_mode: &GlyphDrawMode) { if self.unsupported.is_some() { return; @@ -627,16 +637,20 @@ impl<'a> Device<'a> for PathDevice<'_> { } } + /// Images are not editable at this step. fn draw_image(&mut self, _image: Image<'a, '_>, _transform: KurboAffine) { self.unsupported("images"); } + /// Balances [`Self::push_clip_path`]. fn pop_clip_path(&mut self) { let _ = self.clips.pop(); } + /// Balances [`Self::push_transparency_group`]. fn pop_transparency_group(&mut self) {} + /// Annotations are not part of the page's editable paint. fn begin_annotations(&mut self) { self.unsupported("annotations"); } @@ -863,15 +877,17 @@ fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f6 fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option { let mut subpaths = Vec::new(); let mut current: Option = None; + let mut restarted = false; let mut last = Point::new(0.0, 0.0); for element in bez.elements() { match *element { PathEl::MoveTo(p) => { if let Some(subpath) = current.take() - && subpath.knots.len() > 1 + && (!restarted || subpath.knots.len() > 1) { subpaths.push(subpath); } + restarted = false; current = Some(Subpath { closed: false, knots: vec![Knot::corner(p.x, p.y)], op: PathOp::Combine }); last = Point::new(p.x, p.y); } @@ -922,6 +938,9 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option } else { subpath.knots[0].in_ctrl = first; } + } + // A single-point closed subpath still paints a dot with a round cap. + if !restarted || subpath.knots.len() > 1 { subpaths.push(subpath); } // A close starts a new subpath at the closed subpath's start point; a @@ -929,13 +948,16 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option if let Some(start) = restart { current = Some(Subpath { closed: false, knots: vec![Knot::corner(start.x, start.y)], op: PathOp::Combine }); last = start; + restarted = true; + } else { + restarted = false; } } } } } if let Some(subpath) = current - && subpath.knots.len() > 1 + && (!restarted || subpath.knots.len() > 1) { subpaths.push(subpath); } @@ -1414,13 +1436,31 @@ mod tests { } #[test] - fn text_keeps_paint_order_relative_to_paths() { - let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f"); + fn text_keeps_paint_order_on_both_sides_of_a_path() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (A) Tj ET 0 0 1 rg 0 0 200 100 re f BT /F1 24 Tf 20 20 Td (B) Tj ET"); let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("order.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); let layers = children(&imported.document); - assert!(matches!(layers[0].content, LayerContent::Text(_)), "text was painted first"); - assert!(matches!(layers[1].content, LayerContent::Shape(_)), "the covering path comes after"); + assert_eq!(layers.len(), 3); + let text = |layer: &Layer| match &layer.content { + LayerContent::Text(text) => text.text.clone(), + _ => String::new(), + }; + assert_eq!(text(&layers[0]), "A"); + assert!(matches!(layers[1].content, LayerContent::Shape(_)), "the path stays between the runs"); + assert_eq!(text(&layers[2]), "B"); + } + + #[test] + fn a_closed_single_point_subpath_is_retained() { + let mut bez = BezPath::new(); + bez.move_to((10.0, 10.0)); + bez.close_path(); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 1); + assert_eq!(path.subpaths[0].knots.len(), 1); + assert!(path.subpaths[0].closed); } #[test] From 0692a3cf4767a984205478732624723413a2d926 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:21:19 +0100 Subject: [PATCH 56/77] PDF: third review round on editable import - Bound fake-bold glyph/stroke pairing by comparison count and check cancellation during the scan. - Keep both knots when a closing curve returns to its start; fall back for a dashed single-point subpath. - Skip inkless runs (zero-width/format characters) instead of treating an empty cache as a render failure. - Track soft masks and reject the page only when paint happens while one is active. - Tests: fake-bold mismatch across a path, closing curve loop. --- crates/io/src/pdf_editable.rs | 80 +++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index f718ac7c22..c4b2bef7eb 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -37,6 +37,8 @@ const MAX_LAYERS: usize = 20_000; const MAX_KNOTS: usize = 1_000_000; /// Pixel bytes of shape caches one editable page may retain before the rest render on demand. const MAX_CACHE_BYTES: u64 = 256 << 20; +/// Cap on fill/stroke glyph comparisons so hostile pages cannot make pairing quadratic forever. +const MAX_PAIR_CHECKS: usize = 1_000_000; /// How often the watcher copies the interrupt into craft-pdf's cancellation flag. const POLL: Duration = Duration::from_millis(5); /// Largest gap (in em) inside one run before a space is inserted. @@ -97,6 +99,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, strokes: Vec::new(), fonts: Vec::new(), font_keys: HashMap::new(), + soft_mask: false, clips: Vec::new(), knots: 0, cached: 0, @@ -143,9 +146,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, if let Some(reason) = device.unsupported { return Err(EditableError::Unsupported(reason)); } - if let Err(reason) = pair_fake_bold(&mut device.glyphs, device.strokes) { - return Err(EditableError::Unsupported(reason)); - } + pair_fake_bold(&mut device.glyphs, device.strokes, ctl)?; let families = register_fonts(&device.fonts)?; let mut layers = merge_text_layers(device.layers, text_layers(&device.glyphs, &families)); render_text_caches(&mut layers, device.dpi, device.fmt, ctl)?; @@ -153,10 +154,16 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, } /// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. -fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), &'static str> { +fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { + if glyphs.len().saturating_mul(strokes.len()) > MAX_PAIR_CHECKS { + return Err(EditableError::Unsupported("too much stroked text")); + } for stroke in strokes { let mut matched = false; - for glyph in glyphs.iter_mut() { + for (index, glyph) in glyphs.iter_mut().enumerate() { + if index % 256 == 0 && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } let tolerance = 0.01 * glyph.size_px + 0.01; if glyph.text == stroke.text && glyph.font.key == stroke.font.key @@ -170,7 +177,7 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), } } if !matched { - return Err("stroked text"); + return Err(EditableError::Unsupported("stroked text")); } } Ok(()) @@ -215,7 +222,7 @@ fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap u128 { u128::from_le_bytes(key) } +/// Whether text has a visible character (not only spaces, controls or zero-width marks). +fn has_ink(text: &str) -> bool { + text.chars().any(|c| !c.is_whitespace() && !c.is_control() && !matches!(c, '\u{200B}'..='\u{200F}' | '\u{2060}' | '\u{FEFF}')) +} + /// The family alias an embedded face is registered under: its PostScript name plus a content /// key, so subsets with the same name from different documents cannot replace each other. fn font_alias(postscript: &str, key: u128) -> String { @@ -375,6 +387,8 @@ struct PathDevice<'a> { fonts: Vec<(u128, String, String, FontData)>, /// Hayro's cheap font identity → our content hash, so bytes are hashed once per face. font_keys: HashMap, + /// Whether a soft mask is active; the first paint under one falls back the page. + soft_mask: bool, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, @@ -446,6 +460,11 @@ impl PathDevice<'_> { if path.is_empty() { return; } + if stroke.as_ref().is_some_and(|stroke| !stroke.dashes.is_empty()) && path.subpaths.iter().any(|subpath| subpath.closed && subpath.knots.len() == 1) { + // A dashed zero-length subpath cannot be reproduced as a single point. + self.unsupported("a dashed zero-length subpath"); + return; + } if self.layers.len() >= MAX_LAYERS { self.unsupported("too many objects"); return; @@ -469,11 +488,10 @@ impl PathDevice<'_> { } impl<'a> Device<'a> for PathDevice<'_> { - /// A soft mask changes compositing in ways a plain layer cannot express. + /// A soft mask changes compositing in ways a plain layer cannot express, but it only matters + /// if something is painted while it is active. fn set_soft_mask(&mut self, mask: Option>) { - if mask.is_some() { - self.unsupported("soft masks"); - } + self.soft_mask = mask.is_some(); } /// Only normal blending is editable. @@ -488,6 +506,10 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.unsupported.is_some() { return; } + if self.soft_mask { + self.unsupported("soft masks"); + return; + } if self.ctl.check().is_err() { self.cancelled = true; return; @@ -550,6 +572,10 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.unsupported.is_some() { return; } + if self.soft_mask { + self.unsupported("soft masks"); + return; + } if self.ctl.check().is_err() { self.cancelled = true; return; @@ -932,7 +958,15 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option let first = subpath.knots[0].anchor; let last_anchor = subpath.knots.last().map_or(first, |knot| knot.anchor); if (first.x - last_anchor.x).abs() < 1e-9 && (first.y - last_anchor.y).abs() < 1e-9 { - if let Some(knot) = subpath.knots.pop() { + // A straight closing segment can merge into the first knot; a curve + // that returns to its start must keep both knots to preserve the loop. + let straight = subpath.knots.last().is_some_and(|knot| { + knot.in_ctrl.x == knot.anchor.x + && knot.in_ctrl.y == knot.anchor.y + && knot.out_ctrl.x == knot.anchor.x + && knot.out_ctrl.y == knot.anchor.y + }); + if straight && let Some(knot) = subpath.knots.pop() { subpath.knots[0].in_ctrl = knot.in_ctrl; } } else { @@ -1451,6 +1485,26 @@ mod tests { assert_eq!(text(&layers[2]), "B"); } + #[test] + fn fake_bold_does_not_pair_across_a_path() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f BT /F1 24 Tf 1 Tr 1 w 0 0 1 RG 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("pair.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(imported.warnings.iter().any(|warning| warning.contains("stroked text") && warning.contains("rasterised")), "{:?}", imported.warnings); + } + + #[test] + fn a_curve_returning_to_its_start_keeps_both_knots() { + let mut bez = BezPath::new(); + bez.move_to((0.0, 0.0)); + bez.curve_to((10.0, 0.0), (10.0, 10.0), (0.0, 0.0)); + bez.close_path(); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 1); + assert!(path.subpaths[0].knots.len() >= 2); + } + #[test] fn a_closed_single_point_subpath_is_retained() { let mut bez = BezPath::new(); From bf21e370f4a47f9c47c0cc42198701aca1f3dbcd Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:54:52 +0100 Subject: [PATCH 57/77] PDF: fourth review round on editable import - Count actual fake-bold comparisons toward the cap instead of the product of glyph and stroke counts. - Fall back for any dashed zero-length subpath (open or closed); test covers a stroked line followed by a closed point. - Handle invisible glyphs before the soft-mask check so hidden text does not rasterise the page. --- crates/io/src/pdf_editable.rs | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index c4b2bef7eb..0d12d6e924 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -155,15 +155,17 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, /// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { - if glyphs.len().saturating_mul(strokes.len()) > MAX_PAIR_CHECKS { - return Err(EditableError::Unsupported("too much stroked text")); - } + let mut checks = 0usize; for stroke in strokes { let mut matched = false; for (index, glyph) in glyphs.iter_mut().enumerate() { if index % 256 == 0 && ctl.check().is_err() { return Err(EditableError::Cancelled); } + if checks == MAX_PAIR_CHECKS { + return Err(EditableError::Unsupported("too much stroked text")); + } + checks += 1; let tolerance = 0.01 * glyph.size_px + 0.01; if glyph.text == stroke.text && glyph.font.key == stroke.font.key @@ -460,7 +462,7 @@ impl PathDevice<'_> { if path.is_empty() { return; } - if stroke.as_ref().is_some_and(|stroke| !stroke.dashes.is_empty()) && path.subpaths.iter().any(|subpath| subpath.closed && subpath.knots.len() == 1) { + if stroke.as_ref().is_some_and(|stroke| !stroke.dashes.is_empty()) && path.subpaths.iter().any(is_zero_length) { // A dashed zero-length subpath cannot be reproduced as a single point. self.unsupported("a dashed zero-length subpath"); return; @@ -572,6 +574,9 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.unsupported.is_some() { return; } + if matches!(draw_mode, GlyphDrawMode::Invisible) { + return; + } if self.soft_mask { self.unsupported("soft masks"); return; @@ -580,9 +585,6 @@ impl<'a> Device<'a> for PathDevice<'_> { self.cancelled = true; return; } - if matches!(draw_mode, GlyphDrawMode::Invisible) { - return; - } if matches!(draw_mode, GlyphDrawMode::Clip) { self.unsupported("text used as a clipping path"); return; @@ -819,6 +821,11 @@ fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { Some((x0, y0, x1, y1)) } +/// Whether every knot of a subpath shares the first anchor (a zero-length subpath). +fn is_zero_length(subpath: &Subpath) -> bool { + subpath.knots.first().is_some_and(|first| subpath.knots.iter().all(|knot| knot.anchor.x == first.anchor.x && knot.anchor.y == first.anchor.y)) +} + /// Whether a path transform keeps circles circular: both axes the same length and perpendicular. fn uniform_transform(transform: &KurboAffine) -> bool { let [a, b, c, d, _, _] = transform.as_coeffs(); @@ -1505,6 +1512,18 @@ mod tests { assert!(path.subpaths[0].knots.len() >= 2); } + #[test] + fn a_dashed_zero_length_line_falls_back() { + let bytes = pdf("[1 1] 0 d 10 10 m 20 20 l 30 30 m h S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("dash.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("dashed zero-length subpath") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + } + #[test] fn a_closed_single_point_subpath_is_retained() { let mut bez = BezPath::new(); From d39fc3f9cdbcc4b84461dd3acd256cd70c0d32b4 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:29:05 +0100 Subject: [PATCH 58/77] PDF: fifth review round on editable import - Accept explicitly closed page-sized clip rectangles (m l l l l h). - Pair fake-bold strokes through a text/font/order index instead of a full scan, keeping the comparison cap and cancellation checks. - Tests: solid zero-length stroke stays editable, explicit clip rectangle. --- crates/io/src/pdf_editable.rs | 81 +++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 0d12d6e924..e83b6a2581 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -155,32 +155,41 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, /// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { + // Index fills by the exact attributes a stroke must match, so each stroke only compares + // against candidates at its own paint order/font/text instead of the whole page. + let mut index: HashMap<(usize, u128, String), Vec> = HashMap::new(); + for (at, glyph) in glyphs.iter().enumerate() { + index.entry((glyph.order, glyph.font.key, glyph.text.clone())).or_default().push(at); + } + let mut paired = vec![false; glyphs.len()]; let mut checks = 0usize; for stroke in strokes { - let mut matched = false; - for (index, glyph) in glyphs.iter_mut().enumerate() { - if index % 256 == 0 && ctl.check().is_err() { - return Err(EditableError::Cancelled); - } - if checks == MAX_PAIR_CHECKS { - return Err(EditableError::Unsupported("too much stroked text")); - } - checks += 1; - let tolerance = 0.01 * glyph.size_px + 0.01; - if glyph.text == stroke.text - && glyph.font.key == stroke.font.key - && glyph.order == stroke.order - && (glyph.origin.x - stroke.origin.x).abs() <= tolerance - && (glyph.origin.y - stroke.origin.y).abs() <= tolerance - { - glyph.fake_bold = true; - matched = true; - break; + let mut found = None; + if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, stroke.text.clone())) { + for &at in candidates { + if paired[at] { + continue; + } + if checks == MAX_PAIR_CHECKS { + return Err(EditableError::Unsupported("too much stroked text")); + } + if checks.is_multiple_of(256) && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + checks += 1; + let glyph = &glyphs[at]; + let tolerance = 0.01 * glyph.size_px + 0.01; + if (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { + found = Some(at); + break; + } } } - if !matched { + let Some(at) = found else { return Err(EditableError::Unsupported("stroked text")); - } + }; + glyphs[at].fake_bold = true; + paired[at] = true; } Ok(()) } @@ -781,7 +790,7 @@ fn space_camel(name: &str) -> String { /// The bounds of a path that is one axis-aligned rectangle, else `None`. fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { let elements = path.elements(); - if elements.len() != 5 { + if !(5..=6).contains(&elements.len()) { return None; } let mut points = Vec::with_capacity(4); @@ -793,7 +802,12 @@ fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { } } if points.len() != 4 { - return None; + // An explicitly closed rectangle uses four LineTo points plus the duplicate start. + if points.len() == 5 && (points[0].0 - points[4].0).abs() < 1.0e-6 && (points[0].1 - points[4].1).abs() < 1.0e-6 { + points.pop(); + } else { + return None; + } } let x0 = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); let x1 = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); @@ -1512,6 +1526,18 @@ mod tests { assert!(path.subpaths[0].knots.len() >= 2); } + #[test] + fn an_explicitly_closed_rectangle_is_a_no_op_clip() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((200.0, 0.0)); + path.line_to((200.0, 100.0)); + path.line_to((0.0, 100.0)); + path.line_to((0.0, 0.0)); + path.close_path(); + assert_eq!(axis_aligned_rect(&path), Some((0.0, 0.0, 200.0, 100.0))); + } + #[test] fn a_dashed_zero_length_line_falls_back() { let bytes = pdf("[1 1] 0 d 10 10 m 20 20 l 30 30 m h S"); @@ -1524,6 +1550,15 @@ mod tests { ); } + #[test] + fn a_solid_zero_length_line_stays_editable() { + let bytes = pdf("10 10 m 20 20 l 30 30 m h S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("solid.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + assert!(layers.iter().any(|layer| matches!(layer.content, LayerContent::Shape(_))), "{:?}", imported.warnings); + } + #[test] fn a_closed_single_point_subpath_is_retained() { let mut bez = BezPath::new(); From e81e813b945519948ef99134a80e5c5ce81e2a57 Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:10:06 +0300 Subject: [PATCH 59/77] fix(ui): the Type options bar gets the reference tooltips (#12057) The font family menu showed no tooltip, the font style menu none, and the font size icon and colour swatch other wording. They now show the reference application's texts: "Search and select fonts", "Set font style", "Set font size" (icon and field) and "Set text color", through the catalogue with the English fallback. The controls keep their accessible names. The family tooltip is added by the options bar only: the Character, style and Glyphs panels share the picker and keep their texts. canvas_tests' T-icon tooltip probe took the tooltip to be the widget's own name; it now takes the expected tooltip text. Credit: own entry in contributors/people.toml. --- contributors/people.toml | 4 + crates/ui-egui/src/tools/type_tool.rs | 2 + .../src/tools/type_tool/canvas_tests.rs | 15 ++- crates/ui-egui/src/tools/type_tool/fonts.rs | 12 ++ .../src/tools/type_tool/options_bar.rs | 13 +- .../tools/type_tool/options_tooltips_tests.rs | 121 ++++++++++++++++++ 6 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs diff --git a/contributors/people.toml b/contributors/people.toml index a2ead57598..5995994db3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -315,3 +315,7 @@ sync_github_name = false [people.davidvhk] real_name = "David Vanhoucke" sync_github_name = true + +[people.rmsff] +real_name = "Serikov Roman" +sync_github_name = false diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..766aa4220c 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -137,6 +137,8 @@ mod canvas_tests; mod font_commit_tests; #[cfg(test)] mod font_picker_tests; +#[cfg(test)] +mod options_tooltips_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 091a0a22cb..0a8fbbd7f1 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -795,25 +795,26 @@ fn options_bar_t_icon_scrub_stops_at_the_size_limits() { } /// Rests the pointer on the widget named `label` past the tooltip delay; true when its tooltip -/// (a second node with the same name) shows. -fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, role: egui::accesskit::Role, label: &str) -> bool { +/// `tip` shows (one more node with that text). +fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, role: egui::accesskit::Role, label: &str, tip: &str) -> bool { + let before = h.query_all_by_label(tip).count(); let at = h.get_by_role_and_label(role, label).rect().center(); h.hover_at(at); h.run_steps(8); - h.query_all_by_label(label).count() == 2 + h.query_all_by_label(tip).count() == before + 1 } -/// The scrubby "T" icon keeps its "Font size" tooltip: the size field's drag area, laid over the -/// icon after it, must not take its hover. +/// The scrubby "T" icon keeps its tooltip ("Set font size", #12057): the size field's drag area, +/// laid over the icon after it, must not take its hover. #[test] fn options_bar_t_icon_shows_its_tooltip() { let mut app = new_app(); app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap(); let mut h = options_harness(app); - assert!(tooltip_shows(&mut h, egui::accesskit::Role::Button, "Set the text color"), "the probe sees an ordinary tooltip"); + assert!(tooltip_shows(&mut h, egui::accesskit::Role::Button, "Set the text color", "Set text color"), "the probe sees an ordinary tooltip"); h.hover_at(egui::pos2(890.0, 55.0)); h.run_steps(4); - assert!(tooltip_shows(&mut h, egui::accesskit::Role::Label, "Font size"), "hovering the \"T\" icon shows \"Font size\""); + assert!(tooltip_shows(&mut h, egui::accesskit::Role::Label, "Font size", "Set font size"), "hovering the \"T\" icon shows \"Set font size\""); } /// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the diff --git a/crates/ui-egui/src/tools/type_tool/fonts.rs b/crates/ui-egui/src/tools/type_tool/fonts.rs index 7c8162e3f4..022b978f8b 100644 --- a/crates/ui-egui/src/tools/type_tool/fonts.rs +++ b/crates/ui-egui/src/tools/type_tool/fonts.rs @@ -338,6 +338,18 @@ pub(super) fn font_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui, current: & Some(style) } +/// The options bar's hover text on the [`font_picker`] just drawn in `ui`: its name field and its +/// arrow, by the ids [`family_picker_in`] gives them. Only the options bar calls it (#12057); the +/// panels sharing the picker keep their texts. +pub(super) fn font_picker_tip(ui: &egui::Ui, tip: &str) { + let field = ui.make_persistent_id(egui::IdSalt::new("type-font")); + for id in [field, field.with("arrow")] { + if let Some(r) = ui.ctx().read_response(id) { + r.on_hover_text(tip); + } + } +} + /// The font list's maximum height: it scrolls inside the menu, under the fixed preview and filters. pub(super) const FONT_LIST_HEIGHT: f32 = 440.0; /// The font list's width: star, style expander, name, format badge and sample. diff --git a/crates/ui-egui/src/tools/type_tool/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index 33287d1234..25f28093e5 100644 --- a/crates/ui-egui/src/tools/type_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/type_tool/options_bar.rs @@ -1,6 +1,6 @@ use super::defaults::{apply, formatting_params, open_color_picker, shown_scale, target, target_doc, type_unit}; use super::editing::{cancel, commit_with}; -use super::fonts::{font_picker, fonts_ready, selected_style, style_label, styles}; +use super::fonts::{font_picker, font_picker_tip, fonts_ready, selected_style, style_label, styles}; use super::keys::is_vertical; use super::{Color32, LayerId, PhotocraftApp, Stroke, byte_of, insertion_style, json, selected_styles, text_layer}; @@ -43,6 +43,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { None => apply(app, ui.ctx(), json!({"font": fam})), } } + font_picker_tip(ui, tl!("Search and select fonts")); let style_family = if fam.is_empty() { styles_at(app).map(|(c, _)| c.font_family).unwrap_or_default() } else { fam.clone() }; let opts: Vec<(String, String)> = styles(&style_family).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); @@ -50,15 +51,19 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.tool_options.type_style = style.clone(); apply(app, ui.ctx(), json!({"fontStyle": style})); } + // The menu's button, by the id its combo box takes from "type-style"; "Style" stays its name. + if let Some(menu) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("type-style"))) { + menu.on_hover_text(tl!("Set font style")); + } } let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, "type", 13.0, t.icon); icon.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tl!("Font size"))); // Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437). - crate::widgets::scrub_option_icon(ui, &icon, tl!("Font size")); + crate::widgets::scrub_option_icon(ui, &icon, tl!("Set font size")); let (unit, label) = type_unit(app); let mut display = size * unit; - if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() { + if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).on_hover_text(tl!("Set font size")).changed() { let size = display / unit; app.ui.tool_options.type_size = size; apply(app, ui.ctx(), json!({"size": size, "metricsAsShown": true})); @@ -128,7 +133,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]))); ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); - let resp = resp.on_hover_text(tl!("Set the text color")); + let resp = resp.on_hover_text(tl!("Set text color")); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Set the text color"))); if resp.clicked() { open_color_picker(app, [c[0], c[1], c[2]]); diff --git a/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs b/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs new file mode 100644 index 0000000000..a41a0e2f2c --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs @@ -0,0 +1,121 @@ +//! #12057: the Type options bar's font family menu, font style menu, font size icon and field, +//! and text colour swatch carry the reference application's tooltips (also their accessible +//! descriptions). The controls keep their names, and the Character panel's font menu, which +//! shares the family picker, keeps its text. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; + +const FAMILY_TIP: &str = "Search and select fonts"; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app +} + +/// `draw` in a row, once the app's fonts are set up (the first pass has none). +fn harness(draw: fn(&mut PhotocraftApp, &mut egui::Ui)) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 600.0)).build_ui_state( + move |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| draw(app, ui)); + } + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + super::options_bar::options_bar(app, ui); +} + +fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.vertical(|ui| super::panel::character_panel(app, ui, false)); +} + +fn description(n: egui_kittest::Node<'_>) -> Option { + n.accesskit_node().description().map(|d| d.to_string()) +} + +fn described_by_label(h: &Harness<'static, PhotocraftApp>, label: &str) -> Vec { + h.query_all_by_label(label).filter_map(description).collect() +} + +fn described_by_role(h: &Harness<'static, PhotocraftApp>, role: Role) -> Vec { + h.query_all_by_role(role).filter_map(description).collect() +} + +/// Every text shape drawn this frame, tooltips included. +fn drawn(h: &Harness<'static, PhotocraftApp>) -> Vec { + fn collect(shape: &egui::Shape, text: &mut Vec) { + match shape { + egui::Shape::Text(shape) => text.push(shape.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + let mut text = Vec::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut text); + } + text +} + +#[test] +fn the_font_family_menu_has_the_reference_tooltip() { + let h = harness(options_bar); + // The name field (the bar's only text input) and its arrow, which keeps the name "Font". + assert_eq!(described_by_role(&h, Role::TextInput), [FAMILY_TIP]); + assert_eq!(described_by_label(&h, "Font"), [FAMILY_TIP]); +} + +#[test] +fn the_font_style_menu_has_the_reference_tooltip_and_keeps_its_name() { + let h = harness(options_bar); + assert_eq!(described_by_label(&h, "Style"), ["Set font style"]); +} + +#[test] +fn the_font_size_field_has_the_reference_tooltip() { + let h = harness(options_bar); + // The bar's only number field. + assert_eq!(described_by_role(&h, Role::SpinButton), ["Set font size"]); +} + +#[test] +fn the_font_size_icon_shows_the_reference_tooltip_and_keeps_its_name() { + let mut h = harness(options_bar); + // The icon itself: the size field beside it is labelled by it, so it is named "Font size" too. + h.get_by_role_and_label(Role::Label, "Font size").hover(); + h.run_steps(8); + let text = drawn(&h); + assert!(text.iter().any(|t| t == "Set font size"), "{text:?}"); + assert!(!text.iter().any(|t| t == "Font size"), "old tooltip: {text:?}"); +} + +#[test] +fn the_text_color_swatch_has_the_reference_tooltip_and_keeps_its_name() { + let h = harness(options_bar); + let found = described_by_label(&h, "Set the text color"); + assert_eq!(found, ["Set text color"]); +} + +/// A guard, so it passes without the fix too: it fails if the tooltip moves into the picker that +/// the panels share. +#[test] +fn the_character_panel_font_menu_keeps_its_text() { + let h = harness(character_panel); + assert!(h.query_by_label("Font").is_some(), "the panel draws its font menu"); + let found: Vec = described_by_label(&h, "Font").into_iter().chain(described_by_role(&h, Role::TextInput)).collect(); + assert!(!found.iter().any(|d| d == FAMILY_TIP), "{found:?}"); +} From 9f5af15a194343f34098114fd33dd1d5584c8c4b Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:57:55 +0300 Subject: [PATCH 60/77] fix(ui): Escape commits a Type Mask session as Command+Return does (#12070) Escape while typing with the Horizontal or Vertical Type Mask tool threw the session away. As in the reference application it now commits: the text outline becomes the selection in one history state and no layer is added, whatever Preferences > Type "Use ESC to commit" says. The options bar's Cancel still throws the session away. Escape on an empty mask text, or while an input method is composing, was not measured and still cancels. A Command transform drag keeps its own Escape, and the Type tool's Escape still follows the preference. The #1055 canvas test that pinned the old Escape now goes through Cancel, which keeps what it guarded: the selection and layers unchanged. --- crates/ui-egui/src/tools/type_tool.rs | 2 + .../src/tools/type_tool/canvas_tests.rs | 8 +- crates/ui-egui/src/tools/type_tool/keys.rs | 6 +- .../src/tools/type_tool/mask_escape_tests.rs | 152 ++++++++++++++++++ 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..1d4ac7e85f 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -146,3 +146,5 @@ mod commit_tests; #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod mask_escape_tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 091a0a22cb..42effdb2eb 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -2728,10 +2728,10 @@ fn type_mask_commits_the_glyphs_as_the_selection_in_one_step() { assert!(v.height() > 2 * v.width(), "{v:?}"); } -/// #1055: Escape cancels a Type Mask session whatever "Use Escape to Commit" says: the selection -/// and the layers stay as they were. +/// #1055: Cancel throws a Type Mask session away: the selection and the layers stay as they were. +/// (Escape commits it, as in the reference application: #12070, `mask_escape_tests`.) #[test] -fn type_mask_escape_leaves_the_selection_unchanged() { +fn type_mask_cancel_leaves_the_selection_unchanged() { let mut app = new_app(); app.run("select.all", json!({})).unwrap(); let (before, layers) = (selection_bounds(&app), app.session.active().unwrap().doc.layers.len()); @@ -2741,7 +2741,7 @@ fn type_mask_escape_leaves_the_selection_unchanged() { click(&mut h, p); assert!(h.state().ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "typing a mask"); super::insert(h.state_mut(), "I"); - h.key_press(egui::Key::Escape); + super::cancel(h.state_mut()); h.run_steps(2); assert!(h.state().ui.text_edit.is_none()); assert_eq!(selection_bounds(h.state()), before); diff --git a/crates/ui-egui/src/tools/type_tool/keys.rs b/crates/ui-egui/src/tools/type_tool/keys.rs index 74c81c2f7d..0da9b40319 100644 --- a/crates/ui-egui/src/tools/type_tool/keys.rs +++ b/crates/ui-egui/src/tools/type_tool/keys.rs @@ -255,7 +255,11 @@ fn handle_pressed_key( insert(app, "\t"); } Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), - Key::Escape if edit.edit.mask => cancel(app), + // A Type Mask commits on Escape as on ⌘↩, whatever "Use ESC to commit" says; the options bar's + // Cancel still throws it away (#12070). Escape on an empty mask text, or while an input + // method is composing, was not measured and still cancels. + Key::Escape if edit.edit.mask && (edit.text.is_empty() || edit.edit.preedit.is_some()) => cancel(app), + Key::Escape if edit.edit.mask => commit(app), Key::Escape if app.session.prefs().type_.use_esc_to_commit => commit(app), Key::Escape => cancel(app), Key::A if modifiers.command => { diff --git a/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs b/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs new file mode 100644 index 0000000000..30566900b0 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs @@ -0,0 +1,152 @@ +//! #12070: Escape commits a Type Mask session the way ⌘↩ does, whatever "Use Escape to Commit" +//! says: the text outline becomes the selection in one history state and no layer is added. An +//! empty mask text and a composing input method still cancel, and the Type tool's Escape still +//! follows the preference. (A ⌘ transform drag's Escape arm stays first; masks have no transform +//! box yet, #12082.) Keys go through the app's shortcut handling, as on the canvas. + +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn new_app(tool: Tool, use_esc_to_commit: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.run("prefs.set", json!({"path": "type.useEscToCommit", "value": use_esc_to_commit})).unwrap(); + // Away from the text, so New, Add, Subtract and Intersect each leave a different selection. + app.run("select.rect", json!({"x": 300, "y": 150, "width": 60, "height": 30})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// Start new text with a click and type `s`, as the canvas does. +fn type_new(app: &mut PhotocraftApp, s: &str) -> LayerId { + super::pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + super::insert(app, s); + LayerId(app.ui.text_edit.as_ref().unwrap().layer) +} + +/// One frame of the app's shortcut handling for each of `events`. +fn send(app: PhotocraftApp, events: Vec) -> PhotocraftApp { + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // As `canvas_tests`' harness: the shortcuts wait for the app's fonts. + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::shortcuts::handle(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for event in events { + h.event(event); + h.run_steps(1); + } + h.into_state() +} + +fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +fn escape(app: PhotocraftApp) -> PhotocraftApp { + send(app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]) +} + +/// The selection (compared pixel for pixel, shown by its bounds), the history (oldest first) and +/// the layer count. +#[derive(PartialEq)] +struct Snapshot { + selection: Option, + history: Vec, + layers: usize, +} + +impl std::fmt::Debug for Snapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let bounds = self.selection.as_ref().map(Surface::content_bounds); + write!(f, "selection {bounds:?}, history {:?}, {} layers", self.history, self.layers) + } +} + +fn snapshot(app: &PhotocraftApp) -> Snapshot { + let st = app.session.active().unwrap(); + Snapshot { selection: st.doc.selection.clone(), history: st.history.entries(), layers: st.doc.layers.len() } +} + +#[test] +fn escape_commits_a_type_mask_as_command_return_does() { + for tool in [Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for use_esc_to_commit in [true, false] { + let what = format!("{tool:?}, useEscToCommit={use_esc_to_commit}"); + let session = || { + let mut app = new_app(tool, use_esc_to_commit); + let before = snapshot(&app); + type_new(&mut app, "AB"); + (app, before) + }; + let (app, before) = session(); + let escaped = escape(app); + let (app, _) = session(); + let returned = send(app, vec![key(egui::Key::Enter, egui::Modifiers::COMMAND)]); + assert!(escaped.ui.text_edit.is_none(), "{what}: the session ends"); + let after = snapshot(&escaped); + let bounds = after.selection.as_ref().map(Surface::content_bounds); + assert!(bounds.is_some_and(|r| r.x1 < 300), "{what}: the text outline alone is the selection: {bounds:?}"); + assert_eq!(after.history.len(), before.history.len() + 1, "{what}: one history state"); + assert_eq!(after.layers, before.layers, "{what}: no layer is added"); + assert_eq!(after, snapshot(&returned), "{what}: the same as ⌘↩"); + } + } +} + +#[test] +fn escape_with_an_empty_mask_text_changes_nothing() { + for tool in [Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for use_esc_to_commit in [true, false] { + let what = format!("{tool:?}, useEscToCommit={use_esc_to_commit}"); + let mut app = new_app(tool, use_esc_to_commit); + let before = snapshot(&app); + type_new(&mut app, ""); + let app = escape(app); + assert!(app.ui.text_edit.is_none(), "{what}"); + assert_eq!(snapshot(&app), before, "{what}"); + } + } +} + +#[test] +fn escape_while_an_input_method_composes_still_cancels_a_type_mask() { + let preedit = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほ".into(), active_range_chars: None }); + let session = || { + let mut app = new_app(Tool::HorizontalTypeMask, true); + let before = snapshot(&app); + type_new(&mut app, "AB"); + (app, before) + }; + let (app, _) = session(); + let app = send(app, vec![preedit()]); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.preedit.is_some()), "composing"); + let (app, before) = session(); + let app = send(app, vec![preedit(), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.ui.text_edit.is_none()); + assert_eq!(snapshot(&app), before); +} + +#[test] +fn the_type_tools_escape_still_follows_the_preference() { + for (use_esc_to_commit, kept) in [(true, true), (false, false)] { + let mut app = new_app(Tool::Type, use_esc_to_commit); + let id = type_new(&mut app, "AB"); + let app = escape(app); + assert!(app.ui.text_edit.is_none()); + let layer = app.session.active().unwrap().doc.layer(id).is_some(); + assert_eq!(layer, kept, "useEscToCommit={use_esc_to_commit}: committed, or cancelled and the new layer gone"); + } +} From 368eee36793e2bf5662c8b4b4cf6c693e038127b Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:30:49 +0300 Subject: [PATCH 61/77] fix(ui): a Type Mask always replaces the selection when committed (#12073) Committing a Type Mask took the selection mode from the modifiers held at that moment, so Shift added the text outline to the selection and Alt subtracted it. As in the reference application, a commit now always replaces the selection with the outline: a click outside the text, the Commit button and Cmd+Return, with or without Shift or Alt. type.createSelection keeps its "operation" parameter for scripts. commit_with keeps its modifiers parameter so its callers don't change. The #3036 canvas test keeps its check that the working text has no Layers panel row; its Shift and Alt assertions now expect a replace. --- crates/ui-egui/src/tools/type_tool.rs | 3 + .../src/tools/type_tool/canvas_tests.rs | 17 +-- crates/ui-egui/src/tools/type_tool/editing.rs | 24 ++-- .../type_tool/mask_replace_selection_tests.rs | 135 ++++++++++++++++++ crates/ui-egui/src/tools/type_tool/pointer.rs | 2 +- 5 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..fd44c046ce 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -141,6 +141,9 @@ mod font_picker_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod mask_replace_selection_tests; + #[cfg(test)] mod commit_tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 091a0a22cb..4650d8fb02 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -3005,19 +3005,18 @@ fn multi_click_without_a_session_does_nothing() { assert_eq!((ed.anchor, ed.caret), (5, 5)); } -/// #3036: ⇧ on the click that commits a Type Mask adds the glyphs to the selection and ⌥ -/// subtracts them, as in Photoshop; while typing, the working text has no Layers panel row. +/// #3036: while typing, the working text has no Layers panel row. ⇧ or ⌥ on the click that +/// commits a Type Mask no longer combine: the glyphs replace the selection (#12073, +/// `mask_replace_selection_tests`). #[test] -fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { +fn type_mask_shift_and_alt_replace_without_a_layer_row() { use crate::canvas::{ToolEvent, tool_event}; use crate::state::Tool; - use photocraft_geom::Rect; let mut app = new_app(); type_with(&mut app, Tool::HorizontalTypeMask, "I"); let glyphs = selection_bounds(&app).unwrap(); let mut app = new_app(); app.ui.extras.snap = false; // the second text would snap to the first one's selected edge - let rect = Rect::new(1000, 100, 1100, 200); app.run("select.rect", json!({"x": 1000, "y": 100, "width": 100, "height": 100})).unwrap(); // Type, then commit with a click outside the text holding `m`. let type_and_commit = |app: &mut PhotocraftApp, m: Modifiers| { @@ -3032,14 +3031,10 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { tool_event(app, ToolEvent::Up { x: 1500.0, y: 800.0 }, m); assert!(app.ui.text_edit.is_none()); }; - let at = |app: &PhotocraftApp, x: i32, y: i32| app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)); - let stem = ((glyphs.x0 + glyphs.x1) / 2, (glyphs.y0 + glyphs.y1) / 2); type_and_commit(&mut app, Modifiers::SHIFT); - assert_eq!(selection_bounds(&app), Some(rect.union(&glyphs)), "⇧ adds"); - assert!(at(&app, stem.0, stem.1) > 0.99 && at(&app, 1050, 150) > 0.99); + assert_eq!(selection_bounds(&app), Some(glyphs), "⇧ replaces"); type_and_commit(&mut app, Modifiers::ALT); - assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); - assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); + assert_eq!(selection_bounds(&app), Some(glyphs), "⌥ replaces"); } /// #7713: justification needs a paragraph box. On point text the Paragraph panel's four justify diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index f93826c1d1..95f2dbbcc1 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -405,26 +405,26 @@ pub(crate) fn commit_shortcut(app: &mut PhotocraftApp, ctx: &egui::Context) -> b true } -/// [`commit`] with the modifiers held while committing: a Type Mask adds its glyphs to the -/// selection with ⇧, subtracts them with ⌥ and intersects with ⇧⌥ (Photoshop); with none it -/// replaces the selection. -pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { +/// [`commit`] from a click, button or key that has modifiers held. They no longer change the +/// result, since a Type Mask always replaces the selection (#12073); the parameter stays so its +/// callers don't change. +pub fn commit_with(app: &mut PhotocraftApp, _mods: egui::Modifiers) { app.ui.type_insertion_style = None; crate::type_font_preview::clear(app); let current = app.session.active_index(); let Some(home) = app.ui.text_edit.as_ref().map(|ed| edit_home(app, ed)) else { - return commit_here(app, mods); + return commit_here(app); }; match home { Some(home) if Some(home) != current => { // An edit left in a background document commits there, in its own history (#6483). app.session.set_active(home); - commit_here(app, mods); + commit_here(app); if let Some(i) = current { app.session.set_active(i); } } - Some(_) => commit_here(app, mods), + Some(_) => commit_here(app), // Its document closed: there is nothing to commit into, and the layer id may name a // layer of another document. None => { @@ -445,7 +445,7 @@ pub(super) fn edit_home(app: &PhotocraftApp, ed: &TextEdit) -> Option { } /// [`commit_with`] in the active document, which is the edit's own. -fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { +fn commit_here(app: &mut PhotocraftApp) { if app.ui.tool.is_type() { crate::type_transform::finish(app); } @@ -459,11 +459,11 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { } let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; if ed.mask { - // Type Mask: the text's glyphs become (or combine with) the selection and the working - // layer goes, all in the session's one history step. No text: nothing changes. - let operation = crate::tool_feedback::selection_mode(crate::state::Tool::HorizontalTypeMask, 0, mods); + // Type Mask: the text's glyphs replace the selection, with ⇧ or ⌥ held too, as in the + // reference application (#12073), and the working layer goes, all in the session's one + // history step. No text: nothing changes. if !text.trim().is_empty() - && let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": operation, "coalesce": ed.session})) + && let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs b/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs new file mode 100644 index 0000000000..dba036ae85 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs @@ -0,0 +1,135 @@ +//! #12073: committing a Type Mask always replaces the selection with the text outline, with ⇧, +//! ⌥ or ⇧⌥ held too: a click outside the text, the Commit button and ⌘↩ all go through +//! `commit_with`. `type.createSelection` keeps its `operation` param for scripts. + +use egui::Modifiers; +use egui_kittest::Harness; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +const COMBOS: [Modifiers; 4] = [Modifiers::NONE, Modifiers::SHIFT, Modifiers::ALT, Modifiers { shift: true, alt: true, ..Modifiers::NONE }]; + +/// A document with a selection away from the text, so Add, Subtract and Intersect would each +/// leave something other than the text outline. +fn new_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 400})).unwrap(); + app.run("select.rect", json!({"x": 600, "y": 250, "width": 100, "height": 100})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool_options.type_size = 48.0; + app.ui.tool = Tool::HorizontalTypeMask; + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, mods: Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x, y }, mods); +} + +/// Start a mask with a click at `at` and type `s`. +fn mask(app: &mut PhotocraftApp, at: (f64, f64), s: &str) { + mask_with(app, at, s, Modifiers::NONE); +} + +/// [`mask`] started by a click holding `mods`. +fn mask_with(app: &mut PhotocraftApp, at: (f64, f64), s: &str, mods: Modifiers) { + click(app, at.0, at.1, mods); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "typing a mask"); + super::insert(app, s); +} + +fn selection(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.clone() +} + +fn bounds(s: &Option) -> Option { + s.as_ref().map(Surface::content_bounds) +} + +/// The selection a plain commit of `s` at `at` leaves: the text outline alone. +fn outline(at: (f64, f64), s: &str) -> Option { + let mut app = new_app(); + mask(&mut app, at, s); + super::commit_with(&mut app, Modifiers::NONE); + let outline = selection(&app); + assert!(bounds(&outline).is_some_and(|r| r.x1 < 600), "the text alone: {:?}", bounds(&outline)); + outline +} + +#[test] +fn every_commit_replaces_the_selection_whatever_modifiers_are_held() { + let outline = outline((100.0, 200.0), "AB"); + for mods in COMBOS { + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + super::commit_with(&mut app, mods); + assert!(app.ui.text_edit.is_none(), "{mods:?}"); + assert!(selection(&app) == outline, "{mods:?}: {:?}, not the outline {:?}", bounds(&selection(&app)), bounds(&outline)); + } +} + +#[test] +fn a_modified_click_outside_the_text_replaces_the_selection() { + let outline = outline((100.0, 200.0), "AB"); + for mods in COMBOS { + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + click(&mut app, 450.0, 60.0, mods); + assert!(app.ui.text_edit.is_none(), "{mods:?}: the click commits"); + assert!(selection(&app) == outline, "{mods:?}: {:?}", bounds(&selection(&app))); + } +} + +#[test] +fn shift_command_return_replaces_the_selection() { + let outline = outline((100.0, 200.0), "AB"); + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // As `canvas_tests`' harness: the shortcuts wait for the app's fonts. + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::shortcuts::handle(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }); + h.run_steps(1); + let app = h.into_state(); + assert!(app.ui.text_edit.is_none()); + assert!(selection(&app) == outline, "{:?}", bounds(&selection(&app))); +} + +/// The reference application's "AB", then ⇧-clicks to commit it and to start "C", then "C" +/// committed: only "C" is selected. (Here a click outside the text commits without starting new +/// text, so that is two ⇧-clicks.) +#[test] +fn the_next_mask_after_a_shift_click_commit_replaces_the_first() { + let second = outline((300.0, 200.0), "C"); + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + click(&mut app, 450.0, 60.0, Modifiers::SHIFT); + assert!(app.ui.text_edit.is_none(), "the ⇧-click commits"); + mask_with(&mut app, (300.0, 200.0), "C", Modifiers::SHIFT); + click(&mut app, 450.0, 60.0, Modifiers::SHIFT); + assert!(selection(&app) == second, "{:?}, not only \"C\" {:?}", bounds(&selection(&app)), bounds(&second)); +} + +#[test] +fn type_create_selection_still_combines_for_scripts() { + let mut app = new_app(); + let rect = bounds(&selection(&app)).unwrap(); + app.run("type.createSelection", json!({"text": "AB", "x": 100, "y": 200, "size": 48, "operation": "add"})).unwrap(); + let added = bounds(&selection(&app)).unwrap(); + assert_eq!(added.union(&rect), added, "the rectangle is kept"); + assert!(added.x0 < rect.x0, "and the text is added"); +} diff --git a/crates/ui-egui/src/tools/type_tool/pointer.rs b/crates/ui-egui/src/tools/type_tool/pointer.rs index 19bab778a4..68df59059f 100644 --- a/crates/ui-egui/src/tools/type_tool/pointer.rs +++ b/crates/ui-egui/src/tools/type_tool/pointer.rs @@ -140,7 +140,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } return true; } - // ⇧/⌥ on that click combine a Type Mask with the selection. + // A Type Mask replaces the selection with ⇧ or ⌥ on that click too (#12073). commit_with(app, mods); return true; // Photoshop: a click outside commits without starting new text } From 342355d2dcb116391ac147c831f89c1c26236160 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 07:41:42 -0500 Subject: [PATCH 62/77] Patch badge: a drag keeps its press modifiers; concrete arrow geometry and negative drag tests (#12130) Cause: while a Patch or Content-Aware Move drag was under way, the cursor badge read the current modifiers, but the release and the paint read the modifiers from the press (`drag.modifiers`). Pressing Shift or Alt mid-drag after a plain press inside the selection flipped the arrow to Add or Subtract although the drag still moved the patch, and a drag pressed with Shift showed no badge once the key was let go. Fix: `badge` takes the modifiers of the Patch or Content-Aware Move drag in progress, as the Lasso line below it already does. The tests now pin the arrow glyph's exact geometry (3 strokes, horizontal shaft, both barbs from the tip, mirrored ends), a drag started outside showing no arrow inside, Content-Aware Move staying badge-free, the press modifiers winning mid-drag, Shift+Alt giving Intersect on hover, and the Curvature Pen's real Move badge over an anchor. Verified: `cargo test -p photocraft-ui-egui --lib patch_cursor` 10 passed (9 in patch_cursor_badge_tests plus the retouch_ui one), `retouch` 58 passed, clippy --all-targets -D warnings clean, fmt clean. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/tool_feedback.rs | 5 +- .../ui-egui/src/patch_cursor_badge_tests.rs | 69 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/tool_feedback.rs b/crates/ui-egui/src/canvas/tool_feedback.rs index 0b26dc5a25..88aec042e5 100644 --- a/crates/ui-egui/src/canvas/tool_feedback.rs +++ b/crates/ui-egui/src/canvas/tool_feedback.rs @@ -124,7 +124,10 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option Date: Sun, 8 Nov 2026 15:51:41 +0300 Subject: [PATCH 63/77] fix(ui): vertical text gets Top / Center / Bottom alignment buttons (#12059) The Type options bar always showed Left / Center / Right. As in the reference application, for vertical text it now shows "Top align text", "Center text" and "Bottom align text" with vertical glyphs: when the edited or selected text layer is vertical, or with no text targeted when the Vertical Type tool is active. The buttons still set left / center / right and the lit one follows the targeted paragraph. The Type Mask tools keep today's buttons (not measured). The orientation is read from the target's own document, as the paragraph style is, so a text edit left in a background document is not judged by a same-id layer of the active one. The three glyphs (vertical-align-{top,middle,bottom}.svg) are original work drawn on Lucide's grid and stroke, with their ATTRIBUTION.md row; they are kept out of the Lucide row's wildcard. --- ATTRIBUTION.md | 3 +- assets/icons/vertical-align-bottom.svg | 17 ++ assets/icons/vertical-align-middle.svg | 17 ++ assets/icons/vertical-align-top.svg | 17 ++ crates/ui-egui/src/icon_data.rs | 3 + crates/ui-egui/src/tools/type_tool.rs | 3 + .../src/tools/type_tool/options_bar.rs | 19 +- .../type_tool/vertical_align_buttons_tests.rs | 178 ++++++++++++++++++ 8 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 assets/icons/vertical-align-bottom.svg create mode 100644 assets/icons/vertical-align-middle.svg create mode 100644 assets/icons/vertical-align-top.svg create mode 100644 crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index bac9633320..4b38673804 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -42,7 +42,7 @@ next to them. | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -58,6 +58,7 @@ next to them. | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/vertical-align-{top,middle,bottom}.svg` | Type options-bar alignment glyphs for vertical text: three columns hanging from a top line, centred on a middle line, or standing on a bottom line, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/vertical-align-bottom.svg b/assets/icons/vertical-align-bottom.svg new file mode 100644 index 0000000000..09931ffd77 --- /dev/null +++ b/assets/icons/vertical-align-bottom.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/vertical-align-middle.svg b/assets/icons/vertical-align-middle.svg new file mode 100644 index 0000000000..019f70872d --- /dev/null +++ b/assets/icons/vertical-align-middle.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/vertical-align-top.svg b/assets/icons/vertical-align-top.svg new file mode 100644 index 0000000000..c5269fe99a --- /dev/null +++ b/assets/icons/vertical-align-top.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 93103b34a3..5b89a68d20 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -182,6 +182,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("type-mask-vertical", include_bytes!("../../../assets/icons/type-mask-vertical.svg")), ("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")), ("unlink", include_bytes!("../../../assets/icons/unlink.svg")), + ("vertical-align-bottom", include_bytes!("../../../assets/icons/vertical-align-bottom.svg")), + ("vertical-align-middle", include_bytes!("../../../assets/icons/vertical-align-middle.svg")), + ("vertical-align-top", include_bytes!("../../../assets/icons/vertical-align-top.svg")), ("wand", include_bytes!("../../../assets/icons/wand.svg")), ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), ("x", include_bytes!("../../../assets/icons/x.svg")), diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..1ebfebf1a8 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -130,6 +130,9 @@ fn byte_range(text: &str, a: usize, b: usize) -> (usize, usize) { (at(a.min(b)), at(a.max(b))) } +#[cfg(test)] +mod vertical_align_buttons_tests; + #[cfg(test)] mod canvas_tests; diff --git a/crates/ui-egui/src/tools/type_tool/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index 33287d1234..d581b79cde 100644 --- a/crates/ui-egui/src/tools/type_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/type_tool/options_bar.rs @@ -95,9 +95,24 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }, None => o.type_align.as_str(), }; - for (align, icon, tip) in + // Vertical text shows Top / Center / Bottom for the same left / center / right values, after the + // targeted layer's orientation, or with no text targeted the Vertical Type tool's (#12059). The + // Type Mask tools keep the horizontal buttons (not measured). + let vertical = !app.ui.tool.is_type_mask() + && match super::defaults::target_text(app) { + Some(t) => t.orientation == photocraft_doc::text::Orientation::Vertical, + None => app.ui.tool == crate::state::Tool::VerticalType, + }; + let buttons = if vertical { + [ + ("left", "vertical-align-top", tl!("Top align text")), + ("center", "vertical-align-middle", tl!("Center text")), + ("right", "vertical-align-bottom", tl!("Bottom align text")), + ] + } else { [("left", "align-left", tl!("Left align text")), ("center", "align-center", tl!("Center text")), ("right", "align-right", tl!("Right align text"))] - { + }; + for (align, icon, tip) in buttons { if crate::icons::button(ui, icon, 24.0, shown_align == align, tip).clicked() { app.ui.tool_options.type_align = align.into(); apply(app, ui.ctx(), json!({"align": align})); diff --git a/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs b/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs new file mode 100644 index 0000000000..9d3145e9ef --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs @@ -0,0 +1,178 @@ +//! #12059: for vertical text the options bar's alignment buttons are Top / Center / Bottom, after +//! the targeted text layer's orientation, or with no text targeted the Vertical Type tool's. They +//! still set left / center / right, and the lit one follows the targeted paragraph. The Type Mask +//! tools keep the horizontal buttons (not measured). + +use egui::accesskit::{Role, Toggled}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::{LayerContent, LayerId, text::TextAlign}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +const HORIZONTAL: [&str; 3] = ["Left align text", "Center text", "Right align text"]; +const VERTICAL: [&str; 3] = ["Top align text", "Center text", "Bottom align text"]; + +fn new_app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 400})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// A text layer with `orientation`, made the active layer (selected, not edited). +fn text_layer(app: &mut PhotocraftApp, orientation: &str) -> LayerId { + let made = app.run("type.create", json!({"text": "AB CD", "x": 400, "y": 100, "orientation": orientation})).unwrap(); + LayerId(made["layer"].as_u64().unwrap()) +} + +fn bar(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.horizontal(|ui| super::options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +/// Which of the two button sets the bar shows; panics on a mix or neither. +fn shown(h: &Harness<'static, PhotocraftApp>) -> [&'static str; 3] { + let has = |label: &str| h.query_by_role_and_label(Role::Button, label).is_some(); + match (has("Top align text") && has("Bottom align text"), has("Left align text") && has("Right align text")) { + (true, false) => VERTICAL, + (false, true) => HORIZONTAL, + (true, true) => panic!("both the vertical and the horizontal buttons"), + (false, false) => panic!("no alignment buttons"), + } +} + +fn lit(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_role_and_label(Role::Button, label).accesskit_node().toggled() == Some(Toggled::True) +} + +fn align_of(app: &PhotocraftApp, id: LayerId) -> TextAlign { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.paragraphs[0].style.align, + other => panic!("{}", other.kind_name()), + } +} + +#[test] +fn with_no_text_targeted_the_tool_decides() { + for (tool, buttons) in [(Tool::VerticalType, VERTICAL), (Tool::Type, HORIZONTAL)] { + let h = bar(new_app(tool)); + assert_eq!(shown(&h), buttons, "{tool:?}"); + assert!(lit(&h, buttons[0]), "{tool:?}: the default, left, lights the first button"); + } +} + +#[test] +fn a_selected_text_layer_decides_whatever_the_tool() { + for tool in [Tool::Type, Tool::VerticalType] { + for (orientation, buttons) in [("vertical", VERTICAL), ("horizontal", HORIZONTAL)] { + let mut app = new_app(tool); + text_layer(&mut app, orientation); + assert_eq!(shown(&bar(app)), buttons, "{tool:?}, {orientation} layer selected"); + } + } +} + +#[test] +fn text_being_edited_decides() { + for (orientation, buttons) in [("vertical", VERTICAL), ("horizontal", HORIZONTAL)] { + let mut app = new_app(Tool::Type); + text_layer(&mut app, orientation); + super::edit_active(&mut app).unwrap(); + assert!(app.ui.text_edit.is_some()); + assert_eq!(shown(&bar(app)), buttons, "editing {orientation} text"); + } +} + +#[test] +fn the_vertical_buttons_set_left_center_and_right_and_follow_the_paragraph() { + for (label, align, value) in + [("Bottom align text", TextAlign::Right, "right"), ("Center text", TextAlign::Center, "center"), ("Top align text", TextAlign::Left, "left")] + { + let mut app = new_app(Tool::VerticalType); + let id = text_layer(&mut app, "vertical"); + let mut h = bar(app); + h.get_by_role_and_label(Role::Button, label).click(); + h.run_steps(2); + assert_eq!(align_of(h.state(), id), align, "{label}"); + assert_eq!(h.state().ui.tool_options.type_align, value, "{label}: the tool default too"); + for other in VERTICAL { + assert_eq!(lit(&h, other), other == label, "{label} clicked: {other}"); + } + } +} + +#[test] +fn the_type_mask_tools_keep_the_horizontal_buttons() { + for tool in [Tool::VerticalTypeMask, Tool::HorizontalTypeMask] { + assert_eq!(shown(&bar(new_app(tool))), HORIZONTAL, "{tool:?}, idle"); + let mut app = new_app(tool); + super::pointer_up(&mut app, [400.0, 100.0], [400.0, 100.0]); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "{tool:?}: typing a mask"); + assert_eq!(shown(&bar(app)), HORIZONTAL, "{tool:?}, editing"); + } +} + +/// Clicking Toggle text orientation switches the buttons in the same frame (the toggle is drawn +/// before them). +#[test] +fn toggling_the_orientation_switches_the_buttons() { + let mut app = new_app(Tool::Type); + text_layer(&mut app, "horizontal"); + let mut h = bar(app); + assert_eq!(shown(&h), HORIZONTAL); + h.get_by_role_and_label(Role::Button, "Toggle text orientation").click(); + h.run_steps(1); + assert_eq!(shown(&h), VERTICAL, "now vertical"); +} + +/// A text edit left in a background document decides by its own layer, not by a same-id layer of +/// the active document (layer ids repeat across documents; as `styles_at`, #9179). +#[test] +fn a_background_text_edit_decides_by_its_own_document() { + let mut app = new_app(Tool::Type); + let a = app.session.active().unwrap().doc.id.0; + let id = text_layer(&mut app, "vertical"); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + document: Some(a), + caret: 0, + anchor: 0, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + // The same file opened twice, its copy of the layer turned horizontal. + let twin = app.session.active().unwrap().doc.as_ref().clone(); + app.session.open_document(twin, None); + app.run("type.orientation.horizontal", json!({"layer": id.0})).unwrap(); + assert_ne!(app.session.active().unwrap().doc.id.0, a, "the other document is active"); + assert_eq!(shown(&bar(app)), VERTICAL, "the edited layer is vertical"); +} + +/// The lit button follows the targeted paragraph, not the tool default. +#[test] +fn the_lit_button_follows_the_paragraph_not_the_tool_default() { + let mut app = new_app(Tool::VerticalType); + app.ui.tool_options.type_align = "center".into(); + let made = app.run("type.create", json!({"text": "AB CD", "x": 400, "y": 100, "orientation": "vertical", "align": "right"})).unwrap(); + let id = LayerId(made["layer"].as_u64().unwrap()); + assert_eq!(align_of(&app, id), TextAlign::Right); + let h = bar(app); + for label in VERTICAL { + assert_eq!(lit(&h, label), label == "Bottom align text", "{label}"); + } +} From a21907fff27a57432605d3b07e80501a51d309e8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 14:04:53 +0100 Subject: [PATCH 64/77] #9396: carry on the nearest open end across all subpaths With the ends of two open subpaths both within the Pen's reach, the press took the first subpath listed, even when the pointer was nearer the other one's end, so the wrong subpath was carried on. It now compares every open end in reach and takes the nearest. Co-Authored-By: kavishkashehan726-sudo Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/tools/vector_ui/pen.rs | 20 ++++++++------------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 71c272f793..2e73604cc9 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -589,3 +589,24 @@ fn a_press_in_reach_of_both_ends_carries_the_nearer_one_on_and_does_not_close() assert!(!work_path(&app).subpaths[0].closed); assert_eq!(ends(&app), Some([20.0, 20.0]), "from the nearer, first anchor"); } + +/// #9396 review: with the ends of two open subpaths both within reach, the press carries on the +/// subpath whose end is nearest the pointer, not the first one listed. +#[test] +fn a_press_in_reach_of_two_subpaths_ends_carries_the_nearest_on() { + let mut app = app(Tool::Pen); + assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels"); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, 20.0, 20.0); + click(&mut app, 60.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + click(&mut app, 100.0, 100.0); + click(&mut app, 64.0, 22.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(work_path(&app).subpaths.len(), 2, "two open subpaths"); + // (63, 21): 3.2 px from the first subpath's last anchor, 1.4 px from the second's. + click(&mut app, 63.0, 21.0); + let pen = app.ui.pen.as_ref().expect("a resumed draft"); + assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(1), "{pen:?}"); + assert_eq!(pen.knots.last().map(|k| k[0]), Some([64.0, 22.0]), "from the second subpath's nearer end"); +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 83e99f983f..d31dac59a8 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -565,18 +565,14 @@ pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option let doc = app.session.active()?.doc.id.0; let (name, layer, path) = resume_target(app)?; let dist = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y); - path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { - let last = s.knots.last().map(dist).filter(|d| *d < tol); - let first = s.knots.first().map(dist).filter(|d| *d < tol); - // With both ends in reach (a short path, zoomed out) the nearer one is carried on. - let reversed = match (last, first) { - (Some(l), Some(f)) => f < l, - (Some(_), None) => false, - (None, Some(_)) => true, - (None, None) => return None, - }; - Some(resumed(doc, name.clone(), layer, i, s, reversed)) - }) + // With several ends in reach (a short path, or ends of two subpaths close together, zoomed + // out) the nearest one is carried on; on a tie the last anchor, then the earlier subpath. + let ends = + path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).flat_map(|(i, s)| { + [(s.knots.last(), false), (s.knots.first(), true)].into_iter().filter_map(move |(k, reversed)| Some((i, s, reversed, dist(k?)))) + }); + let (i, s, reversed, _) = ends.filter(|e| e.3 < tol).reduce(|best, e| if e.3 < best.3 { e } else { best })?; + Some(resumed(doc, name, layer, i, s, reversed)) } /// The endpoint Direct Selection has selected ([`crate::direct_select::selected_open_end`]), as From 51174ad04e5d4ba4875fae9f6cef78bf24fb4037 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:34:23 +0100 Subject: [PATCH 65/77] PDF: review fixes for shape caches and font registration - Render each shape cache clipped to its own bounds instead of the full page clip. - Copy embedded font bytes before locking the shared text engine and take the lock per font, not for the whole registration loop. - Track seen font content keys in a HashSet instead of scanning the font list for every glyph; hash glyph text for fake-bold pairing instead of cloning strings. - Update the module and `pdf-editable` feature docs to describe text and accepted no-op clips. - Poll the cancellation watcher every 20 ms; Device callbacks only cover paints, so a single long path still needs the watcher. --- crates/io/Cargo.toml | 4 +-- crates/io/src/pdf_editable.rs | 53 ++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 923d5da891..2090bdb00f 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -16,8 +16,8 @@ corpus = [] heif = ["photocraft-codecs/heif"] # Test support for synthetic memory injection, estimate charges and verification across crates. testgen = [] -# Editable PDF import (experimental): painted paths become shape layers. Text and the other -# unsupported constructs keep the raster page. +# Editable PDF import (experimental): painted paths and embedded-font text become editable +# layers; unsupported constructs keep the raster page. pdf-editable = ["dep:kurbo"] [dependencies] diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index e83b6a2581..d5e5d52066 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -1,16 +1,22 @@ -//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers. +//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers +//! and embedded-font text into text layers. //! -//! The first step is deliberately narrow: solid fills and strokes become [`ShapeLayer`]s in -//! document pixels. Text, images, clipping paths, transparency groups, patterns, soft masks and -//! annotations mark the page unsupported, and the caller falls back to the raster importer for -//! that page with a warning. Nothing is ever half imported. +//! Solid fills and strokes become [`ShapeLayer`]s in document pixels; embedded fonts whose glyphs +//! carry Unicode and a PostScript name become [`photocraft_doc::TextLayer`]s, with the font bytes +//! registered in the app's shared text engine. A page-sized axis-aligned clip that contains the +//! artboard is a no-op and is accepted. Images, other clipping paths, transparency groups, +//! patterns, soft masks, annotations and unsupported text mark the page unsupported, and the +//! caller falls back to the raster importer for that page with a warning. Nothing is ever half +//! imported. //! //! This is the app-owned `Device` route: Hayro's interpreter walks the page and reports each //! paint with its real colour and geometry, so shape layers need no page raster underneath. That //! is what makes moving or deleting a layer behave, and what avoids the black-path and //! doubled-text failures of the old PDFium import. -use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; @@ -39,8 +45,10 @@ const MAX_KNOTS: usize = 1_000_000; const MAX_CACHE_BYTES: u64 = 256 << 20; /// Cap on fill/stroke glyph comparisons so hostile pages cannot make pairing quadratic forever. const MAX_PAIR_CHECKS: usize = 1_000_000; -/// How often the watcher copies the interrupt into craft-pdf's cancellation flag. -const POLL: Duration = Duration::from_millis(5); +/// How often the watcher copies the interrupt into craft-pdf's cancellation flag. The Device's +/// callbacks are only reached per paint, so a page with one long path still needs this; 20 ms +/// keeps wakeups negligible while staying responsive. +const POLL: Duration = Duration::from_millis(20); /// Largest gap (in em) inside one run before a space is inserted. const WORD_GAP: f64 = 0.2; /// Largest baseline offset (in em) for glyphs to share one line. @@ -99,6 +107,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, strokes: Vec::new(), fonts: Vec::new(), font_keys: HashMap::new(), + seen_fonts: HashSet::new(), soft_mask: false, clips: Vec::new(), knots: 0, @@ -157,15 +166,15 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { // Index fills by the exact attributes a stroke must match, so each stroke only compares // against candidates at its own paint order/font/text instead of the whole page. - let mut index: HashMap<(usize, u128, String), Vec> = HashMap::new(); + let mut index: HashMap<(usize, u128, u64), Vec> = HashMap::new(); for (at, glyph) in glyphs.iter().enumerate() { - index.entry((glyph.order, glyph.font.key, glyph.text.clone())).or_default().push(at); + index.entry((glyph.order, glyph.font.key, text_hash(&glyph.text))).or_default().push(at); } let mut paired = vec![false; glyphs.len()]; let mut checks = 0usize; for stroke in strokes { let mut found = None; - if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, stroke.text.clone())) { + if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, text_hash(&stroke.text))) { for &at in candidates { if paired[at] { continue; @@ -179,7 +188,7 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru checks += 1; let glyph = &glyphs[at]; let tolerance = 0.01 * glyph.size_px + 0.01; - if (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { + if glyph.text == stroke.text && (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { found = Some(at); break; } @@ -194,6 +203,13 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru Ok(()) } +/// A hash of a glyph string for the fake-bold index; equality is still checked on the string. +fn text_hash(text: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + /// Builds one point-text layer per line and style run, with the layer index it must be inserted /// at to preserve paint order relative to paths. fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap) -> Vec<(usize, Layer)> { @@ -294,9 +310,12 @@ fn text_layer(text: String, run: &GlyphRun, number: usize, family: &str) -> Laye /// the family each font key must use. fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result, EditableError> { let shared = photocraft_text::shared(); - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); let mut families = std::collections::HashMap::new(); for (key, postscript, derived, data) in fonts { + // Copy before taking the engine lock; parsing still happens under it, but the UI's + // layout is not blocked while the bytes are cloned. + let bytes = data.as_ref().as_ref().to_vec(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); // Prefer an installed full family; a subset only holds the glyphs the page uses. The // Device has the font program's PostScript name, not the PDF `/BaseFont`; a content-keyed // alias keeps two documents' subsets of one name apart for the life of the shared engine, @@ -308,7 +327,7 @@ fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result { fonts: Vec<(u128, String, String, FontData)>, /// Hayro's cheap font identity → our content hash, so bytes are hashed once per face. font_keys: HashMap, + /// Content keys of fonts already in [`Self::fonts`], so glyph callbacks do not scan it. + seen_fonts: HashSet, /// Whether a soft mask is active; the first paint under one falls back the page. soft_mask: bool, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. @@ -491,7 +512,7 @@ impl PathDevice<'_> { self.unsupported("shape cache budget"); return; } - shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, self.clip)); + shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, area)); self.cached = self.cached.saturating_add(bytes); } self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); @@ -662,7 +683,7 @@ impl<'a> Device<'a> for PathDevice<'_> { let identity = outline.font_cache_key(); let key = *self.font_keys.entry(identity).or_insert_with(|| font_content_key(&data)); let family = family_from_postscript(&postscript); - if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { + if self.seen_fonts.insert(key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } let font = FontInfo { key, family, postscript, weight, italic }; From fec4980c7b8e5bc0d027bd6153a10ee502746e02 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 08:44:18 -0500 Subject: [PATCH 66/77] Patch Destination: clone only the selection; guard, error path and redo tests (#12119) The Destination branch of the Patch command no longer clones the whole document: it takes only the selection before the target surface borrows the document, and computes the selection's content bounds once, passing them to `is_hard_rect` and reusing them for the translate. Behaviour is unchanged. Tests are split and extended: Destination with a feathered, ellipse, lasso and holed-rectangle selection leaves the selection in place while the patch still lands; Destination with no selection fails, adds no history entry and a following undo reverts the previous step, not the setup; redo restores both the patched pixels and the moved selection; Source and Content-Aware are separate tests, the latter asserting a pixel change; the main test also asserts source pixels unchanged, the destination carrying the source texture, the moved selection still hard-edged, and the returned damage and offset. A rectangle larger than the patched area and a partly covering selection without a hole cannot be built, because the patched area is the selection's own content bounds; an inverted selection makes the area the whole canvas, which Patch refuses. Verified: `cargo test -p photocraft-engine --lib patch` 18 passed; `--lib retouch` 119 passed; clippy `--all-targets -D warnings` clean; `cargo fmt -p photocraft-engine` clean. Not changed: the coverage pass in `is_hard_rect` (one pass over the patched area per command, the same order of work as the patch itself). Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 14 +-- .../patch_destination_selection_tests.rs | 117 +++++++++++++++++- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index b6bf3b18d6..a132412a4f 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -213,8 +213,8 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste /// Whether `sel` is a hard-edged rectangle exactly filling `area` (the only Destination selection /// measured to move with the patch). -fn is_hard_rect(sel: &Surface, area: Rect) -> bool { - sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && sel.content_bounds() == area && coverage(sel, area).iter().all(|c| *c >= 1.0) +fn is_hard_rect(sel: &Surface, bounds: Rect, area: Rect) -> bool { + sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && bounds == area && coverage(sel, area).iter().all(|c| *c >= 1.0) } pub(super) fn patch(s: &mut Session, p: &Value) -> Result { @@ -226,13 +226,13 @@ pub(super) fn patch(s: &mut Session, p: &Value) -> Result { let dmg = if plan.destination { // Destination: a plain rectangular selection moves with the patch, in the same history state. let dmg = s.edit(label, |doc, _| { - let pre = doc.clone(); + let sel = doc.selection.clone().ok_or_else(|| bad(CMD, "select the area to patch first"))?; let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; - let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; - let dmg = patch_surface(surf, sel, &plan, lock, 1); - if is_hard_rect(sel, plan.area) { + let dmg = patch_surface(surf, &sel, &plan, lock, 1); + let bounds = sel.content_bounds(); + if is_hard_rect(&sel, bounds, plan.area) { let (dx, dy) = plan.offset; - doc.selection = Some(sel.translated(dx, dy, sel.content_bounds())); + doc.selection = Some(sel.translated(dx, dy, bounds)); } Ok(dmg) })?; diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs index 3aa7879af0..fa42660c54 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -33,14 +33,41 @@ fn px(s: &Session, x: i32, y: i32) -> [f32; 4] { d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) } +fn history_len(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +fn select_rect(s: &mut Session) { + s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); +} + +const DESTINATION: fn() -> Value = || json!({"mode": "destination", "offset": [460, 280]}); + #[test] fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { let mut s = session(); - s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + select_rect(&mut s); let before = px(&s, 600, 420); - s.execute("paint.patch", json!({"mode": "destination", "offset": [460, 280]})).unwrap(); + let source_before: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect(); + let r = s.execute("paint.patch", DESTINATION()).unwrap(); + assert_eq!(r["offset"], json!([460, 280])); + // The damage covers the patched area (the 40 px square at the destination, plus the solve's ring). + assert_eq!(r["damage"], json!([578, 398, 44, 44])); assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); assert_ne!(px(&s, 600, 420), before, "pixels changed too"); + // Source pixels untouched. + let source_after: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect(); + assert_eq!(source_after, source_before); + // The destination carries the source texture: the step between two neighbouring checker cells is kept + // in the same direction and at a similar strength (the seamless clone adds a smooth colour correction, and the + // red channel clamps at 0 on the dark green, so the step shrinks). + let step_src = px(&s, 140, 140)[0] - px(&s, 148, 140)[0]; + let step_dst = px(&s, 600, 420)[0] - px(&s, 608, 420)[0]; + assert!(step_src * step_dst > 0.0 && step_dst.abs() > 0.2, "texture kept: source step {step_src}, destination step {step_dst}"); + // The moved selection is still a hard-edged rectangle exactly filling its bounds. + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + assert!(is_hard_rect(sel, sel.content_bounds(), Rect::new(580, 400, 620, 440))); // One history state: a single undo brings back the pixels and the old selection. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); @@ -48,11 +75,91 @@ fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { } #[test] -fn source_and_content_aware_keep_the_selection() { +fn destination_selection_stays_when_it_is_not_a_plain_rectangle() { + let cases: [(&str, Value, Value); 4] = [ + ("feathered", json!({"x": 120, "y": 120, "width": 40, "height": 40, "feather": 6.0}), json!(null)), + ("ellipse", json!({"x": 120, "y": 120, "width": 40, "height": 40, "ellipse": true}), json!(null)), + ("lasso", json!({"points": [[120, 120], [160, 130], [140, 165]]}), json!("lasso")), + ("rectangle with a hole", json!({"x": 120, "y": 120, "width": 40, "height": 40}), json!("hole")), + ]; + for (name, params, kind) in cases { + let mut s = session(); + let cmd = if kind == json!("lasso") { "select.lasso" } else { "select.rect" }; + s.execute(cmd, params).unwrap(); + if kind == json!("hole") { + // Same bounds as the rectangle, but not every pixel inside is fully selected. + s.execute("select.rect", json!({"x": 135, "y": 135, "width": 10, "height": 10, "mode": "subtract"})).unwrap(); + } + let sel_before = bounds(&mut s); + let area = |s: &Session| -> Vec<[f32; 4]> { (400..440).flat_map(|y| (580..620).map(move |x| (x, y))).map(|(x, y)| px(s, x, y)).collect() }; + let before = area(&s); + s.execute("paint.patch", DESTINATION()).unwrap(); + assert_eq!(bounds(&mut s), sel_before, "{name}: the selection stays in place"); + assert_ne!(area(&s), before, "{name}: the patch still lands"); + } +} + +#[test] +fn partial_coverage_and_oversized_rectangles_are_not_constructible_apart_from_the_hole_case() { + // `plan.area` is the selection's own content bounds, so a rectangle larger than the area cannot + // exist, and the area is never wider than the selection. The only way the bounds equal the area + // yet coverage falls short is a hole (covered above). An inverted selection makes the area the + // whole canvas, which no non-zero drag can keep on the canvas, so Patch refuses it. let mut s = session(); - s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + select_rect(&mut s); + s.execute("select.inverse", json!({})).unwrap(); + assert!(s.execute("paint.patch", DESTINATION()).is_err()); +} + +#[test] +fn destination_without_a_selection_fails_and_leaves_history_alone() { + let mut s = session(); + // A marker step after the setup, so a stray undo would be visible. + s.edit("marker", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 20, 20), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let h = history_len(&s); + assert!(s.execute("paint.patch", DESTINATION()).is_err()); + assert_eq!(history_len(&s), h, "no history entry"); + // Undo reverts the marker (the last real step), not the setup. + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(px(&s, 15, 15), [1.0, 0.0, 0.0, 1.0], "the marker step was undone"); + assert!((px(&s, 450, 300)[0] - 0.1).abs() < 0.01 && (px(&s, 450, 300)[1] - 0.6).abs() < 0.01, "the setup step is still there"); +} + +#[test] +fn redo_restores_the_patched_pixels_and_the_moved_selection() { + let mut s = session(); + select_rect(&mut s); + let before = px(&s, 600, 420); + s.execute("paint.patch", DESTINATION()).unwrap(); + let patched = px(&s, 600, 420); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_eq!(px(&s, 600, 420), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); + assert_eq!(px(&s, 600, 420), patched); +} + +#[test] +fn source_mode_keeps_the_selection() { + let mut s = session(); + select_rect(&mut s); s.execute("paint.patch", json!({"offset": [260, 0]})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); - s.execute("paint.patch", json!({"offset": [260, 0], "contentAware": true})).unwrap(); +} + +#[test] +fn content_aware_keeps_the_selection_and_changes_the_pixels() { + let mut s = session(); + select_rect(&mut s); + // Content-Aware ignores the mode and fills the selection itself from the dragged-to place (the + // green half), so the pixels under the selection change. + let before = px(&s, 140, 140); + s.execute("paint.patch", json!({"offset": [400, 0], "contentAware": true})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_ne!(px(&s, 140, 140), before, "the selected area was filled"); } From 0c6b5645363311830e69ec8324ed7a0737e924ee Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 15:04:43 +0100 Subject: [PATCH 67/77] Magic Wand: skip the selection mask when Contiguous is off; more tests (#11848) PR #11848 built the full-area selection mask for every Subtract and Intersect click, though the global match (Contiguous off) ignores it. Build it only when the fill is contiguous. Tests for the review: Contiguous off keeps the global match, Subtract and Intersect without a selection select nothing, the row check fails on a hole in the selected run, and the fill's half-selected threshold (0.49 vs 0.5) at the seed and at the fill's edge. Co-Authored-By: DuBearte <130190089+DuBearte@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/selection_cmds.rs | 6 ++- .../selection_cmds/wand_seed_outside_tests.rs | 44 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index c1c665e111..550dcd6e3f 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -143,10 +143,12 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result { }; let m = mode(p); // Subtract and Intersect grow the region from the seed through the selection only (#11779). - let within = matches!(m, SelectionMode::Subtract | SelectionMode::Intersect) + // A global match (Contiguous off) ignores the limit, so it skips the full-area mask. + let contiguous = b(p, "contiguous", true); + let within = (contiguous && matches!(m, SelectionMode::Subtract | SelectionMode::Intersect)) .then(|| s.active().and_then(|st| st.doc.selection.as_ref()).map(|sel| sel::mask_from_surface(Some(sel), area))) .flatten(); - let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), within.as_deref()); + let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), contiguous, b(p, "antiAlias", true), within.as_deref()); drop(img); let selected = s.edit("Magic Wand", |doc, _| { doc.selection = sel::combine_region(doc.selection.as_ref(), region.as_ref(), m); diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs index 13165d591f..ce289bea91 100644 --- a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -23,13 +23,19 @@ fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); } -/// The columns of row 400 that are at least half selected: (first, end), or `None`. +fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) { + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap(); +} + +/// The columns of row 400 that are at least half selected: (first, end), or `None`. Every +/// column in between must be selected too, so a hole in the run fails the test. fn selected(s: &Session) -> Option<(i32, i32)> { let doc = &s.active().unwrap().doc; let row = photocraft_geom::Rect::new(0, 400, 800, 401); let m = photocraft_algo::selection::mask_from_surface(doc.selection.as_ref(), row); let first = m.iter().position(|v| *v >= 0.5)?; let last = m.iter().rposition(|v| *v >= 0.5)?; + assert!(m[first..=last].iter().all(|v| *v >= 0.5), "a hole in the selected run {first}..={last}"); Some((first as i32, last as i32 + 1)) } @@ -66,3 +72,39 @@ fn a_seed_inside_the_selection_works_as_before() { wand(&mut s, 320, 10.0, "intersect"); assert_eq!(selected(&s), Some((220, 380))); } + +#[test] +fn contiguous_off_keeps_the_global_match() { + // Without Contiguous the seed's colour matches everywhere, as before: greys 115–145 are + // x 220–500, wherever the seed lies. + let mut s = ramp_selected(); + wand_global(&mut s, 480, 32.0, "subtract"); + assert_eq!(selected(&s), Some((100, 220))); + let mut s = ramp_selected(); + wand_global(&mut s, 480, 32.0, "intersect"); + assert_eq!(selected(&s), Some((220, 380))); +} + +#[test] +fn without_a_selection_subtract_and_intersect_select_nothing() { + for mode in ["subtract", "intersect"] { + let mut s = ramp(); + wand(&mut s, 320, 10.0, mode); + assert_eq!(selected(&s), None, "{mode}"); + } +} + +#[test] +fn the_fill_passes_only_pixels_at_least_half_selected() { + use photocraft_algo::selection::wand_region_within; + use photocraft_geom::Rect; + // One flat grey row of four pixels; only the coverage limit decides the region. + let img = [[128u8, 128, 128, 255]; 4]; + let area = Rect::new(0, 0, 4, 1); + let fill = |within: [f32; 4], seed: i32| wand_region_within(&img, area, (seed, 0), 0.0, true, false, Some(&within)); + assert!(fill([0.49, 1.0, 1.0, 1.0], 0).is_none(), "a seed just under half selected finds nothing"); + let r = fill([0.5, 1.0, 1.0, 1.0], 0).unwrap(); + assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::>(), [true; 4], "half selected counts"); + let r = fill([1.0, 1.0, 0.49, 1.0], 0).unwrap(); + assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::>(), [true, true, false, false], "the fill stops at 0.49"); +} From cbdce347f07518c9ce2eb16576f55243cffef9fb Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 15:12:49 +0100 Subject: [PATCH 68/77] Background Eraser: Protect Foreground keeps the per-channel distance; tolerance doc Only the sample match uses the measured increase-plus-decrease distance (#11901). Protect Foreground Color keeps today's largest per-channel difference, as #11903 asks for its unmeasured cases. The tolerance doc describes the new distance and the graded falloff. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 14 ++++++++++++-- crates/paint/src/bg_erase_graded_alpha_tests.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 751d58519b..a47c5acbfb 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -17,7 +17,11 @@ use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; pub struct BgEraseSettings { pub sampling: Sampling, pub limits: Limits, - /// 0..1: largest per-channel difference from the sampled colour that is erased. + /// 0..1: how far from the sampled colour a colour is still partly erased. The distance is the + /// largest increase plus the largest decrease over the colour channels (`dist`); an exact + /// match is erased fully, then less the further a colour is, and colours at or beyond the + /// Tolerance are kept (`graded_weight`). Protect Foreground Color still compares the + /// foreground by the largest per-channel difference (`max_channel_dist`). pub tolerance: f32, /// Keep pixels that match the foreground colour. pub protect_foreground: bool, @@ -52,6 +56,12 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { up + down } +/// The largest per-channel difference of `a` from `b`: the distance Protect Foreground Color keeps +/// using (#11901 measured only the sample match; #11903 keeps today's measure for the foreground). +fn max_channel_dist(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) +} + /// The match weight of one dab for a colour `d` away from the sampled colour (#11901): an exact /// match is erased fully, then the weight falls off linearly over the whole Tolerance `tol` after /// a short plateau `d0 = min(tol / 20, 8 levels)`, and colours at or beyond the Tolerance are kept. @@ -151,7 +161,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option Date: Sun, 8 Nov 2026 15:13:28 +0100 Subject: [PATCH 69/77] Background Eraser: document that Find Edges grades in this tool Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 567d0b2034..9ae05ef384 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -16,6 +16,9 @@ use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; #[serde(default, rename_all = "camelCase")] pub struct BgEraseSettings { pub sampling: Sampling, + /// Which matching pixels are erased. [`Limits::FindEdges`] grades the erase over the Tolerance + /// like [`Limits::Contiguous`] in this tool (#11904), unlike Color Replacement, where it turns + /// the soft falloff off. pub limits: Limits, /// 0..1: how far from the sampled colour a colour is still partly erased. The distance is the /// largest increase plus the largest decrease over the colour channels (`dist`); an exact From f3de619549470981c8e8d0e9272bd1cd57fa16cb Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 09:47:11 -0500 Subject: [PATCH 70/77] Patch Destination: a hard rectangle partly off the canvas moves with the patch (#12119) Cause: is_hard_rect required the selection's bounds to equal plan.area, which is clipped to the canvas. A hard rectangle reaching past the canvas (e.g. moved there with select.transformSelection, which keeps the off-canvas pixels) has bounds larger than the area, so the pixels were patched but the selection was left behind. Fix: treat the selection as a hard rectangle when nothing is selected outside its own bounds and every pixel inside those bounds is fully selected, then translate the whole selection by the drag offset. Feathered, non-rectangular and holed selections still stay in place. Verified: new test destination_moves_a_hard_rectangle_that_extends_past_the_canvas failed before the change (selection stayed at -20,100..40,140 while the pixels changed) and passes after, including one-undo restore of pixels and selection. cargo test -p photocraft-engine --lib patch (19 passed) and retouch (120 passed), clippy -D warnings and fmt clean. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 11 +++++----- .../patch_destination_selection_tests.rs | 21 ++++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index a132412a4f..cb184da85e 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -211,10 +211,11 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal) } -/// Whether `sel` is a hard-edged rectangle exactly filling `area` (the only Destination selection -/// measured to move with the patch). -fn is_hard_rect(sel: &Surface, bounds: Rect, area: Rect) -> bool { - sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && bounds == area && coverage(sel, area).iter().all(|c| *c >= 1.0) +/// Whether `sel` is a hard-edged rectangle: nothing selected outside its own `bounds` and every pixel +/// inside fully selected (the only Destination selection measured to move with the patch). The +/// bounds may reach past the canvas; the patched area is then only the part on the canvas. +fn is_hard_rect(sel: &Surface, bounds: Rect) -> bool { + sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && coverage(sel, bounds).iter().all(|c| *c >= 1.0) } pub(super) fn patch(s: &mut Session, p: &Value) -> Result { @@ -230,7 +231,7 @@ pub(super) fn patch(s: &mut Session, p: &Value) -> Result { let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; let dmg = patch_surface(surf, &sel, &plan, lock, 1); let bounds = sel.content_bounds(); - if is_hard_rect(&sel, bounds, plan.area) { + if is_hard_rect(&sel, bounds) { let (dx, dy) = plan.offset; doc.selection = Some(sel.translated(dx, dy, bounds)); } diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs index fa42660c54..de14bd73c9 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -67,7 +67,7 @@ fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { // The moved selection is still a hard-edged rectangle exactly filling its bounds. let d = s.active().unwrap(); let sel = d.doc.selection.as_ref().unwrap(); - assert!(is_hard_rect(sel, sel.content_bounds(), Rect::new(580, 400, 620, 440))); + assert!(is_hard_rect(sel, sel.content_bounds())); // One history state: a single undo brings back the pixels and the old selection. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); @@ -163,3 +163,22 @@ fn content_aware_keeps_the_selection_and_changes_the_pixels() { assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); assert_ne!(px(&s, 140, 140), before, "the selected area was filled"); } + +#[test] +fn destination_moves_a_hard_rectangle_that_extends_past_the_canvas() { + let mut s = session(); + // 60x40 rectangle with 20 columns off the left edge; the part on the canvas is x 0..40. + s.execute("select.rect", json!({"x": 0, "y": 100, "width": 60, "height": 40})).unwrap(); + s.execute("select.transformSelection", json!({"dx": -20, "dy": 0})).unwrap(); + let sel_before = bounds(&mut s); + let before = px(&s, 480, 400); + s.execute("paint.patch", DESTINATION()).unwrap(); + assert_ne!(px(&s, 480, 400), before, "the patch lands on the canvas part"); + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + assert_eq!(sel.content_bounds(), Rect::new(440, 380, 500, 420), "the whole rectangle moved, off-canvas part included"); + assert!(sel.read_region(Rect::new(440, 380, 500, 420)).chunks_exact(sel.channels().max(1)).all(|p| p[0] >= 1.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), sel_before); + assert_eq!(px(&s, 480, 400), before); +} From 66d5ebed89b3e909b48be5dcadb87a21200ceed5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 10:14:55 -0500 Subject: [PATCH 71/77] Menus: Layer Via Copy and Layer Via Cut with a capital V, as in the reference (#12357) Cause: the menu bar, the command labels and the selection tools' canvas menu spelled the two commands "Layer via Copy" and "Layer via Cut", while the reference application (and PhotoCraft's own History names and ATN event table) use a capital V. Fix: renamed the labels in crates/ui-egui/src/menu_catalog.rs, the two spec! labels in crates/engine/src/cmd/edit_cmds.rs, and the rows in crates/ui-egui/src/canvas/canvas_tool_menu.rs with its test assertions. In the 33 locale files crates/ui-egui/src/i18n/*.tsv that carry the keys, only the key column of those two rows changed (66 rows), so every translation is kept and no language falls back to English. New tests are in crates/ui-egui/src/layer_via_label_tests.rs, registered with one mod line in lib.rs like the other *_tests.rs files. Verified: the 5 new tests pass; canvas_tool_menu 51 passed; engine edit_cmds 112 passed; i18n 66 passed and 8 live_tests failed with the known local font panic in epaint fonts.rs (font-dependent, green on CI); cargo xtask parity left docs/parity-checklist.md unchanged; clippy -D warnings and fmt are clean. TSV check against HEAD: 34 files, same row count in each, exactly 66 rows differ (2 per file in 33 files, Hindi has none), only the key column of those rows differs, the translation column is byte-identical, no old key remains, and no CR bytes in any touched file. Not changed: the translations' text, the History names and the ATN event table (already the capital V), docs/ui-parity.md and the other parity docs. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/edit_cmds.rs | 4 +- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 4 +- .../src/canvas/canvas_tool_menu/tests.rs | 4 +- crates/ui-egui/src/i18n/ar.tsv | 4 +- crates/ui-egui/src/i18n/az.tsv | 4 +- crates/ui-egui/src/i18n/bs.tsv | 4 +- crates/ui-egui/src/i18n/cs.tsv | 4 +- crates/ui-egui/src/i18n/da.tsv | 4 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 4 +- crates/ui-egui/src/i18n/es.tsv | 4 +- crates/ui-egui/src/i18n/fi.tsv | 4 +- crates/ui-egui/src/i18n/fr.tsv | 4 +- crates/ui-egui/src/i18n/he.tsv | 4 +- crates/ui-egui/src/i18n/hu.tsv | 4 +- crates/ui-egui/src/i18n/id.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 4 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ka.tsv | 4 +- crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/nb.tsv | 4 +- crates/ui-egui/src/i18n/nl.tsv | 4 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 4 +- crates/ui-egui/src/i18n/ro.tsv | 4 +- crates/ui-egui/src/i18n/ru.tsv | 4 +- crates/ui-egui/src/i18n/sr.tsv | 4 +- crates/ui-egui/src/i18n/sv.tsv | 4 +- crates/ui-egui/src/i18n/ta.tsv | 4 +- crates/ui-egui/src/i18n/th.tsv | 4 +- crates/ui-egui/src/i18n/tr.tsv | 4 +- crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/ur.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/layer_via_label_tests.rs | 63 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menu_catalog.rs | 4 +- 39 files changed, 140 insertions(+), 74 deletions(-) create mode 100644 crates/ui-egui/src/layer_via_label_tests.rs diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index dbf1a9a6f1..d460524c25 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -1267,8 +1267,8 @@ pub fn specs() -> Vec { has_clip, |s, p| paste(s, p, true) ), - spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)), - spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)), + spec!("layer.new.layerViaCopy", "Layer Via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)), + spec!("layer.new.layerViaCut", "Layer Via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)), spec!("layer.mergeVisible", "Merge Visible", &["Layer"], Some("Cmd+Shift+E"), "{}", has_doc, |s, _| merge_visible(s)), spec!("image.autoTone", "Auto Tone", &["Image"], Some("Cmd+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "tone")), spec!("image.autoContrast", "Auto Contrast", &["Image"], Some("Cmd+Alt+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "contrast")), diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 90d16e45e8..57942de036 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -402,8 +402,8 @@ pub const SELECTION_MENU: &[Row] = &[ Some(("Save Selection…", "select.saveSelection")), Some(("Make Work Path…", "select.toWorkPath")), None, - Some(("Layer via Copy", "layer.new.layerViaCopy")), - Some(("Layer via Cut", "layer.new.layerViaCut")), + Some(("Layer Via Copy", "layer.new.layerViaCopy")), + Some(("Layer Via Cut", "layer.new.layerViaCut")), Some(("New Layer…", "layer.new.layer")), None, Some(("Free Transform", "edit.freeTransform")), diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 0ca26a96e6..266e841023 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -399,8 +399,8 @@ fn selection_menus_follow_photoshop_order() { Some("Save Selection…"), Some("Make Work Path…"), None, - Some("Layer via Copy"), - Some("Layer via Cut"), + Some("Layer Via Copy"), + Some("Layer Via Cut"), Some("New Layer…"), None, Some("Free Transform"), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c94ef593b5..b6482560e1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -325,8 +325,8 @@ Artboard… لوح التصميم… Artboard from Group… لوح تصميم من مجموعة… Artboard from Layers… لوح تصميم من طبقات… - Layer via Copy طبقة عبر النسخ - Layer via Cut طبقة عبر القص + Layer Via Copy طبقة عبر النسخ + Layer Via Cut طبقة عبر القص Layer From Background… طبقة من الخلفية… Layer from Background… طبقة من الخلفية… Background from Layer خلفية من طبقة diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index 5fa57f8379..7b8822c982 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -148,8 +148,8 @@ Duotone… İki ton… Duotone Duoton Trap… Rəng örtüşməsi… - Layer via Copy Kopya vasitəsilə qat - Layer via Cut Kəsərək qat yarat + Layer Via Copy Kopya vasitəsilə qat + Layer Via Cut Kəsərək qat yarat Duplicate Layer… Dublikat qat… Duplicate… Dublikat yarat… Delete Sil diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 9a5926f9a0..27c056d5e3 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -297,8 +297,8 @@ Artboard… Radna ploča… Artboard from Group… Radna ploča iz grupe… Artboard from Layers… Radna ploča iz slojeva… - Layer via Copy Sloj kopiranjem - Layer via Cut Sloj izrezivanjem + Layer Via Copy Sloj kopiranjem + Layer Via Cut Sloj izrezivanjem Layer From Background… Sloj iz pozadine… Layer from Background… Sloj iz pozadine… Background From Layer Pozadina iz sloja diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7983c59648..66e47bb783 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -448,8 +448,8 @@ Artboard… Kreslicí plocha… Artboard from Group… Kreslicí plocha ze skupiny… Artboard from Layers… Kreslicí plocha z vrstev… - Layer via Copy Vrstva kopírováním - Layer via Cut Vrstva vyjmutím + Layer Via Copy Vrstva kopírováním + Layer Via Cut Vrstva vyjmutím Layer From Background… Vrstva z pozadí… Layer from Background… Vrstva z pozadí… Background from Layer Pozadí z vrstvy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bf3859e2fc..16eeb67b88 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -185,8 +185,8 @@ Trap… Trap… # Layer - Layer via Copy Lag via kopiering - Layer via Cut Lag via udklipning + Layer Via Copy Lag via kopiering + Layer Via Cut Lag via udklipning Duplicate Layer… Duplikér lag… Duplicate… Duplikér… Delete Slet diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6f647bd057..814227eb5a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -419,8 +419,8 @@ Artboard… Zeichenbereich… Artboard from Group… Zeichenbereich aus Gruppe… Artboard from Layers… Zeichenbereich aus Ebenen… - Layer via Copy Ebene durch Kopieren - Layer via Cut Ebene durch Ausschneiden + Layer Via Copy Ebene durch Kopieren + Layer Via Cut Ebene durch Ausschneiden Layer From Background… Ebene aus Hintergrund… Layer from Background… Ebene aus Hintergrund… Background from Layer Hintergrund aus Ebene diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4db94496ef..8fd84deab9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1927,8 +1927,8 @@ Group… Ομάδα… Artboard… Πίνακας σχεδίασης… Artboard from Group… Πίνακας σχεδίασης από ομάδα… - Layer via Copy Επίπεδο μέσω αντιγραφής - Layer via Cut Επίπεδο μέσω αποκοπής + Layer Via Copy Επίπεδο μέσω αντιγραφής + Layer Via Cut Επίπεδο μέσω αποκοπής Layer From Background… Επίπεδο από φόντο… Layer from Background… Επίπεδο από φόντο… Background From Layer Φόντο από επίπεδο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5e73e9a447..75b16e771c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -385,8 +385,8 @@ Artboard… Mesa de trabajo… Artboard from Group… Mesa de trabajo a partir de grupo… Artboard from Layers… Mesa de trabajo a partir de capas… - Layer via Copy Capa vía copiar - Layer via Cut Capa vía cortar + Layer Via Copy Capa vía copiar + Layer Via Cut Capa vía cortar Layer From Background… Capa a partir del fondo… Layer from Background… Capa a partir del fondo… Background from Layer Fondo a partir de capa diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 31bc192002..9d67d3414f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -186,8 +186,8 @@ Trap… Trappaus… # Layer - Layer via Copy Taso kopioimalla - Layer via Cut Taso leikkaamalla + Layer Via Copy Taso kopioimalla + Layer Via Cut Taso leikkaamalla Duplicate Layer… Monista taso… Duplicate… Monista… Delete Poista diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d95999ab6c..439781ff10 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -410,8 +410,8 @@ Artboard… Plan de travail… Artboard from Group… Plan de travail d'après un groupe… Artboard from Layers… Plan de travail d'après des calques… - Layer via Copy Calque par copie - Layer via Cut Calque par coupe + Layer Via Copy Calque par copie + Layer Via Cut Calque par coupe Layer From Background… Calque d'après l'arrière-plan… Layer from Background… Calque d'après l'arrière-plan… Background From Layer Arrière-plan d'après le calque diff --git a/crates/ui-egui/src/i18n/he.tsv b/crates/ui-egui/src/i18n/he.tsv index 62b4c04134..c8f682bf39 100644 --- a/crates/ui-egui/src/i18n/he.tsv +++ b/crates/ui-egui/src/i18n/he.tsv @@ -311,8 +311,8 @@ Artboard… לוח ציור… Artboard from Group… לוח ציור מקבוצה… Artboard from Layers… לוח ציור משכבות… - Layer via Copy שכבה דרך העתקה - Layer via Cut שכבה דרך גזירה + Layer Via Copy שכבה דרך העתקה + Layer Via Cut שכבה דרך גזירה Layer From Background… שכבה מרקע… Layer from Background… שכבה מרקע… Background from Layer רקע משכבה diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3418634dcc..bc4edfa981 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -409,8 +409,8 @@ Layer Content Options… Rétegtartalom beállításai… Layer from Background… Réteg a háttérből… Layer Style Rétegstílus - Layer via Copy Réteg másolással - Layer via Cut Réteg kivágással + Layer Via Copy Réteg másolással + Layer Via Cut Réteg kivágással Layer… Réteg… Left Edges Bal szélek Link Layer Mask Rétegmaszk összekapcsolása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c2fe0ba04d..e35857c4bb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -403,8 +403,8 @@ Artboard… Artboard… Artboard from Group… Artboard dari Grup… Artboard from Layers… Artboard dari Layer… - Layer via Copy Layer via Salin - Layer via Cut Layer via Potong + Layer Via Copy Layer via Salin + Layer Via Cut Layer via Potong Layer From Background… Layer dari Background… Layer from Background… Layer dari Background… Background From Layer Background dari Layer diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1596245155..0d48264996 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -431,8 +431,8 @@ Artboard… Tavola da disegno… Artboard from Group… Tavola da gruppo… Artboard from Layers… Tavola da livelli… - Layer via Copy Livello tramite copia - Layer via Cut Livello tramite taglia + Layer Via Copy Livello tramite copia + Layer Via Cut Livello tramite taglia Layer From Background… Livello dallo sfondo… Layer from Background… Livello dallo sfondo… Background from Layer Sfondo da livello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a65fdad599..9c19da7297 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -441,8 +441,8 @@ Artboard… アートボード… Artboard from Group… グループからアートボード… Artboard from Layers… レイヤーからアートボード… - Layer via Copy コピーしたレイヤー - Layer via Cut 切り取ったレイヤー + Layer Via Copy コピーしたレイヤー + Layer Via Cut 切り取ったレイヤー Layer From Background… 背景からレイヤーへ… Layer from Background… 背景からレイヤーへ… Background From Layer レイヤーから背景 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bec93b9ce4..c5085bc9f7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -276,8 +276,8 @@ Artboard… სამუშაო დაფა… Artboard from Group… სამუშაო დაფა ჯგუფიდან… Artboard from Layers… სამუშაო დაფა შრეებიდან… - Layer via Copy შრე კოპირებით - Layer via Cut შრე ამოჭრით + Layer Via Copy შრე კოპირებით + Layer Via Cut შრე ამოჭრით Layer From Background… შრე ფონიდან… Layer from Background… შრე ფონიდან… Background From Layer ფონი შრიდან diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 41556271f1..d5c1a6a3d0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -407,8 +407,8 @@ Artboard… 아트보드… Artboard from Group… 그룹으로 아트보드 만들기… Artboard from Layers… 레이어로 아트보드 만들기… - Layer via Copy 복사하여 레이어 만들기 - Layer via Cut 잘라내어 레이어 만들기 + Layer Via Copy 복사하여 레이어 만들기 + Layer Via Cut 잘라내어 레이어 만들기 Layer From Background… 배경에서 레이어 만들기… Layer from Background… 배경에서 레이어 만들기… Background from Layer 레이어에서 배경 만들기 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 8898daad0e..6124c806fe 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -185,8 +185,8 @@ Trap… Overlapping… # Layer - Layer via Copy Lag via kopiering - Layer via Cut Lag via utklipp + Layer Via Copy Lag via kopiering + Layer Via Cut Lag via utklipp Duplicate Layer… Dupliser lag… Duplicate… Dupliser… Delete Slett diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 1ddceec38c..805c7d92d1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -373,8 +373,8 @@ Artboard… Tekengebied… Artboard from Group… Tekengebied uit groep… Artboard from Layers… Tekengebied uit lagen… - Layer via Copy Laag via kopiëren - Layer via Cut Laag via knippen + Layer Via Copy Laag via kopiëren + Layer Via Cut Laag via knippen Layer From Background… Laag uit achtergrond… Layer from Background… Laag uit achtergrond… Background From Layer Achtergrond uit laag diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index de7d77e8de..4e34bf6a7d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -416,8 +416,8 @@ Artboard… Obszar roboczy… Artboard from Group… Obszar roboczy z grupy… Artboard from Layers… Obszar roboczy z warstw… - Layer via Copy Warstwa przez kopiowanie - Layer via Cut Warstwa przez wycięcie + Layer Via Copy Warstwa przez kopiowanie + Layer Via Cut Warstwa przez wycięcie Layer From Background… Warstwa z tła… Layer from Background… Warstwa z tła… Background From Layer Tło z warstwy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c37ab2c42e..17412673c9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -406,8 +406,8 @@ Artboard… Prancheta… Artboard from Group… Prancheta a partir de grupo… Artboard from Layers… Prancheta a partir de camadas… - Layer via Copy Camada via cópia - Layer via Cut Camada via recorte + Layer Via Copy Camada via cópia + Layer Via Cut Camada via recorte Layer From Background… Camada a partir do plano de fundo… Layer from Background… Camada a partir do plano de fundo… Background From Layer Plano de fundo a partir da camada diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 57812b13f3..57539882ab 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -353,8 +353,8 @@ Artboard… Planșă de lucru… Artboard from Group… Planșă de lucru din grup… Artboard from Layers… Planșă de lucru din straturi… - Layer via Copy Strat prin copiere - Layer via Cut Strat prin decupare + Layer Via Copy Strat prin copiere + Layer Via Cut Strat prin decupare Layer From Background… Strat din fundal… Layer from Background… Strat din fundal… Background from Layer Fundal din strat diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 55975759f3..10eea079f6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -365,8 +365,8 @@ Duotone… Дуотон… Duotone Дуотон Trap… Приводка… - Layer via Copy Скопировать на новый слой - Layer via Cut Вырезать на новый слой + Layer Via Copy Скопировать на новый слой + Layer Via Cut Вырезать на новый слой Duplicate Layer… Дублировать слой… Duplicate… Дублировать… Delete Удалить diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index fc4c0aaf2c..af83474e79 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -431,8 +431,8 @@ Artboard… Radna tabla… Artboard from Group… Radna tabla od grupe… Artboard from Layers… Radna tabla od slojeva… - Layer via Copy Sloj kopiranjem - Layer via Cut Sloj isecanjem + Layer Via Copy Sloj kopiranjem + Layer Via Cut Sloj isecanjem Layer From Background… Sloj od pozadine… Layer from Background… Sloj od pozadine… Background From Layer Pozadina od sloja diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5e301cc811..105b818ae7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -183,8 +183,8 @@ Trap… Överfyllning… # Layer - Layer via Copy Lager via kopiering - Layer via Cut Lager via urklippning + Layer Via Copy Lager via kopiering + Layer Via Cut Lager via urklippning Duplicate Layer… Duplicera lager… Duplicate… Duplicera… Delete Ta bort diff --git a/crates/ui-egui/src/i18n/ta.tsv b/crates/ui-egui/src/i18n/ta.tsv index c8269c4cbb..8131977605 100644 --- a/crates/ui-egui/src/i18n/ta.tsv +++ b/crates/ui-egui/src/i18n/ta.tsv @@ -305,8 +305,8 @@ Artboard… ஆர்ட்போர்டு… Artboard from Group… குழுவிலிருந்து ஆர்ட்போர்டு… Artboard from Layers… அடுக்குகளிலிருந்து ஆர்ட்போர்டு… - Layer via Copy நகல் வழியாக அடுக்கு - Layer via Cut வெட்டு வழியாக அடுக்கு + Layer Via Copy நகல் வழியாக அடுக்கு + Layer Via Cut வெட்டு வழியாக அடுக்கு Layer From Background… பின்னணியிலிருந்து அடுக்கு… Layer from Background… பின்னணியிலிருந்து அடுக்கு… Background from Layer அடுக்கிலிருந்து பின்னணி diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 8340165370..83f23d4fc1 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -176,8 +176,8 @@ Trap… กับดัก… # Layer - Layer via Copy เลเยอร์ผ่านการคัดลอก - Layer via Cut เลเยอร์ผ่านการตัด + Layer Via Copy เลเยอร์ผ่านการคัดลอก + Layer Via Cut เลเยอร์ผ่านการตัด Duplicate Layer… ทำซ้ำเลเยอร์… Duplicate… ทำสำเนา… Delete ลบ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e6e68a624e..6bcf9a3e8c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -339,8 +339,8 @@ Artboard… Çalışma Yüzeyi… Artboard from Group… Gruptan Çalışma Yüzeyi… Artboard from Layers… Katmanlardan Çalışma Yüzeyi… - Layer via Copy Kopyalama Yoluyla Katman - Layer via Cut Kesim Yoluyla Katman + Layer Via Copy Kopyalama Yoluyla Katman + Layer Via Cut Kesim Yoluyla Katman Layer From Background… Arka Plandan Katman… Layer from Background… Arka Plandan Katman… Background From Layer Katmandan Arka Plan diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 49e3cb976f..4b57f9c8f0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -348,8 +348,8 @@ Duotone… Дуплекс… Duotone Дуплекс Trap… Трепінг… - Layer via Copy Шар через копіювання - Layer via Cut Шар через вирізання + Layer Via Copy Шар через копіювання + Layer Via Cut Шар через вирізання Duplicate Layer… Дублювати шар… Duplicate… Дублювати… Delete Видалити diff --git a/crates/ui-egui/src/i18n/ur.tsv b/crates/ui-egui/src/i18n/ur.tsv index 5812a8053e..a6cadc2114 100644 --- a/crates/ui-egui/src/i18n/ur.tsv +++ b/crates/ui-egui/src/i18n/ur.tsv @@ -311,8 +311,8 @@ Artboard… آرٹ بورڈ… Artboard from Group… گروپ سے آرٹ بورڈ… Artboard from Layers… تہوں سے آرٹ بورڈ… - Layer via Copy کاپی کے ذریعے تہہ - Layer via Cut کٹ کے ذریعے تہہ + Layer Via Copy کاپی کے ذریعے تہہ + Layer Via Cut کٹ کے ذریعے تہہ Layer From Background… پس منظر سے تہہ… Layer from Background… پس منظر سے تہہ… Background from Layer تہہ سے پس منظر diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 94389f5482..0651f9000b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -458,8 +458,8 @@ Layer from Background… Lớp từ nền… Layer Mask Mặt nạ lớp Layer Style Kiểu lớp - Layer via Copy Lớp bằng sao chép - Layer via Cut Lớp bằng cắt + Layer Via Copy Lớp bằng sao chép + Layer Via Cut Lớp bằng cắt Layer… Lớp… Left Edges Cạnh trái Levels… Mức… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index afa93fddef..3d8f8659f2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -423,8 +423,8 @@ Artboard… 画板… Artboard from Group… 从组创建画板… Artboard from Layers… 从图层创建画板… - Layer via Copy 通过复制创建图层 - Layer via Cut 通过剪切创建图层 + Layer Via Copy 通过复制创建图层 + Layer Via Cut 通过剪切创建图层 Layer From Background… 从背景创建图层… Layer from Background… 从背景创建图层… Background From Layer 从图层创建背景 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index af4fd8e9d2..3eb32cc6ef 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -422,8 +422,8 @@ Artboard… 工作區域… Artboard from Group… 從群組建立工作區域… Artboard from Layers… 從圖層建立工作區域… - Layer via Copy 拷貝的圖層 - Layer via Cut 剪下的圖層 + Layer Via Copy 拷貝的圖層 + Layer Via Cut 剪下的圖層 Layer From Background… 從背景建立圖層… Layer from Background… 從背景建立圖層… Background from Layer 從圖層建立背景 diff --git a/crates/ui-egui/src/layer_via_label_tests.rs b/crates/ui-egui/src/layer_via_label_tests.rs new file mode 100644 index 0000000000..b8a20c3b27 --- /dev/null +++ b/crates/ui-egui/src/layer_via_label_tests.rs @@ -0,0 +1,63 @@ +//! "Layer Via Copy" and "Layer Via Cut" carry a capital V in every menu, as in the reference +//! application (#12357), and every translation moved with the key. + +use crate::canvas::canvas_tool_menu::SELECTION_MENU; +use crate::menu_catalog::CATALOG; + +const NEW: [(&str, &str); 2] = [("Layer Via Copy", "layer.new.layerViaCopy"), ("Layer Via Cut", "layer.new.layerViaCut")]; +const OLD: [&str; 2] = ["Layer via Copy", "Layer via Cut"]; + +#[test] +fn menu_bar_rows_use_the_capital_v() { + for (label, id) in NEW { + let row = CATALOG.iter().find(|r| r.3 == id).expect("catalog row"); + assert_eq!(row.1, label); + } + assert!(CATALOG.iter().all(|r| !OLD.contains(&r.1))); +} + +#[test] +fn command_labels_use_the_capital_v() { + let specs = photocraft_engine::cmd::command_specs(); + for (label, id) in NEW { + let spec = specs.iter().find(|s| s.id == id).expect("command spec"); + assert_eq!(spec.label, label); + } +} + +#[test] +fn selection_tools_canvas_menu_uses_the_capital_v() { + for (label, id) in NEW { + assert!(SELECTION_MENU.iter().flatten().any(|r| *r == (label, id)), "{label} row"); + } + assert!(SELECTION_MENU.iter().flatten().all(|r| !OLD.contains(&r.0))); +} + +#[test] +fn every_locale_file_keeps_its_translation_under_the_new_keys() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + let mut checked = 0; + for entry in std::fs::read_dir(dir).expect("i18n dir") { + let path = entry.expect("entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("tsv") { + continue; + } + let text = std::fs::read_to_string(&path).expect("tsv"); + let keys: Vec<&str> = text.lines().filter_map(|l| l.split('\t').nth(1)).collect(); + let name = path.display(); + assert!(OLD.iter().all(|o| !keys.contains(o)), "{name} still has an old key"); + // Hindi has no entries for these menus yet; every other file has both. + if keys.contains(&"Layer Via Copy") || keys.contains(&"Layer Via Cut") { + assert!(NEW.iter().all(|(n, _)| keys.contains(n)), "{name} lacks a new key"); + checked += 1; + } + } + assert!(checked >= 30, "only {checked} locale files carry the keys"); +} + +#[test] +fn a_translation_is_found_under_the_new_key() { + let de = crate::i18n::Lang::from_code("de").expect("German"); + assert_eq!(crate::i18n::tr(de, "Layer Via Copy"), "Ebene durch Kopieren"); + assert_eq!(crate::i18n::tr(de, "Layer Via Cut"), "Ebene durch Ausschneiden"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5e84a6d87..8416644b02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -907,6 +907,9 @@ mod color_tab_theme_tests; #[cfg(test)] mod new_layer_button_tests; +#[cfg(test)] +mod layer_via_label_tests; + #[cfg(test)] mod smart_filter_rows_tests; diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 3838a70bcf..02b3bdc021 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -256,8 +256,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "New"], "Artboard from Layers…", None, "layer.new.artboardFromLayers"), (&["Layer", "New"], "Frame from Layers", None, "layer.new.frameFromLayers"), (&["Layer", "New"], "---", None, "---"), - (&["Layer", "New"], "Layer via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"), - (&["Layer", "New"], "Layer via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"), + (&["Layer", "New"], "Layer Via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"), + (&["Layer", "New"], "Layer Via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"), (&["Layer"], "Duplicate Layer…", None, "layer.duplicate"), (&["Layer", "Delete"], "Layer", None, "layer.delete"), (&["Layer", "Delete"], "Hidden Layers", None, "layer.delete.hiddenLayers"), From 5ae31d3de2bf945332e5777789cc759eba8fbdf9 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 8 Nov 2026 16:58:06 +0100 Subject: [PATCH 72/77] fix(pdf): preserve editable import fidelity and bound caches --- crates/io/src/pdf_editable.rs | 238 ++++++++++++++++++++++++++++------ 1 file changed, 201 insertions(+), 37 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index d5e5d52066..af56f64e21 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -34,15 +34,19 @@ use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::text::{CharStyle, TextRun}; use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; use photocraft_doc::{Fill, Layer, LayerContent, TextLayer}; -use photocraft_geom::{Affine, Point, Rect}; +use photocraft_geom::{Affine, Point, Rect, TILE_SIZE}; use photocraft_raster::Interrupt; /// Most layers one editable page may create; past it the page falls back to raster. const MAX_LAYERS: usize = 20_000; /// Most path knots one editable page may hold; past it the page falls back to raster. const MAX_KNOTS: usize = 1_000_000; -/// Pixel bytes of shape caches one editable page may retain before the rest render on demand. +/// Allocated tile bytes of all shape and text caches retained by one editable page. const MAX_CACHE_BYTES: u64 = 256 << 20; +/// Bound text collection before building its layers and caches, including repeated overprinting. +const MAX_GLYPHS: usize = 100_000; +/// The strings in collected glyph records must also fit, not just their record count. +const MAX_GLYPH_BYTES: usize = 16 << 20; /// Cap on fill/stroke glyph comparisons so hostile pages cannot make pairing quadratic forever. const MAX_PAIR_CHECKS: usize = 1_000_000; /// How often the watcher copies the interrupt into craft-pdf's cancellation flag. The Device's @@ -104,6 +108,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, ctl: *ctl, layers: Vec::new(), glyphs: Vec::new(), + glyph_bytes: 0, strokes: Vec::new(), fonts: Vec::new(), font_keys: HashMap::new(), @@ -157,8 +162,12 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, } pair_fake_bold(&mut device.glyphs, device.strokes, ctl)?; let families = register_fonts(&device.fonts)?; - let mut layers = merge_text_layers(device.layers, text_layers(&device.glyphs, &families)); - render_text_caches(&mut layers, device.dpi, device.fmt, ctl)?; + let texts = text_layers(&device.glyphs, &families, device.dpi, ctl)?; + if device.layers.len().saturating_add(texts.len()) > MAX_LAYERS { + return Err(EditableError::Unsupported("too many objects")); + } + let mut layers = merge_text_layers(device.layers, texts); + render_text_caches(&mut layers, device.dpi, device.fmt, ctl, &mut device.cached)?; Ok(layers) } @@ -168,6 +177,9 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru // against candidates at its own paint order/font/text instead of the whole page. let mut index: HashMap<(usize, u128, u64), Vec> = HashMap::new(); for (at, glyph) in glyphs.iter().enumerate() { + if at.is_multiple_of(256) && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } index.entry((glyph.order, glyph.font.key, text_hash(&glyph.text))).or_default().push(at); } let mut paired = vec![false; glyphs.len()]; @@ -188,7 +200,11 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru checks += 1; let glyph = &glyphs[at]; let tolerance = 0.01 * glyph.size_px + 0.01; - if glyph.text == stroke.text && (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { + if glyph.text == stroke.text + && same_style(glyph, &stroke) + && (glyph.origin.x - stroke.origin.x).abs() <= tolerance + && (glyph.origin.y - stroke.origin.y).abs() <= tolerance + { found = Some(at); break; } @@ -212,10 +228,19 @@ fn text_hash(text: &str) -> u64 { /// Builds one point-text layer per line and style run, with the layer index it must be inserted /// at to preserve paint order relative to paths. -fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap) -> Vec<(usize, Layer)> { +fn text_layers( + glyphs: &[GlyphRun], + families: &std::collections::HashMap, + dpi: f64, + ctl: &Interrupt, +) -> Result, EditableError> { let mut out = Vec::new(); let mut i = 0; while i < glyphs.len() { + ctl.check().map_err(|_| EditableError::Cancelled)?; + if out.len() >= MAX_LAYERS { + return Err(EditableError::Unsupported("too many text objects")); + } let first = &glyphs[i]; let family = families.get(&first.font.key).cloned().unwrap_or_else(|| first.font.family.clone()); let (dx, dy) = (first.angle.cos(), first.angle.sin()); @@ -223,6 +248,7 @@ fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap = layout.clusters.iter().map(|c| (c.range.start, c)).collect(); + for (offset, expected) in positions { + let cluster = clusters.get(&offset); + let actual = + cluster.and_then(|c| layout.lines.get(c.line).map(|line| text.transform.apply(Point::new(f64::from(c.x), f64::from(line.baseline))))); + if actual.is_none_or(|at| (at.x - expected.x).abs() > 0.5 || (at.y - expected.y).abs() > 0.5) { + return Err(EditableError::Unsupported("text positioning")); + } + } + } + out.push((first.order, layer)); } if i == start { i += 1; } } - out + Ok(out) } /// Whether `b` belongs to the same line and style run as `a`. @@ -290,6 +334,8 @@ fn text_layer(text: String, run: &GlyphRun, number: usize, family: &str) -> Laye weight: u16::try_from(weight).unwrap_or(400), italic: run.font.italic, faux_bold: run.fake_bold, + // PDF positions already contain kerning; applying font kerning again moves glyphs. + kerning: photocraft_doc::text::Kerning::Off, size_pt: run.size_pt as f32, color: run.color, ..CharStyle::default() @@ -340,7 +386,7 @@ fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result Result<(), EditableError> { +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &Interrupt, cached: &mut u64) -> Result<(), EditableError> { let shared = photocraft_text::shared(); for layer in layers { if ctl.check().is_err() { @@ -352,7 +398,12 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &In // Re-lock per layer so a text-heavy import does not hold the UI's engine for the // whole cache-render loop. let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let rect = engine.render_layer(text, dpi as f32, fmt); + let layout = engine.layout(text, dpi as f32); + let area = photocraft_text::render::ink_rect(&layout, &text.transform); + admit_cache(cached, area, fmt)?; + let rendered = photocraft_text::render::rasterize(&layout, &text.transform, fmt, text.antialias); + let rect = rendered.rect; + text.cache = Some(rendered.surface); if rect.width() == 0 || rect.height() == 0 { return Err(EditableError::Unsupported("a text cache could not be rendered")); } @@ -361,6 +412,17 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &In Ok(()) } +/// Reserves complete tiles before rendering: even one painted pixel allocates a whole tile. +fn admit_cache(cached: &mut u64, area: Rect, fmt: PixelFormat) -> Result<(), EditableError> { + let bytes = area.tile_count().saturating_mul(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64).saturating_mul(fmt.bytes_per_pixel() as u64); + let total = cached.saturating_add(bytes); + if total > MAX_CACHE_BYTES { + return Err(EditableError::Unsupported("page cache budget")); + } + *cached = total; + Ok(()) +} + /// Puts text layers back at the path index where their glyphs were painted. fn merge_text_layers(layers: Vec, mut texts: Vec<(usize, Layer)>) -> Vec { texts.sort_by_key(|(order, _)| *order); @@ -411,6 +473,7 @@ struct PathDevice<'a> { layers: Vec, /// Filled glyphs in content order. glyphs: Vec, + glyph_bytes: usize, /// Stroked glyphs, paired with a fill at the same place to recognise fake bold. strokes: Vec, /// Embedded fonts met on the page: cache key, PostScript name, derived family and bytes. @@ -424,7 +487,7 @@ struct PathDevice<'a> { /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, - /// Bytes of shape caches retained so far. + /// Allocated tile bytes of caches retained so far. cached: u64, unsupported: Option<&'static str>, cancelled: bool, @@ -501,19 +564,17 @@ impl PathDevice<'_> { self.unsupported("too many objects"); return; } - let reach = stroke.as_ref().map_or(0.0, |stroke| f64::from(stroke.width)); - let b = path_bounds(&path); - let area = Rect::new((b.0 - reach).floor() as i32, (b.1 - reach).floor() as i32, (b.2 + reach).ceil() as i32, (b.3 + reach).ceil() as i32) - .intersect(&self.clip); - let bytes = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.fmt.bytes_per_pixel() as u64); let mut shape = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; + // Use the renderer's actual ink bounds, including miter tips, and reuse the compilation. + // Control-point bounds expanded by one stroke width can clip acute joins. + let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, self.clip); + let area = compiled.bounds().unwrap_or(Rect::EMPTY).intersect(&self.clip); if area.width() > 0 && area.height() > 0 { - if self.cached.saturating_add(bytes) > MAX_CACHE_BYTES { - self.unsupported("shape cache budget"); + if let Err(EditableError::Unsupported(reason)) = admit_cache(&mut self.cached, area, self.fmt) { + self.unsupported(reason); return; } - shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, area)); - self.cached = self.cached.saturating_add(bytes); + shape.cache = Some(compiled.render(self.fmt, area)); } self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); } @@ -619,6 +680,10 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("text used as a clipping path"); return; } + if self.glyphs.len().saturating_add(self.strokes.len()) >= MAX_GLYPHS { + self.unsupported("too many glyphs"); + return; + } let text = match glyph.as_unicode() { Some(craft_pdf::interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), Some(craft_pdf::interpret::hayro_cmap::BfString::String(s)) => s, @@ -670,6 +735,18 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("skewed or mirrored text"); return; } + if let GlyphDrawMode::Stroke(stroke) = draw_mode { + // Only a thin, solid, same-colour outline can be approximated by faux bold. + // Colour equality is checked against the fill in pair_fake_bold. + if !stroke.line_width.is_finite() + || stroke.line_width < 0.0 + || f64::from(stroke.line_width) * transform.determinant().abs().sqrt() > size_pt * 0.05 + || !stroke.dash_array.is_empty() + { + self.unsupported("stroked text geometry"); + return; + } + } let origin = self.doc.apply(Point::new(e, f)); let size_px = size_pt * self.doc.determinant().abs().sqrt(); let angle = b.atan2(a); @@ -686,6 +763,12 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.seen_fonts.insert(key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } + let bytes = std::mem::size_of::().saturating_add(text.len()).saturating_add(family.len()).saturating_add(postscript.len()); + self.glyph_bytes = self.glyph_bytes.saturating_add(bytes); + if self.glyph_bytes > MAX_GLYPH_BYTES { + self.unsupported("glyph memory budget"); + return; + } let font = FontInfo { key, family, postscript, weight, italic }; let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false, order: self.layers.len() }; if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { @@ -889,20 +972,6 @@ fn knots_affine(transform: &KurboAffine) -> Affine { Affine { m: [a, b, c, d, e, f] } } -/// The anchor bounds of a path, used only to estimate a shape cache's size. -fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { - let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - for point in [knot.anchor, knot.in_ctrl, knot.out_ctrl] { - b.0 = b.0.min(point.x); - b.1 = b.1.min(point.y); - b.2 = b.2.max(point.x); - b.3 = b.3.max(point.y); - } - } - b -} - /// A Hayro stroke as PhotoCraft's stroke: width in pixels, dashes in multiples of the width. fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f64) -> Option { if !stroke.line_width.is_finite() || stroke.line_width < 0.0 || !scale.is_finite() || scale <= 0.0 { @@ -1126,7 +1195,11 @@ mod tests { "BaseFont" => Object::Name(name.as_bytes().to_vec()), "FirstChar" => 32, "LastChar" => 126, - "Widths" => Object::Array(vec![Object::Integer(600); 95]), + "Widths" => Object::Array((32..=126u8).map(|c| { + let Ok(face) = ttf_parser::Face::parse(font_bytes, 0) else { return Object::Integer(600); }; + let width = face.glyph_index(char::from(c)).and_then(|id| face.glyph_hor_advance(id)).unwrap_or(0); + Object::Real(f32::from(width) * 1000.0 / f32::from(face.units_per_em())) + }).collect()), "FontDescriptor" => descriptor, "Encoding" => "WinAnsiEncoding", }); @@ -1442,7 +1515,7 @@ mod tests { #[test] fn fill_then_stroke_text_is_fake_bold_not_a_second_run() { - let bytes = text_pdf("BT /F1 24 Tf 2 Tr 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let bytes = text_pdf("BT /F1 24 Tf 2 Tr 1 0 0 rg 1 0 0 RG 20 50 Td (Hi) Tj ET"); let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); let texts: Vec<_> = children(&imported.document) @@ -1640,4 +1713,95 @@ mod tests { let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; assert!(matches!(import_with_options_and_interrupt("paths.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); } + #[test] + fn positioned_pdf_text_is_not_silently_reflowed() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td [(H) -900 (i)] TJ ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("spacing.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_))), "unrepresentable spacing must rasterise"); + assert!(imported.warnings.iter().any(|w| w.contains("rasterised"))); + } + + #[test] + fn colored_text_outlines_are_not_fake_bold() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 0 0 1 RG 1 w 2 Tr 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("outline.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_))), "a blue outline cannot become red bold text"); + } + + #[test] + fn a_shape_cache_keeps_the_whole_miter() { + let bytes = pdf("10 w 10 M 40 20 m 50 70 l 60 20 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("miter.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let layer = children(&imported.document).first().unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected an editable stroke") }; + let board = Rect::from_xywh(0, 0, 200, 100); + let expected = photocraft_vector::render_shape(shape, imported.document.pixel_format(), board); + assert!(shape.cache.as_ref().unwrap().read_region(board) == expected.read_region(board), "bounds must include the pointed join"); + } + + #[test] + fn cache_budget_charges_full_tiles_and_preserves_the_limit() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let tile_bytes = TILE_SIZE as u64 * TILE_SIZE as u64 * fmt.bytes_per_pixel() as u64; + let mut used = MAX_CACHE_BYTES - tile_bytes; + admit_cache(&mut used, Rect::from_xywh(0, 0, 1, 1), fmt).unwrap(); + assert_eq!(used, MAX_CACHE_BYTES); + assert_eq!(admit_cache(&mut used, Rect::from_xywh(0, 0, 1, 1), fmt), Err(EditableError::Unsupported("page cache budget"))); + assert_eq!(used, MAX_CACHE_BYTES); + let mut used = 0; + admit_cache(&mut used, Rect::from_xywh(-1, -1, 2, 2), fmt).unwrap(); + assert_eq!(used, 4 * tile_bytes, "a tiny rectangle may straddle four tiles"); + } + + #[test] + fn text_cache_respects_the_budget_already_used_by_shapes() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text-budget.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let mut layers = vec![Layer::new("Text", LayerContent::Text(text))]; + let mut cached = MAX_CACHE_BYTES; + assert_eq!( + render_text_caches(&mut layers, 72.0, imported.document.pixel_format(), &Interrupt::NONE, &mut cached), + Err(EditableError::Unsupported("page cache budget")) + ); + let LayerContent::Text(text) = &layers[0].content else { panic!("text layer") }; + assert!(text.cache.is_none(), "reject before allocating the text cache"); + } + + #[test] + fn multiple_text_caches_share_one_allowance() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text-budget.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let fmt = imported.document.pixel_format(); + let layout = photocraft_text::shared().lock().unwrap().layout(&text, 72.0); + let area = photocraft_text::render::ink_rect(&layout, &text.transform); + let mut one_cache = 0; + admit_cache(&mut one_cache, area, fmt).unwrap(); + assert!(one_cache > 0); + let mut layers = vec![Layer::new("First", LayerContent::Text(text.clone())), Layer::new("Second", LayerContent::Text(text))]; + let mut cached = MAX_CACHE_BYTES - one_cache; + assert_eq!(render_text_caches(&mut layers, 72.0, fmt, &Interrupt::NONE, &mut cached), Err(EditableError::Unsupported("page cache budget"))); + assert_eq!(cached, MAX_CACHE_BYTES); + assert!(matches!(&layers[0].content, LayerContent::Text(t) if t.cache.is_some())); + assert!(matches!(&layers[1].content, LayerContent::Text(t) if t.cache.is_none())); + let mut cached = 0; + render_text_caches(&mut layers, 72.0, fmt, &Interrupt::NONE, &mut cached).unwrap(); + assert_eq!(cached, one_cache * 2); + assert!(layers.iter().all(|l| matches!(&l.content, LayerContent::Text(t) if t.cache.is_some()))); + } + #[test] + fn thick_text_outlines_are_not_fake_bold() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 1 0 0 RG 5 w 2 Tr 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("thick-outline.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_)))); + } } From dd6e77309f6873e916ca78e76858ae90924c956a Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 8 Nov 2026 17:28:55 +0100 Subject: [PATCH 73/77] test(ui): align Hand menu anchor checks with fixed zoom rows --- .../canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index 5cb270385c..d5039a6257 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -35,7 +35,7 @@ fn zoom_menu_steps_keep_the_view_centre_and_history() { } #[test] -fn clicked_point_preference_and_hand_menu_keep_their_existing_anchors() { +fn clicked_point_zoom_keeps_its_anchor_and_hand_menu_rejects_step_commands() { for (tool, center_on_point) in [(Tool::Zoom, true), (Tool::Hand, true), (Tool::Hand, false)] { for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { let mut app = zoom_app(400, 300); @@ -47,6 +47,14 @@ fn clicked_point_preference_and_hand_menu_keep_their_existing_anchors() { right_click(&mut h, p, Modifiers::NONE); let doc = h.state().ui.canvas_tool_menu.as_ref().unwrap().anchor.unwrap().map(|n| n as f32); let ctx = h.ctx.clone(); + if tool == Tool::Hand { + // #12022 gives Hand only fixed zoom rows. An injected step command must not run. + assert!(!available(h.state(), h.state().ui.canvas_tool_menu.as_ref().unwrap(), command)); + choose(h.state_mut(), &ctx, command); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (before.zoom, before.center), "Hand must reject {command}"); + continue; + } choose(h.state_mut(), &ctx, command); let v = &h.state().ui.views[0]; assert_eq!(v.zoom, crate::zoom_levels::step(before.zoom, direction, [400, 300])); From b5501b9bd1e63204698d69a430990de5d36d1991 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 18:20:55 +0100 Subject: [PATCH 74/77] PR #12049: keep a held ~ erasing on the Background, keep Clear on a locked one The engine turned every "mode": "clear" stroke on the Background into Normal, so a held ~ painted the foreground there instead of erasing to the background colour as on main. The options bar already turns a chosen Clear off at the press, so the engine conversion was not needed: drop it and keep the one check in clear_off_on_background. A Background with locked pixels refuses the stroke, so a chosen Clear now stays chosen there. The "Tool state machine" doc line is back on tool_event. Co-Authored-By: DuBearte Co-Authored-By: Claude Opus 5.5 --- .../src/brush_clear_background_tests.rs | 22 +++++++++--- crates/engine/src/cmd/brush_cmds.rs | 18 ++-------- .../ui-egui/src/canvas/paint_mouse/tests.rs | 35 +++++++++++++++++++ crates/ui-egui/src/canvas/tool_input.rs | 15 ++++++-- 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/brush_clear_background_tests.rs b/crates/engine/src/brush_clear_background_tests.rs index e786af9dd7..e295e68c0b 100644 --- a/crates/engine/src/brush_clear_background_tests.rs +++ b/crates/engine/src/brush_clear_background_tests.rs @@ -1,5 +1,7 @@ -//! #11897: the Brush's Clear mode on the Background paints as Normal with the foreground colour, -//! not the background colour; on a pixel layer Clear still erases, and the Eraser on the +//! #11897: the Brush's chosen Clear on the Background paints as Normal. The options bar turns +//! Clear off at the press there (`clear_off_on_background` in ui-egui), so the engine never sees +//! it; a `"mode": "clear"` stroke that does reach the Background is a held `~`, which keeps +//! erasing to the background colour. On a pixel layer Clear still erases, and the Eraser on the //! Background still paints the background colour. use crate::Session; @@ -27,17 +29,27 @@ fn close(a: [f32; 4], b: [f32; 4]) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 0.02) } -/// A hard stroke across (10..30, 10), as the canvas sends it with Clear chosen. +/// A hard stroke across (10..30, 10), as the canvas sends it in Clear mode. fn clear_stroke() -> Value { json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true, "mode": "clear"}) } +/// A held `~` on the Background erases to the background colour, as before #11897. #[test] -fn clear_on_the_background_paints_the_foreground() { +fn a_clear_stroke_on_the_background_still_paints_the_background_colour() { let mut s = session(); s.execute("paint.stroke", clear_stroke()).unwrap(); let p = pixel(&mut s, 20, 10); - assert!(close(p, BLACK), "painted {p:?}, not the background colour"); + assert!(close(p, GREEN), "painted {p:?}, not the foreground"); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Brush Tool")); +} + +#[test] +fn a_normal_stroke_on_the_background_paints_the_foreground() { + let mut s = session(); + s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0})).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, BLACK), "{p:?}"); } #[test] diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 73fded269f..87a4bcdd41 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -424,24 +424,10 @@ fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { b } -/// The Brush's Clear on the Background paints as Normal with its own colour, as in Photoshop -/// (#11897): the Background has no transparency to clear. Masks, channels, other layers (even -/// with locked transparency) and the Pencil keep Clear. -fn clear_on_background(s: &Session, p: &Value, mut brush: BrushSettings) -> Result { - if !clear_mode(p) || is_mask_target(p) || crate::channel_cmds::is_channel_target(p) || crate::color_channels::target(s, p)?.is_some() { - return Ok(brush); - } - let id = layer_id(s, p)?; - if s.active().is_some_and(|st| st.doc.is_background(id)) { - brush.erase = false; - } - Ok(brush) -} - /// `paint.stroke`: the Brush (and Eraser) tool. pub fn paint_stroke(s: &mut Session, p: &Value) -> Result { let pts = parse_points(p, "paint.stroke")?; - let brush = clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p))?; + let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p); let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Brush Tool" }; if flag(p, "fill", false) { return fill_region(s, p, label, brush, pts); @@ -619,7 +605,7 @@ impl LiveStroke { "paint.pencil" => true, other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")), }; - let brush = if pencil { pencil_brush(s, p)? } else { clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, cmd)?, p))? }; + let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) }; let (seed, fg) = (brush.seed, brush.color); let (layer, mut brush, zoom) = stroke_target(s, p, brush)?; let st = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index cdc95c6158..d1092867fe 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -1044,3 +1044,38 @@ fn chosen_clear_paints_normally_on_the_background_and_the_mode_reads_normal() { let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); assert!(px[2] > 0.9 && px[0] < 0.1, "the foreground blue, not the background colour: {px:?}"); } + +/// #11897: a Background with Lock All refuses the stroke, so a chosen Clear stays chosen. +#[test] +fn chosen_clear_stays_on_a_locked_background() { + let mut h = harness(None); + h.state_mut().ui.tool_options.brush_clear = true; + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.state_mut().run("layer.lockLayers", json!({"all": true})).unwrap(); + h.run_steps(2); + drag(&mut h, PointerButton::Primary); + assert!(h.state().ui.tool_options.brush_clear, "the Mode still reads Clear"); +} + +/// #11897: a held `~` on the Background keeps erasing to the background colour. +#[test] +fn held_tilde_on_the_background_paints_the_background_colour() { + let mut h = harness(None); + h.state_mut().run("tools.setColors", json!({"background": "#00ff00"})).unwrap(); + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + let (a, b) = drag(&mut h, PointerButton::Primary); + h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(strokes(&h).last().unwrap()["mode"], json!("clear")); + let app = h.state(); + let v = app.ui.views[0].clone(); + let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom; + let st = app.session.active().unwrap(); + let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); + assert!(px[1] > 0.9 && px[0] < 0.1 && px[2] < 0.1, "the background green, not the foreground blue: {px:?}"); +} diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 41e36bcd43..0f8a04f770 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -377,16 +377,25 @@ fn mixer_press_failed(app: &mut PhotocraftApp, message: String) { } } -/// Tool state machine. Shared by mouse input and automation. /// The Brush's chosen Clear paints as Normal on the Background, and its Mode then reads Normal -/// (#11897). A held `~` keeps today's behaviour. +/// (#11897): at the press of a Brush stroke on the Background's own pixels, Clear (chosen in the +/// options bar, not a held `~`) is turned off, so the stroke is sent as Normal. A Background whose +/// pixels are locked refuses the stroke, so Clear stays chosen there. fn clear_off_on_background(app: &mut PhotocraftApp, tool: Tool) { - let on_background = app.session.active().is_some_and(|st| st.active_layer.is_some_and(|id| st.doc.is_background(id))); + let on_background = app.session.active().is_some_and(|st| { + st.active_layer.is_some_and(|id| { + st.doc.is_background(id) && { + let locks = st.doc.effective_locks(id); + !locks.pixels && !locks.all + } + }) + }); if tool == Tool::Brush && app.ui.tool_options.brush_clear && !app.held_erase && on_background && paint_target(app) == serde_json::json!("pixels") { app.ui.tool_options.brush_clear = false; } } +/// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { if consumes_cancelled_vector_event(app, ev) { return; From 76ad52bf47edbf097e7b33687ef30f27c5bde305 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 12:38:04 -0500 Subject: [PATCH 75/77] Patch Destination: the rectangle check is capped and scans in bands (#12119) Cause: is_hard_rect read selection coverage over the selection's own content bounds in one buffer. After a transform those bounds can reach far past the canvas, while the patch-size guard only measures the on-canvas part, so a huge mostly-off-canvas selection could stall or exhaust memory. Fix: is_hard_rect now calls is_hard_rect_within with MAX_PIXELS (the patch's own cap); bounds over the cap are not a hard rectangle and the selection stays in place. Otherwise coverage is scanned in 64-row bands, stopping at the first pixel below 1.0. Verified: new test covers a limit below and at the pixel count and a hole in the third band; patch (25) and retouch (126) lib tests pass, clippy -D warnings and fmt clean. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 27 +++++++++++++++++-- .../patch_destination_selection_tests.rs | 19 +++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 17066b137e..1f1c6b023e 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -214,11 +214,34 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal) } +/// Rows scanned per band by [`is_hard_rect_within`], so the coverage buffer stays small. +const RECT_BAND_ROWS: i32 = 64; + /// Whether `sel` is a hard-edged rectangle: nothing selected outside its own `bounds` and every pixel /// inside fully selected (the only Destination selection measured to move with the patch). The -/// bounds may reach past the canvas; the patched area is then only the part on the canvas. +/// bounds may reach past the canvas; the patched area is then only the part on the canvas. Bounds +/// of more than [`MAX_PIXELS`] pixels (the patch's own cap) are never classified as a hard +/// rectangle, so a huge selection moved mostly off the canvas is not scanned and stays in place. fn is_hard_rect(sel: &Surface, bounds: Rect) -> bool { - sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && coverage(sel, bounds).iter().all(|c| *c >= 1.0) + is_hard_rect_within(sel, bounds, MAX_PIXELS) +} + +/// [`is_hard_rect`] with the pixel `limit` given. Scans `bounds` in bands of [`RECT_BAND_ROWS`] rows +/// and stops at the first pixel that is not fully selected. +fn is_hard_rect_within(sel: &Surface, bounds: Rect, limit: u64) -> bool { + let (w, h) = ((i64::from(bounds.x1) - i64::from(bounds.x0)).max(0) as u64, (i64::from(bounds.y1) - i64::from(bounds.y0)).max(0) as u64); + if w.saturating_mul(h) > limit || sel.default_pixel().first().copied().unwrap_or(0.0) > 0.0 { + return false; + } + let mut y = bounds.y0; + while y < bounds.y1 { + let y1 = y.saturating_add(RECT_BAND_ROWS).min(bounds.y1); + if !coverage(sel, Rect::new(bounds.x0, y, bounds.x1, y1)).iter().all(|c| *c >= 1.0) { + return false; + } + y = y1; + } + true } pub(super) fn patch(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs index de14bd73c9..7ed9e54efa 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -182,3 +182,22 @@ fn destination_moves_a_hard_rectangle_that_extends_past_the_canvas() { assert_eq!(bounds(&mut s), sel_before); assert_eq!(px(&s, 480, 400), before); } + +#[test] +fn hard_rect_check_is_capped_and_scans_every_band() { + let mut s = session(); + // 40x150 rectangle: three bands of 64 rows (64 + 64 + 22). + s.execute("select.rect", json!({"x": 100, "y": 100, "width": 40, "height": 150})).unwrap(); + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + let b = sel.content_bounds(); + assert_eq!(b, Rect::new(100, 100, 140, 250)); + assert!(!is_hard_rect_within(sel, b, 5999), "over the limit is never a hard rectangle"); + assert!(is_hard_rect_within(sel, b, 6000), "at the limit it is checked as usual"); + assert!(is_hard_rect_within(sel, b, u64::MAX)); + // A hole in the third band must still be found. + let mut holed = sel.clone(); + let fmt = holed.format(); + holed.write_region(Rect::new(120, 240, 121, 241), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 0.0, 0.0])); + assert!(!is_hard_rect_within(&holed, b, u64::MAX), "a hole past the first band fails the check"); +} From 4e43c23e433325f40b72caae0661d2c4f89829cc Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 20:31:42 +0100 Subject: [PATCH 76/77] Magic Wand seed-outside tests: Anti-alias off, so they test the modes with hard edges (#12397) #12146 gives near-matching neighbours a soft fringe when Anti-alias is on. The #11848 tests left Anti-alias at its default and expected exact column bounds, so main's engine tests failed (381 instead of 380). Co-Authored-By: Claude Opus 5.5 --- .../src/cmd/selection_cmds/wand_seed_outside_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs index ce289bea91..a0f079a96b 100644 --- a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -19,12 +19,14 @@ fn ramp() -> Session { s } +/// Anti-alias off: these tests check Contiguous and the modes with hard edges; the soft fringe +/// near-matching neighbours get is tested in `photocraft_algo`'s wand anti-alias tests. fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "antiAlias": false})).unwrap(); } fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false, "antiAlias": false})).unwrap(); } /// The columns of row 400 that are at least half selected: (first, end), or `None`. Every From bfe5d2a6716bac9ec42cc962ce6de74676e03597 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 21:03:37 +0100 Subject: [PATCH 77/77] Magic Wand seed-outside tests: keep Anti-alias on, expect the measured fringe (#12397) Instead of turning Anti-alias off, the tests keep the default setup and expect the reference application's measured results (#11776, #11779): the selection ends at 381, Subtract at 219, Intersect runs 219-381. Suggested by hectorviov on #12399. Co-Authored-By: Claude Opus 5.5 --- .../selection_cmds/wand_seed_outside_tests.rs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs index a0f079a96b..51540e3cfd 100644 --- a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -19,14 +19,12 @@ fn ramp() -> Session { s } -/// Anti-alias off: these tests check Contiguous and the modes with hard edges; the soft fringe -/// near-matching neighbours get is tested in `photocraft_algo`'s wand anti-alias tests. fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "antiAlias": false})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); } fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false, "antiAlias": false})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap(); } /// The columns of row 400 that are at least half selected: (first, end), or `None`. Every @@ -41,11 +39,12 @@ fn selected(s: &Session) -> Option<(i32, i32)> { Some((first as i32, last as i32 + 1)) } -/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them. +/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them, plus +/// the Anti-alias fringe: column 380 of grey 135 at about 251/255, as measured in #11776. fn ramp_selected() -> Session { let mut s = ramp(); wand(&mut s, 110, 32.0, "replace"); - assert_eq!(selected(&s), Some((100, 380))); + assert_eq!(selected(&s), Some((100, 381))); s } @@ -54,7 +53,7 @@ fn a_seed_outside_the_selection_subtracts_nothing() { let mut s = ramp_selected(); // Grey 145 (x 460–500) is outside the selection, though its own region reaches into it. wand(&mut s, 480, 32.0, "subtract"); - assert_eq!(selected(&s), Some((100, 380)), "unchanged"); + assert_eq!(selected(&s), Some((100, 381)), "unchanged"); } #[test] @@ -67,12 +66,14 @@ fn a_seed_outside_the_selection_intersects_to_nothing() { #[test] fn a_seed_inside_the_selection_works_as_before() { // Grey 125 with tolerance 10 reaches greys 115–135; inside the selection that is x 220–380. + // With the default Anti-alias fringe, as measured in #11779: Subtract leaves 100–219 (x 219 + // keeps about 6/255, under the 0.5 cut) and Intersect 219–381. let mut s = ramp_selected(); wand(&mut s, 320, 10.0, "subtract"); - assert_eq!(selected(&s), Some((100, 220))); + assert_eq!(selected(&s), Some((100, 219))); let mut s = ramp_selected(); wand(&mut s, 320, 10.0, "intersect"); - assert_eq!(selected(&s), Some((220, 380))); + assert_eq!(selected(&s), Some((219, 381))); } #[test] @@ -81,10 +82,10 @@ fn contiguous_off_keeps_the_global_match() { // x 220–500, wherever the seed lies. let mut s = ramp_selected(); wand_global(&mut s, 480, 32.0, "subtract"); - assert_eq!(selected(&s), Some((100, 220))); + assert_eq!(selected(&s), Some((100, 219))); let mut s = ramp_selected(); wand_global(&mut s, 480, 32.0, "intersect"); - assert_eq!(selected(&s), Some((220, 380))); + assert_eq!(selected(&s), Some((219, 381))); } #[test]