diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 977169958f..071b6346bf 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -15,7 +15,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/text/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 | @@ -31,6 +31,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/Cargo.lock b/Cargo.lock index c11d9c805d..1aba9042c6 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..2baf000039 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -125,6 +125,10 @@ 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 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)] 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/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/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/algo/src/selection.rs b/crates/algo/src/selection.rs index daf7a2526a..0d8866e18e 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); @@ -270,6 +303,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 +326,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 +347,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 +362,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)); } @@ -356,25 +410,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 +476,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 +795,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"); +} 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..e295e68c0b --- /dev/null +++ b/crates/engine/src/brush_clear_background_tests.rs @@ -0,0 +1,71 @@ +//! #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; +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 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 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, 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] +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/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 93bc4654b9..ff40ea8a8b 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -242,9 +242,27 @@ 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 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] { + 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); - n.popup = map_box(a, n.popup); + 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.rs b/crates/engine/src/cmd.rs index c4addf4c22..34fc19846b 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; @@ -114,6 +116,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; @@ -150,6 +154,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/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..51986b4894 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,60 @@ 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; 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 { + 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?; + // 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 layer.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 +777,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})); @@ -796,7 +861,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 +911,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 +921,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 +946,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 +1014,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; @@ -1297,8 +1378,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 +1413,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..b2d107d7bb --- /dev/null +++ b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs @@ -0,0 +1,283 @@ +//! 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 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. + 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/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"); + } +} 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/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 495f9d1892..b1ec4df7d6 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,8 @@ 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)] use paths::openable_name; @@ -285,6 +288,15 @@ pub fn specs() -> Vec { native, open_as ), + spec!( + "file.openAsSmartObject", + "Open as Smart Object…", + &["File"], + None, + 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 + ), // 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 @@ -421,6 +433,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.rs b/crates/engine/src/cmd/file_cmds/open.rs index 6121180e22..282eae0e4e 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -74,9 +74,15 @@ fn import_result(name: &str, bytes: &[u8]) -> Result Result { +pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> 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 @@ fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOpti "{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 new file mode 100644 index 0000000000..57bd8ce39f --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -0,0 +1,115 @@ +//! 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 (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::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; + +const CMD: &str = "file.openAsSmartObject"; + +/// The history step's name. +const LABEL: &str = "Open As Smart Object"; + +/// 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 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}"), + false, + move |ctx| { + ctx.progress(0.0, "Reading"); + let bytes = read_file(&path)?; + ctx.check()?; + ctx.progress(0.05, "Decoding"); + 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, confirm_damage && damaged)) + }, + |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, + 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) || s.held_open_named(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.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 damaged || question.is_some() { + let held = s.hold_open(doc, false); + 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); + // 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})) +} + +/// 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, 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 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. + let index = s.add_document(doc, None); + 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 new file mode 100644 index 0000000000..c205af3d71 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -0,0 +1,367 @@ +//! 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(); + // 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); + 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_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(), ["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"); + 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(); + 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); + 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})] { + 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(_))); +} + +#[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"); +} + +#[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"); +} + +#[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}"); +} 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); 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..1db8e5681b --- /dev/null +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -0,0 +1,116 @@ +//! #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"); +} + +#[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); +} + +#[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 + // 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]); +} diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index 3cba7c4318..115b8c40f9 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -260,7 +260,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, diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index 6a8a6e2b05..66cf276332 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -130,26 +130,19 @@ pub(super) 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, 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. + 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); @@ -272,11 +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 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 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. @@ -328,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 self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + if self.wraps() { return self.push_periodic(); } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); @@ -360,6 +356,8 @@ impl LiveRetouch { 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, @@ -369,8 +367,9 @@ impl LiveRetouch { selection: self.sel.as_ref(), lock: self.lock, mode: self.mode, + cmd: live_cmd, } - .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`. diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index a260443caf..b26decfd69 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -1,4 +1,5 @@ use super::clone::{blend_param, clamp_samples, clone_mapping, clone_result, clone_sample}; +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, @@ -53,15 +54,30 @@ pub(super) 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, 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")) } + }); + } Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode)) })?; Ok(clone_result(dmg, off, aligned, p, &stroke)) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 38655075fd..1f1c6b023e 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -153,6 +153,8 @@ struct Plan { /// Shift of the repaired area from the selection, and of the texture from the repaired area. dst_shift: (i32, i32), src_shift: (i32, i32), + /// Destination mode: a hard rectangular selection moves with the patch. + destination: bool, offset: (i32, i32), } @@ -186,7 +188,7 @@ fn plan(s: &Session, p: &Value) -> Result { PatchMode::Source => ((0, 0), (dx, dy)), PatchMode::Destination => ((dx, dy), (-dx, -dy)), }; - Ok(Plan { id, canvas, area, dst_shift, src_shift, offset: (dx, dy) }) + Ok(Plan { id, canvas, area, dst_shift, src_shift, destination: mode == PatchMode::Destination, offset: (dx, dy) }) } /// Heal `surf` at the selection (shifted by `dst_shift`) with texture read `src_shift` away, the @@ -212,16 +214,63 @@ 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 +/// 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 { + 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 { if flag(p, "contentAware", false) { return patch_content_aware(s, p); } let plan = plan(s, p)?; let label = HISTORY_NAME; - 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 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 dmg = patch_surface(surf, &sel, &plan, lock, 1); + let bounds = sel.content_bounds(); + if is_hard_rect(&sel, bounds) { + let (dx, dy) = plan.offset; + doc.selection = Some(sel.translated(dx, dy, 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] })) } @@ -305,3 +354,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..7ed9e54efa --- /dev/null +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -0,0 +1,203 @@ +//! 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) +} + +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(); + select_rect(&mut s); + let before = px(&s, 600, 420); + 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())); + // 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 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(); + 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])); +} + +#[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"); +} + +#[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); +} + +#[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"); +} diff --git a/crates/engine/src/cmd/retouch_cmds/periodic.rs b/crates/engine/src/cmd/retouch_cmds/periodic.rs index 242579198e..63c3b40f07 100644 --- a/crates/engine/src/cmd/retouch_cmds/periodic.rs +++ b/crates/engine/src/cmd/retouch_cmds/periodic.rs @@ -1,6 +1,6 @@ //! Bounded reads from the repeating document tile used by Pattern Preview. -use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, StrokePoint, Surface, alpha_index, apply_coverage, bad}; +use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, Stroke, StrokePoint, Surface, alpha_index, apply_coverage, bad}; /// At most four disjoint rectangles in the document tile. Projecting the dirty area separately /// from the stroke bounds avoids scanning the whole document when a small dab crosses a seam. @@ -23,6 +23,33 @@ 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. +#[allow(clippy::too_many_arguments)] +pub(super) fn stroke( + pre: &Document, + target: &mut Surface, + stroke: &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); + 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, cmd }.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. @@ -34,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<'_> { @@ -138,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 636d3c1ce7..f1980bae0d 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2532,3 +2532,192 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { } } } + +// --------------------------------------------------------------------------------------------- +// View › Pattern Preview: Clone Stamp and Healing Brush wrap 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 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. +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) in wrap_cases() { + let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut canvas, tiled_texture); + let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb"); + 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}")); + 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 = composite_tolerance(depth); + 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}); + for depth in [8, 32] { + for (cmd, extra) in wrap_cases() { + let run = |wrap: bool| { + let mut s = session(300, 230, depth, "rgb"); + 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); + s.execute(cmd, q).unwrap(); + layer_pixels(&s) + }; + 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 <= composite_tolerance(depth), "{cmd} {extra} at {depth}-bit: off by {error}"); + } + } +} + +#[test] +fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { + for (cmd, extra) in wrap_cases() { + let mut s = session(16, 12, 8, "rgb"); + 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() { + 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"); + assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); + } +} + +#[test] +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); + 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 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 (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(); + live.bounds() + }; + assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn"); + } +} diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 798b4f03a0..619a1af7ae 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,15 @@ 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). + // 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), 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); 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..51540e3cfd --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -0,0 +1,113 @@ +//! #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(); +} + +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)) +} + +/// 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, 381))); + 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, 381)), "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. + // 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, 219))); + let mut s = ramp_selected(); + wand(&mut s, 320, 10.0, "intersect"); + assert_eq!(selected(&s), Some((219, 381))); +} + +#[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, 219))); + let mut s = ramp_selected(); + wand_global(&mut s, 480, 32.0, "intersect"); + assert_eq!(selected(&s), Some((219, 381))); +} + +#[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"); +} 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/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index d05c05f46a..f1f8aa53c4 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,56 @@ 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, 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); + } +} + 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 +1003,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..8f6a8058fb --- /dev/null +++ b/crates/engine/src/cmd/type_indexed_mode_tests.rs @@ -0,0 +1,287 @@ +//! #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. +//! +//! 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, 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"; + +/// 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"); + 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. + 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"); + } +} + +/// 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/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 7c4419ea33..2197d57f91 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -2156,7 +2156,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/lib.rs b/crates/engine/src/lib.rs index bb9940c9e5..24187b53bd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -357,4 +357,6 @@ mod tests; #[cfg(test)] mod fx_mode_tests; +#[cfg(test)] +mod brush_clear_background_tests; pub mod command_hints; 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/io/Cargo.toml b/crates/io/Cargo.toml index 36975e57eb..2090bdb00f 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 and embedded-font text become editable +# layers; 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/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/lib.rs b/crates/io/src/lib.rs index 195a58b01a..c796b7cffa 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,13 @@ 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 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, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -628,6 +637,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..af56f64e21 --- /dev/null +++ b/crates/io/src/pdf_editable.rs @@ -0,0 +1,1807 @@ +//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers +//! and embedded-font text into text layers. +//! +//! 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::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}; +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, InterpreterWarning, 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::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, 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; +/// 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 +/// 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. +const LINE_TOLERANCE: f64 = 0.30; + +/// 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 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, + clip: rect, + fmt, + dpi: 72.0 * sx, + ctl: *ctl, + layers: Vec::new(), + glyphs: Vec::new(), + glyph_bytes: 0, + strokes: Vec::new(), + fonts: Vec::new(), + font_keys: HashMap::new(), + seen_fonts: HashSet::new(), + soft_mask: false, + 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); + 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. 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); + 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); + } + 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)); + } + pair_fake_bold(&mut device.glyphs, device.strokes, ctl)?; + let families = register_fonts(&device.fonts)?; + 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) +} + +/// 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, 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()]; + let mut checks = 0usize; + for stroke in strokes { + let mut found = None; + if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, text_hash(&stroke.text))) { + 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.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; + } + } + } + let Some(at) = found else { + return Err(EditableError::Unsupported("stroked text")); + }; + glyphs[at].fake_bold = true; + paired[at] = true; + } + 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, + 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()); + 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 positions = Vec::new(); + let mut last = first; + while i < glyphs.len() { + let glyph = &glyphs[i]; + if glyph.order != first.order || !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(' '); + } + } + positions.push((text.len(), glyph.origin)); + text.push_str(&glyph.text); + last = glyph; + i += 1; + } + if !text.is_empty() && has_ink(&text) { + let layer = text_layer(text, first, out.len(), &family); + if let LayerContent::Text(text) = &layer.content { + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + let layout = engine.layout(text, dpi as f32); + // PDF spacing is already in the glyph origins. A word-gap heuristic cannot + // preserve TJ, character spacing, or explicitly positioned glyphs. Keep a run + // editable only if the actual text layout reproduces every recorded origin. + let clusters: 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; + } + } + Ok(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, 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", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + let style = CharStyle { + 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, + // 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() + }; + let layer = TextLayer { + text: text.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)), + runs: vec![TextRun { len: text.len(), style }], + ..TextLayer::default() + }; + Layer::new(format!("Text {}", number + 1), LayerContent::Text(layer)) +} + +/// 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 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, + // 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(bytes, &alias); + if names.is_empty() && !engine.has_family(&alias) { + return Err(EditableError::Unsupported("a font could not be registered")); + } + 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, cached: &mut u64) -> Result<(), EditableError> { + let shared = photocraft_text::shared(); + for layer in layers { + if ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + if let LayerContent::Text(text) = &mut layer.content + && has_ink(&text.text) + { + // 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 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")); + } + } + } + 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); + 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. +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) +} + +/// 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 { + 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. + 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, + /// Document DPI, for text-layer caches. + dpi: f64, + ctl: Interrupt<'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. + 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. + clips: Vec, + knots: usize, + /// Allocated tile bytes of caches retained so far. + cached: u64, + unsupported: Option<&'static str>, + 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, + /// 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, +} + +/// 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) { + if self.unsupported.is_none() { + self.unsupported = Some(reason); + } + } + + /// 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) { + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if path.is_empty() { + return; + } + 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; + } + if self.layers.len() >= MAX_LAYERS { + self.unsupported("too many objects"); + return; + } + 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 let Err(EditableError::Unsupported(reason)) = admit_cache(&mut self.cached, area, self.fmt) { + self.unsupported(reason); + return; + } + shape.cache = Some(compiled.render(self.fmt, area)); + } + self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); + } +} + +impl<'a> Device<'a> for PathDevice<'_> { + /// 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>) { + self.soft_mask = mask.is_some(); + } + + /// 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; + } + if self.soft_mask { + self.unsupported("soft masks"); + 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) => { + 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; + }; + self.push_shape(path, None, Some(stroke)); + } + } + } + + /// 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); + return; + } + self.clips.push(false); + 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. + let _ = (mask, blend_mode); + 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; + } + if matches!(draw_mode, GlyphDrawMode::Invisible) { + return; + } + if self.soft_mask { + self.unsupported("soft masks"); + return; + } + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if matches!(draw_mode, GlyphDrawMode::Clip) { + 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, + 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; + } + 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, 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; + } + 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); + 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 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.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(_)) { + self.strokes.push(glyph); + } else { + self.glyphs.push(glyph); + } + } + + /// 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"); + } +} + +/// 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 !(5..=6).contains(&elements.len()) { + 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 { + // 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); + 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; + } + // 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)) +} + +/// 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(); + 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(); + // `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(); + 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). +fn knots_affine(transform: &KurboAffine) -> Affine { + let [a, b, c, d, e, f] = transform.as_coeffs(); + Affine { m: [a, b, c, d, e, f] } +} + +/// 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; + } + // 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 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), + 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 * scale as f32 / 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 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() + && (!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); + } + 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() { + let restart = subpath.knots.first().map(|knot| knot.anchor); + 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 { + // 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 { + 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 + // 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; + restarted = true; + } else { + restarted = false; + } + } + } + } + } + if let Some(subpath) = current + && (!restarted || subpath.knots.len() > 1) + { + 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}; + + /// 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! {}) + } + + /// [`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())); + 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" => resources, + }); + 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 + } + + /// 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 { + 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" => 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()), + "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((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", + }); + 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 { + LayerContent::Group(group) => &group.children, + _ => panic!("the page is not a group"), + } + } + + /// 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 { + 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 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!(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)); + 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); + 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 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"); + 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] + 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!(style.font_family.starts_with("Inter"), "{name}: {}", style.font_family); + } + } + + #[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 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") }; + 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); + } + + #[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 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})"); + // 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_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_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 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 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"); + 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_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(); + 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] + 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] + 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))); + } + #[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(_)))); + } +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index d55676ac16..b55da30bec 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 editable_text = false; let mut x: u32 = 0; for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { ctl.check().map_err(|_| IoError::Cancelled)?; @@ -147,6 +149,30 @@ 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) => { + 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); + 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,13 +221,16 @@ 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))); 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/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/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 323a90036a..e639cbaa75 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}; @@ -16,8 +17,15 @@ 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: 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, @@ -40,11 +48,39 @@ 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 { + 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 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. +/// 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); /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. @@ -70,22 +106,15 @@ 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 float = fmt.sample == SampleType::F32; let mut once: Option>> = None; - let match_w = move |d: f32| -> f32 { - 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 - } - }; + // 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 @@ -117,27 +146,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 { @@ -145,16 +187,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"); + } + } +} 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)); + } + } +} 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..dcbfab1a20 --- /dev/null +++ b/crates/paint/src/bg_erase_graded_alpha_tests.rs @@ -0,0 +1,189 @@ +//! 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}")); + } + } + } + } +} + +#[test] +fn protect_foreground_keeps_todays_distance_to_the_foreground() { + // #11903 measured the foreground only one channel away and keeps today's measure otherwise: + // the largest per-channel difference. This foreground is 100 levels off in red and in green + // (opposite signs): within Tolerance 50 by that measure, though the sample-match distance of + // #11901 adds the two to 200. The pixel is the sampled colour, so only the protection keeps it. + let bs = BgEraseSettings { protect_foreground: true, foreground: rgb([100, 140, 40]), ..swatch(50.0, rgb(SAMPLE)) }; + assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 255); + // Protection off: the same click erases the sample fully. + assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &BgEraseSettings { protect_foreground: false, ..bs }), 0); +} 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 4a4219e143..75f3348b08 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -142,6 +142,22 @@ 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) + } + + /// [`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) + } } static SHARED: warm::Warm> = warm::Warm::new(); 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/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/canvas.rs b/crates/ui-egui/src/canvas.rs index e5b0f62e08..c8a4edd8e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1679,6 +1679,15 @@ fn draw_default_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: & { 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/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 90d16e45e8..6e8b5b2d6c 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")), @@ -1000,8 +1000,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 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")?; if command == FILL_SCREEN { @@ -1019,6 +1019,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 && 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(()) }; 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 0ca26a96e6..41c5c72f9d 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(); @@ -399,8 +402,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"), @@ -548,11 +551,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); @@ -562,8 +566,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); @@ -574,12 +577,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); @@ -592,17 +596,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); @@ -614,7 +619,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 new file mode 100644 index 0000000000..d5039a6257 --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -0,0 +1,196 @@ +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); + } + } + } +} + +#[test] +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); + 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(); + 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])); + 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)); + } + } +} + +#[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"] { + 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/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index c31e137e82..d1092867fe 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -1023,3 +1023,59 @@ 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:?}"); +} + +/// #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_cursor.rs b/crates/ui-egui/src/canvas/tool_cursor.rs index 84b69b26cf..c34f4d6622 100644 --- a/crates/ui-egui/src/canvas/tool_cursor.rs +++ b/crates/ui-egui/src/canvas/tool_cursor.rs @@ -111,6 +111,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/canvas/tool_cursor/tests.rs b/crates/ui-egui/src/canvas/tool_cursor/tests.rs index bd5b40c97e..ec003d4845 100644 --- a/crates/ui-egui/src/canvas/tool_cursor/tests.rs +++ b/crates/ui-egui/src/canvas/tool_cursor/tests.rs @@ -1140,3 +1140,16 @@ mod benchmark_stats { assert_eq!(percentile(&[4.5], 0.95), 4.5, "one sample"); } } + +/// #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/canvas/tool_feedback.rs b/crates/ui-egui/src/canvas/tool_feedback.rs index 6c9c890571..88aec042e5 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 { @@ -122,9 +124,12 @@ 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 +328,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/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index c63b3a8e77..0f8a04f770 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -377,6 +377,24 @@ fn mixer_press_failed(app: &mut PhotocraftApp, message: String) { } } +/// The Brush's chosen Clear paints as Normal on the Background, and its Mode then reads Normal +/// (#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 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) { @@ -540,6 +558,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; 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 b8c75a94e1..2866ef5ecb 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 dbae1e6ee7..95da88ae1d 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 @@ -72,6 +75,8 @@ pub fn queue_open_dialog( bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, + // 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); @@ -86,7 +91,7 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe /// [`open_raw`], or with `kept` settings and the settings they started from (a filmstrip raw /// shown again). -pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen, kept: Option<(CameraRaw, CameraRaw)>) -> Result<(), String> { +pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, mut raw: RawOpen, kept: Option<(CameraRaw, CameraRaw)>) -> 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)?; @@ -104,6 +109,7 @@ pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, raw: R 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)]) @@ -208,6 +214,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() }; @@ -387,9 +396,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 @@ -397,7 +410,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 9ac2f8c997..3e5c00f94f 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs @@ -88,7 +88,8 @@ pub(super) fn store_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam None if params.is_identity() => return Stored::Defaults, _ => {} } - 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 Stored::Skipped }; if let Err(e) = write(&sidecar, &bytes) { crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)])); @@ -146,7 +147,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 9c817414bc..76591f3df1 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -705,7 +705,7 @@ fn auto_tone_of_a_never_shown_raw_matches_the_dialogs() { } } let method = photocraft_io::raw::Demosaic::Ahd; - let raw = RawOpen { document, name: "pattern.dng".into(), path: None, bytes: None, developed_with: method, demosaic: method }; + let raw = RawOpen { document, name: "pattern.dng".into(), path: None, bytes: None, developed_with: method, demosaic: method, crop: None }; let (unshown, _) = filmstrip::first_settings(&app, &raw); open_raw(&mut app, &ctx, raw).unwrap(); let shown = &app.camera_raw.as_ref().unwrap().params; @@ -745,7 +745,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() @@ -988,6 +989,74 @@ 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); +} + +/// #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] diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 78691112c0..a34eae8ecd 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(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)); + 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) + }) + } + /// 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.rs b/crates/ui-egui/src/file_open.rs index 901629eb02..3932df61b2 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -600,12 +600,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 652f996b9c..be03d1676a 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -1697,3 +1697,43 @@ 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.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/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/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index c4ff5397c2..7459f54be6 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -286,11 +286,38 @@ 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); + // `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 { + 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 +635,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 +730,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/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..08bb90fa78 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; @@ -1024,6 +1027,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/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 3838a70bcf..c6e7ad8217 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"), @@ -256,8 +257,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"), 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" diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs index 6c36669b53..60def3d64c 100644 --- a/crates/ui-egui/src/panels/brush_panel/tests.rs +++ b/crates/ui-egui/src/panels/brush_panel/tests.rs @@ -1880,6 +1880,47 @@ fn checkboxes_show_a_pressed_state_from_their_label() { assert_eq!(fill(&h), Some(widgets::check_box_fill(true, false, &t))); } +#[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.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); + 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.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})); + 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.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); + assert_eq!(h.state().session.journal.len(), journal + 4); +} + #[test] fn scatter_header_edits_route_to_the_correct_brush_block_and_disabled_sections_ignore_input() { use egui::accesskit::Role; diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index e2fc55fc4c..d7c16283fb 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -54,6 +54,33 @@ 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).accessible_name(tl!("Spacing"))) + .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); + } + 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(); @@ -356,11 +383,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 } diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index 2da8e95c3e..96338c3464 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -340,6 +340,28 @@ fn ellipse_widget_drags_angle_and_roundness() { assert_eq!(r, 1.0); } +#[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_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); + 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"); + } + } +} + fn check_scatter_header(section: usize) { for kind in theme::ThemeKind::ALL { for both_axes in [false, true] { @@ -367,6 +389,43 @@ fn check_scatter_header(section: usize) { } } +#[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.get_by_role_and_label(Role::CheckBox, "Spacing"); + 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"); + } +} + #[test] fn scatter_header_scattering_places_axes_between_label_and_percentage_in_all_themes() { check_scatter_header(2); diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index e4e807acd2..2f59092106 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -222,7 +222,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/patch_cursor_badge_tests.rs b/crates/ui-egui/src/patch_cursor_badge_tests.rs new file mode 100644 index 0000000000..e9cf4b1f0b --- /dev/null +++ b/crates/ui-egui/src/patch_cursor_badge_tests.rs @@ -0,0 +1,119 @@ +//! 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"); + // Size 4 is the half-length h of the shaft; the barbs fall back by a = 0.6 h. + let (h, a, eps) = (4.0_f32, 2.4_f32, 1e-5_f32); + let glyph = badge_glyph(Badge::Arrow, egui::pos2(0.0, 0.0), h); + assert_eq!(glyph.len(), 3, "one shaft and two barbs"); + let near = |p: egui::Pos2, x: f32, y: f32| (p.x - x).abs() < eps && (p.y - y).abs() < eps; + assert!(near(glyph[0][0], -h, 0.0) && near(glyph[0][1], h, 0.0), "shaft through the centre: {:?}", glyph[0]); + for barb in &glyph[1..] { + assert!(near(barb[0], h, 0.0), "a barb starts at the tip: {barb:?}"); + } + let (up, down) = (glyph[1][1], glyph[2][1]); + assert!((up.x - (h - a)).abs() < eps && (down.x - (h - a)).abs() < eps, "barbs end behind the tip"); + assert!((up.y + down.y).abs() < eps && (up.y.abs() - a).abs() < eps, "barb ends mirror about the shaft: {up:?} {down:?}"); +} + +#[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 a_patch_drag_started_outside_does_not_show_the_arrow_when_it_enters_the_selection() { + let mut app = app(); + app.hover_doc = Some(OUTSIDE); + let plain_outside = badge(&app, Tool::Patch, Modifiers::NONE); + app.drag = Some(Drag::new(Tool::Patch, OUTSIDE, vec![[OUTSIDE[0], OUTSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), plain_outside); + assert_ne!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); +} + +#[test] +fn content_aware_move_has_no_arrow_even_in_a_drag_started_inside() { + let mut app = app(); + app.drag = Some(Drag::new(Tool::ContentAwareMove, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::ContentAwareMove, Modifiers::NONE), None); +} + +/// The release reads the press's modifiers, so ⇧ or ⌥ pressed mid-drag changes nothing. +#[test] +fn modifiers_pressed_mid_drag_do_not_change_the_badge() { + 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); + for m in [Modifiers::SHIFT, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] { + assert_eq!(badge(&app, Tool::Patch, m), Some(Badge::Arrow), "{m:?}"); + } +} + +#[test] +fn a_drag_pressed_with_shift_or_alt_keeps_that_badge() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + for (pressed, want) in [(Modifiers::SHIFT, Badge::Add), (Modifiers::ALT, Badge::Subtract)] { + app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], pressed, false)); + assert_eq!(badge(&app, Tool::Patch, pressed), Some(want)); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(want), "released keys mid-drag"); + } +} + +#[test] +fn shift_and_alt_together_over_the_selection_intersect() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::SHIFT | Modifiers::ALT), Some(Badge::Intersect)); +} + +#[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); + // The Curvature Pen over an anchor of the active path: four arrows. + let path = json!({"subpaths": [{"closed": true, "knots": [[40, 40], [160, 40], [160, 160], [40, 160]]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + app.ui.tool = Tool::CurvaturePen; + app.hover_doc = Some([160.0, 40.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), Some(Badge::Move)); + 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/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index aefc2bb3c7..2e73604cc9 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -494,3 +494,119 @@ fn the_pen_does_not_carry_on_a_work_path_hidden_by_make_selection() { assert_eq!(anchors(&saved.path), [vec![[150.0, 150.0], [200.0, 150.0], [200.0, 190.0]]], "the saved path carried on"); assert_eq!(anchors(&work_path(&app)).len(), 1, "the hidden Work Path untouched"); } + +/// #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"); +} + +/// #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"); +} + +/// #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/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"); + } +} diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 02c18313f5..d09d91e9d5 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, }, ); @@ -974,3 +976,34 @@ fn inline_drop_slot_survives_the_memory_prompt() { }); }); } + +/// A damaged PSD opened as a smart object, once confirmed, is admitted as one: the history says +/// "Open As Smart Object" and two same-stem files get distinct names (#12212 review). +#[test] +fn confirmed_damaged_smart_object_opens_are_admitted_as_smart_objects_with_distinct_names() { + let mut app = app(); + let bytes = recovery_psd::make_psd(true); + let dir = std::env::temp_dir().join(format!("photocraft-damaged-so-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut paths = Vec::new(); + for sub in ["a", "b"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + let path = dir.join(sub).join("dmg.psd"); + std::fs::write(&path, &bytes).unwrap(); + paths.push(path.to_string_lossy().into_owned()); + } + for path in &paths { + crate::jobs_ui::start_open_as_smart_object(&mut app, path, None).unwrap(); + } + assert!(app.session.documents().is_empty()); + for _ in 0..2 { + let id = damage_dialog(&mut app); + assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": id}))["ok"], true); + } + let names: Vec<_> = 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); +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 896320471d..b921d98268 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -58,5 +58,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 8e3377c5b0..ad31e53463 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). @@ -68,7 +72,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, @@ -324,7 +328,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 => {} } @@ -556,8 +563,26 @@ 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() { + 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() { 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..517e7df649 100644 --- a/crates/ui-egui/src/tools/analysis_ui/tests.rs +++ b/crates/ui-egui/src/tools/analysis_ui/tests.rs @@ -296,3 +296,147 @@ 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"); + } +} + +/// 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. +#[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"); +} 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; 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); +} 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::*; diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index 884c775e29..dbfef4bda1 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -29,9 +29,9 @@ 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); } diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index 375f8a410a..ad2bb0a79a 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -28,6 +28,44 @@ fn stripes(app: &mut PhotocraftApp, step: usize, target: &str) { } } +#[test] +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::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, wraps) 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), wraps && pattern_preview, "{tool:?}: {p}"); + } + } +} + #[test] fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { for pattern_preview in [false, true] { @@ -587,7 +625,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"); } } diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..d0807fe5dd 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; @@ -137,12 +140,19 @@ mod canvas_tests; mod font_commit_tests; #[cfg(test)] mod font_picker_tests; +#[cfg(test)] +mod options_tooltips_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod mask_replace_selection_tests; + #[cfg(test)] 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 cc91f8abd4..96c0551955 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -798,25 +798,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 @@ -2731,10 +2732,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()); @@ -2744,7 +2745,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); @@ -3008,19 +3009,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| { @@ -3035,14 +3035,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..02ba991308 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; @@ -473,6 +473,34 @@ 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` 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. + 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 +554,156 @@ 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, LayerId, TextLayer}; + 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_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() + } + + 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"); + } + } + + /// #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"); + } + } +} 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/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"); + } +} 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/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index 33287d1234..d533e10cd0 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})); @@ -95,9 +100,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})); @@ -128,7 +148,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:?}"); +} 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 } 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}"); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index bbd674c4f4..e8fe67f8bd 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -56,7 +56,8 @@ pub(crate) use pen::over_first_anchor; use pen::pen_to_json; pub(crate) use pen::{PATH_OPS, pen_path, pen_replace}; pub use pen::{ - cancel_stale, closes_path_at, freeform_finish, pen_break_handle, pen_commit, pen_down, pen_end, pen_grab_handle, pen_move, pen_undo_last_point, pen_up, + cancel_stale, closes_path_at, continues_path_at, freeform_finish, pen_break_handle, pen_commit, pen_down, pen_end, pen_grab_handle, pen_move, + pen_undo_last_point, pen_up, }; #[cfg(test)] use pen::{closing, pen_path_op}; diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 563b78455b..d31dac59a8 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}; @@ -34,6 +34,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 the 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. @@ -47,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); @@ -61,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 @@ -512,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)) } @@ -550,17 +564,15 @@ 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; - 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; - }; - Some(resumed(doc, name.clone(), layer, i, s, reversed)) - }) + let dist = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y); + // 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 diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 80ecc0455e..99501ca8d5 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-11-07 · **Change:** minor (Both Axes on Scatter headers, #3825 partial; Angle/Roundness fields left of the Brush Tip Shape widget, labels right-aligned, #3825 partial; the eraser end of the pen switches to the Eraser on Windows with Windows Ink on, #2285; no estimate changed; Art History Brush Opacity on the options bar, #10263; no estimate changed; Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; 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; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Both Axes on Scatter headers, #3825 partial; Brush Tip Shape Spacing checkbox and percentage share one header, #3825 partial; Angle/Roundness fields left of the Brush Tip Shape widget, labels right-aligned, #3825 partial; the eraser end of the pen switches to the Eraser on Windows with Windows Ink on, #2285; no estimate changed; Art History Brush Opacity on the options bar, #10263; no estimate changed; Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; 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; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **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. | | Brush Tip Shape places aligned Angle/Roundness controls left of the interactive widget | done | #3825 (Angle/Roundness layout only); UI regressions cover 14 themes at two panel widths, imported-value retention, numeric limits, actual arrow/handle gestures and existing command 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 | | @@ -135,6 +136,7 @@ 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. | | 2026-10-13 | minor | Brush-only Shift+right-click Mode menu shares options-bar choices, including Clear; preserves existing right-click gestures and consumes outside dismissal without painting (#4929). No brush-feel estimate changed. | | 2026-10-13 | minor | Scattering and Dual Brush place Both Axes on the shared Scatter header (#3825, partial), preserving label scrubbing and values. No brush-feel estimate changed. | | 2026-10-13 | minor | Angle/Roundness controls use right-aligned labels in the left column, with the interactive widget on the right (#3825, partial). Units, limits and gestures retained; no brush-feel estimate changed. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5934696f08..7ebf31bbe9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -86,7 +86,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 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. @@ -565,7 +565,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 b3d708494d..207778cb37 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-11-06 · **Change:** minor (CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; Hasselblad 3FR/FFF, the X2D II 100C, decodes; scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; 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-11-08 · **Change:** minor (Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; Hasselblad 3FR/FFF, the X2D II 100C, decodes; scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; 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. @@ -75,7 +75,7 @@ supports many more, including CR3). | Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | | | Hasselblad 3FR/FFF | uncompressed only; compressed (lossless-JPEG) files: embedded preview only | X2D II 100C uses a measured Bayer phase; other bodies assume it with a warning (`crates/raw/src/hasselblad.rs`) | | Others (CRW, 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 @@ -157,6 +157,7 @@ The original reference entry retains its source date and historical totals; curr | 2026-10-24 | minor | Hasselblad 3FR/FFF: the X2D II 100C's 16-bit uncompressed CFA decodes; compressed 3FR still opens as the embedded preview | | 2026-11-02 | minor | Actions Shear (`Shr `) loads, plays and saves the measured curve points and edge mode; unsafe subranges and unrepresentable forms remain kept steps (#10605). | | 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. The ICC profile, EXIF and resolution are written as they are without the option (a palette PNG carries only its resolution, either way); PSD, PSB and `.pcraft` always keep the XMP (#9466) | +| 2026-11-03 | 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-11-01 | minor | `.atn`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | | 2026-11-01 | minor | Recorded direct Gradient Map and Lab-range Replace Color load, play and retain their measured settings on save; unsupported forms remain preserved (part of #10607). | | 2026-11-02 | minor | TIFF Options on Save As and Save a Copy (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid (half-size levels marked reduced-resolution, down to 64 px, alpha-weighted; +45 ms on a 30 MP RGB/8 save), Save Transparency (on: transparency kept ahead of alpha and spot channels, `MTrn` in a layered file; off: matted on white) and Discard Layers and Save a Copy. Scripts, `doc.save`, MCP `save` and the CLI (`--tiff-transparency`, `--tiff-pyramid`) take the same answers. JPEG compression, Pixel Order and Byte Order not offered | diff --git a/docs/gaps.md b/docs/gaps.md index 836f12934c..7a038cf6cf 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-11-07 · **Change:** minor (G4: Both Axes on Scatter headers, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: 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; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: 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; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **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 @@ -269,6 +269,10 @@ what an agent can't finish alone. and 0/49% smoothing. Other presets, explicit spacing and remembered brushes retain their settings; reselect a built-in preset to take its new default. Other brush-feel and latency gaps remain open; percentages and estimates are unchanged. +- **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):** Scattering and Dual Brush place Both Axes between the Scatter label and percentage field. The existing amount track, label @@ -568,6 +572,7 @@ what an agent can't finish alone. | 2026-10-13 | minor | G8: a Type tool click on the work path or the active shape layer's path starts type on it at the point clicked (TYPE-218-1, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G14: lossy DNG (#5112), `.xmp` sidecars, Camera Raw Defaults wired, Calibration panel and Auto; CR3 sensor decode ruled out under the clean-room rule | | 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 | G2: Content-Aware Scale (P28) measured: carve 2013 → 556 ms, row 3.28 → 1.64 s on a 4-core VM, output unchanged; baseline, budgets and estimate unchanged | | 2026-10-13 | minor | G4: Scattering and Dual Brush Both Axes header placement (#3825, partial); label scrubbing and value retention preserved; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Angle/Roundness column and label alignment (#3825, partial); existing values and interactions retained; no readiness percentage or estimate changed |