mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 14:27:35 -06:00
Photoshop keeps the pixels an earlier Delete Cropped Pixels off crop hid only under a turned frame. A straight crop pulled back out over them fills the whole area past the old canvas from the canvas, with Delete Cropped Pixels off or on (checked: 64x48 green with a red left strip, crop to x=16 keeping pixels, then a Content-Aware crop to x=-24 w=72: all green). The PR took the snapshot for straight crops too, so the hidden red came back. The straight path is main's again; the turned path keeps the snapshot. extending_crop_keeps_the_hidden_pixels becomes extending_crop_fills_over_the_hidden_pixels (transparent and white Background, Delete off and on); it fails with the straight snapshot back. Co-authored-by: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.5 KiB
Rust
219 lines
9.5 KiB
Rust
//! Crop tool › Fill: Content-Aware Fill (#3823). A rotated crop, or one that extends past the
|
||
//! canvas, fills the area the image doesn't cover from its surroundings instead of the background
|
||
//! colour, in the crop's one undo step; an unrotated crop inside the canvas is unaffected.
|
||
|
||
use photocraft_engine::Session;
|
||
use serde_json::{Value, json};
|
||
|
||
const MODES: [(&str, u32); 5] = [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 16), ("cmyk", 8)];
|
||
|
||
/// A 64 × 48 document whose Background is a light green, with black as the background colour (what
|
||
/// a plain crop puts in the uncovered area).
|
||
fn doc(mode: &str, depth: u32, background: &str) -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": depth, "background": background})).unwrap();
|
||
s.execute("tools.setColors", json!({"foreground": "#c0e0a0", "background": "#000000"})).unwrap();
|
||
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn layer(s: &Session) -> &photocraft_raster::Surface {
|
||
let d = &s.active().unwrap().doc;
|
||
d.layers[0].surface().unwrap()
|
||
}
|
||
|
||
fn size(s: &Session) -> (u32, u32) {
|
||
let d = &s.active().unwrap().doc;
|
||
(d.size.width, d.size.height)
|
||
}
|
||
|
||
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
|
||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < tol)
|
||
}
|
||
|
||
/// Every pixel of the cropped layer is the image's colour: no black (or transparent) area left.
|
||
fn assert_all_image(s: &Session, want: [f32; 4], what: &str) {
|
||
let (w, h) = size(s);
|
||
let surf = layer(s);
|
||
for y in 0..h as i32 {
|
||
for x in 0..w as i32 {
|
||
let px = surf.rgba(x, y);
|
||
assert!(close(px, want, 0.06), "{what}: ({x}, {y}) is {px:?}, want {want:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
fn crop(s: &mut Session, mut p: Value, content_aware: bool) -> photocraft_engine::Result<Value> {
|
||
p["contentAware"] = json!(content_aware);
|
||
s.execute("image.crop", p)
|
||
}
|
||
|
||
#[test]
|
||
fn rotated_crop_fills_the_corners_in_every_mode() {
|
||
for (mode, depth) in MODES {
|
||
let frame = json!({"x": 0, "y": 0, "width": 64, "height": 48, "angle": 10});
|
||
let mut plain = doc(mode, depth, "white");
|
||
let want = layer(&plain).rgba(32, 24);
|
||
crop(&mut plain, frame.clone(), false).unwrap();
|
||
assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}: a plain rotated crop leaves black corners");
|
||
|
||
let mut s = doc(mode, depth, "white");
|
||
let past = s.active().unwrap().history.past_len();
|
||
crop(&mut s, frame, true).unwrap();
|
||
assert_eq!(size(&s), (64, 48), "{mode} {depth}");
|
||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{mode} {depth}: one undo step");
|
||
assert_all_image(&s, want, &format!("{mode} {depth} rotated"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn extending_crop_fills_the_added_canvas_in_every_mode() {
|
||
for (mode, depth) in MODES {
|
||
let frame = json!({"x": -8, "y": -6, "width": 80, "height": 60});
|
||
let mut plain = doc(mode, depth, "white");
|
||
let want = layer(&plain).rgba(32, 24);
|
||
crop(&mut plain, frame.clone(), false).unwrap();
|
||
assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}");
|
||
|
||
let mut s = doc(mode, depth, "white");
|
||
crop(&mut s, frame, true).unwrap();
|
||
assert_eq!(size(&s), (80, 60), "{mode} {depth}");
|
||
assert_all_image(&s, want, &format!("{mode} {depth} extended"));
|
||
}
|
||
}
|
||
|
||
/// Without a Background the active pixel layer is filled: no transparent strip is left.
|
||
#[test]
|
||
fn extending_crop_fills_the_active_layer_without_a_background() {
|
||
let mut s = doc("rgb", 16, "transparent");
|
||
let want = layer(&s).rgba(32, 24);
|
||
crop(&mut s, json!({"x": 0, "y": -10, "width": 64, "height": 58}), true).unwrap();
|
||
assert_all_image(&s, want, "transparent document");
|
||
}
|
||
|
||
/// Undo takes the crop and the fill back in one step.
|
||
#[test]
|
||
fn undo_restores_crop_and_fill_in_one_step() {
|
||
let mut s = doc("rgb", 8, "white");
|
||
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap();
|
||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap();
|
||
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||
s.execute("select.deselect", json!({})).unwrap();
|
||
let before = s.active().unwrap().doc.clone();
|
||
crop(&mut s, json!({"x": -4, "y": -4, "width": 40, "height": 40, "angle": -6}), true).unwrap();
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(size(&s), (64, 48));
|
||
let after = layer(&s);
|
||
let orig = before.layers[0].surface().unwrap();
|
||
for y in 0..48 {
|
||
for x in 0..64 {
|
||
assert_eq!(after.rgba(x, y), orig.rgba(x, y), "({x}, {y})");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `contentAware: false` (or absent), and a crop that uncovers nothing, are the plain crop.
|
||
#[test]
|
||
fn nothing_uncovered_or_off_is_the_plain_crop() {
|
||
for (frame, ca) in [
|
||
(json!({"x": 5, "y": 4, "width": 30, "height": 20}), true),
|
||
(json!({"x": -5, "y": -4, "width": 80, "height": 60}), false),
|
||
(json!({"x": 20, "y": 15, "width": 20, "height": 14, "angle": 4}), true),
|
||
] {
|
||
let mut a = doc("rgb", 16, "white");
|
||
let mut b = doc("rgb", 16, "white");
|
||
a.execute("image.crop", frame.clone()).unwrap();
|
||
crop(&mut b, frame.clone(), ca).unwrap();
|
||
assert_eq!(size(&a), size(&b), "{frame}");
|
||
let (w, h) = size(&a);
|
||
for y in 0..h as i32 {
|
||
for x in 0..w as i32 {
|
||
assert_eq!(layer(&a).rgba(x, y), layer(&b).rgba(x, y), "{frame} ({x}, {y})");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Bad params and an unfillable crop fail without changing the document.
|
||
#[test]
|
||
fn bad_params_and_unfillable_crops_fail_cleanly() {
|
||
let frame = json!({"x": -4, "y": 0, "width": 70, "height": 48});
|
||
for v in [json!("yes"), json!(1), json!([true]), json!({"on": true})] {
|
||
let mut s = doc("rgb", 8, "white");
|
||
let past = s.active().unwrap().history.past_len();
|
||
let mut p = frame.clone();
|
||
p["contentAware"] = v.clone();
|
||
assert!(s.execute("image.crop", p).is_err(), "{v}");
|
||
assert_eq!(size(&s), (64, 48), "{v}");
|
||
assert_eq!(s.active().unwrap().history.past_len(), past, "{v}");
|
||
}
|
||
// Entirely off the canvas: nothing of the image to sample from (both paths).
|
||
for p in [json!({"x": 500, "y": 500, "width": 20, "height": 20}), json!({"x": 500, "y": 500, "width": 20, "height": 20, "angle": 30})] {
|
||
let mut s = doc("rgb", 8, "white");
|
||
let past = s.active().unwrap().history.past_len();
|
||
let err = crop(&mut s, p.clone(), true).unwrap_err();
|
||
assert!(err.to_string().contains("sample"), "{p}: {err}");
|
||
assert_eq!(size(&s), (64, 48), "{p}");
|
||
assert_eq!(s.active().unwrap().history.past_len(), past, "{p}");
|
||
}
|
||
}
|
||
|
||
/// `doc` with its left 16 columns red, then cropped to x = 16 keeping the cropped pixels: the red
|
||
/// lies hidden past the canvas's left edge.
|
||
fn hidden_red(background: &str) -> Session {
|
||
let mut s = doc("rgb", 8, background);
|
||
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap();
|
||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap();
|
||
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||
s.execute("select.deselect", json!({})).unwrap();
|
||
s.execute("image.crop", json!({"x": 16, "y": 0, "width": 48, "height": 48, "deleteCroppedPixels": false})).unwrap();
|
||
assert_eq!(size(&s), (48, 48));
|
||
s
|
||
}
|
||
|
||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||
|
||
/// A straight frame pulled back out over pixels a Delete Cropped Pixels off crop hid fills over
|
||
/// them from the canvas, as Photoshop does with Delete Cropped Pixels off or on (#8060).
|
||
#[test]
|
||
fn extending_crop_fills_over_the_hidden_pixels() {
|
||
for background in ["transparent", "white"] {
|
||
for delete in [false, true] {
|
||
let mut s = hidden_red(background);
|
||
let green = layer(&s).rgba(24, 24);
|
||
crop(&mut s, json!({"x": -24, "y": 0, "width": 72, "height": 48, "deleteCroppedPixels": delete}), true).unwrap();
|
||
assert_eq!(size(&s), (72, 48));
|
||
let surf = layer(&s);
|
||
for y in 0..48 {
|
||
for x in 0..24 {
|
||
let px = surf.rgba(x, y);
|
||
assert!(close(px, green, 0.06), "{background} delete {delete}: ({x}, {y}) is filled from the canvas, not the hidden red: {px:?}");
|
||
}
|
||
assert!(close(surf.rgba(40, y), green, 1e-6), "{background} delete {delete}: the canvas is unchanged");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A turned frame that reaches over the Background's hidden pixels shows them, not a fill
|
||
/// from the canvas (#8060).
|
||
#[test]
|
||
fn rotated_crop_keeps_the_hidden_pixels() {
|
||
let mut s = hidden_red("white");
|
||
// A diamond on the canvas's left edge: its left half lies over the hidden red, all of it inside
|
||
// the turned canvas (so nothing takes the background colour).
|
||
crop(&mut s, json!({"x": -10, "y": 14, "width": 20, "height": 20, "angle": 45, "deleteCroppedPixels": false}), true).unwrap();
|
||
assert_eq!(size(&s), (20, 20));
|
||
let (w, h) = size(&s);
|
||
let surf = layer(&s);
|
||
let mut red = 0;
|
||
for y in 0..h as i32 {
|
||
for x in 0..w as i32 {
|
||
let px = surf.rgba(x, y);
|
||
assert!(px[0] > 0.5 && px[3] > 0.99, "no background colour or transparency at ({x}, {y}): {px:?}");
|
||
red += usize::from(close(px, RED, 0.06));
|
||
}
|
||
}
|
||
assert!(red >= 100, "the hidden red shows in the turned frame: {red} red pixels");
|
||
}
|