Compare commits

...
Author SHA1 Message Date
Claude 94c003452f Regenerate docs/scorecard.md (stale on main since #1084) 2026-10-09 00:07:06 +00:00
Claude d4e71d568f Merge pr/1487 2026-10-09 00:06:26 +00:00
Claude 045d2317f5 Merge pr/1486 2026-10-09 00:06:26 +00:00
Claude cf66b7c9e1 Merge pr/1485 2026-10-09 00:06:26 +00:00
Claude 1cf662fbf8 Merge pr/1365 2026-10-09 00:06:26 +00:00
Claude 94bce47b10 Merge pr/1378 2026-10-09 00:06:26 +00:00
Claude 019fce25e7 Merge pr/1394 2026-10-09 00:06:26 +00:00
harrisonoconnorhover 28af006ec7 Fix Paint Bucket sampling across visible layers 2026-10-08 23:26:11 +00:00
MarekSpodBiedry 4b3d448715 Edit › Toolbar: hidden tools leave the toolbar (#1116) 2026-10-08 23:24:12 +00:00
MarekSpodBiedry 627baa422a Select › Modify refuses a radius below the dialog's minimum instead of clamping it to 0 (#993)
modify() rejected only a non-finite radius or one above the maximum, then
clamped with r.max(0.0). A negative or zero radius reported success: Border
cleared the selection, Expand and Contract did nothing, and Feather 0 was
accepted although its documented range starts at 0.1.

Each command now checks the range in its params string (Border 1..200,
Smooth/Expand/Contract 1..500, Feather 0.1..1000). A radius outside it,
including NaN and infinity, returns BadParams naming that range, and the
selection is left as it was. The clamp is removed.
2026-10-08 23:22:53 +00:00
harrisonoconnorhover a195a85664 Test Export As scaling through real PNG exports 2026-10-08 15:23:16 -05:00
ragtuxandClaude Fable 5.1 c53af5c41e Export As: a scale that changes only the height is applied (#1353)
export_document scaled the width alone and skipped Image Size when the
rounded width matched the original, so a 1 × 100 document exported at
50% stayed 1 × 100 while the dialog promised 1 × 50. Scale both sides,
send both to image.imageSize, and let the size label use the same
rounding (never below 1 px) so it names the exported size.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 14:28:22 -05:00
MarekSpodBiedry 2314270763 New Document: a size typed in millimetres or another physical unit keeps its digits (#1147) 2026-10-08 21:11:36 +02:00
Claude Opus 5.5 cad2ed4610 PNM: the file, not the header, bounds what a decode reserves (#1110)
ASCII PGM/PPM reserved a u32 (four bytes) per declared sample before
reading any, against a Limits budget of one or two bytes per sample, so a
19-byte header could ask for 12 GiB. With Limits::none() a 2³¹ × 2³¹
header panics with "capacity overflow"; PBM reserved 2⁶² bytes before
finding the raster missing and aborted. ASCII reservations are now capped
by what the remaining bytes can hold (a PBM digit is at least a byte, a
PGM/PPM sample at least a digit and a separator), and binary PBM checks
the raster length before reserving.

Limits::none() documents that it is still bounded by usize overflow
checks, but the decoder multiplied w × h × channels, n × 2 (16-bit
samples) and the PFM raster length unchecked. A new sample_count checks
the product, and the length checks use checked_mul.

Tests: a header alone reserves nothing (P1-P4), u32::MAX sizes are errors
(P3, P5, P6, PF, P7), and ASCII files with no slack (PBM digits without
separators, no trailing whitespace) still decode. The first two failed
before the fix: an abort on the 2⁶² reservation, "capacity overflow" for
the P2/P3 headers, and "attempt to multiply with overflow".
2026-10-08 14:52:43 -04:00
12 changed files with 804 additions and 51 deletions

No files matched your search

+27 -1
View File
@@ -186,10 +186,23 @@ pub fn bucket_fill_src(
let img = crate::selection::rgba8_image(s, area);
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
drop(img);
bucket_fill_region_src(s, &region, opacity, selection, lock_transparency, src);
true
}
/// Paints a bucket region sampled before the edit, so its boundary can come from the visible
/// composite while only `s` receives the colour or pattern. Selection coverage still clips it.
pub fn bucket_fill_region_src(
s: &mut Surface,
region: &crate::selection::Region,
opacity: f32,
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) {
// Composite only over the filled region's box.
let b = region.bbox;
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
true
}
#[cfg(test)]
@@ -244,4 +257,17 @@ mod tests {
assert_eq!(s.pixel(8, 2), vec![1.0, 1.0, 1.0, 1.0]);
assert!(!bucket_fill(&mut s, a, (50, 1), 10.0, true, false, [1.0, 0.0, 0.0, 1.0], 1.0, None, false));
}
#[test]
fn bucket_region_uses_document_coordinates_and_multiplies_coverage() {
let mut s = Surface::new(PixelFormat::RGBA8);
let region = crate::selection::Region { bbox: Rect::new(-3, 4, 0, 5), mask: vec![255, 128, 255] };
let mut selection = Surface::new(PixelFormat::GRAY8);
selection.fill_rect(Rect::new(-3, 4, -1, 5), &[0.5]);
bucket_fill_region_src(&mut s, &region, 0.5, Some(&selection), false, |_, _| [1.0, 0.0, 0.0, 1.0]);
assert!((s.rgba(-3, 4)[3] - 0.25).abs() < 0.005);
assert!((s.rgba(-2, 4)[3] - 0.125).abs() < 0.005);
assert_eq!(s.rgba(-1, 4), [0.0; 4], "outside the selection");
assert_eq!(s.rgba(-4, 4), [0.0; 4], "outside the region");
}
}
+24 -10
View File
@@ -93,6 +93,15 @@ fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec<u
}
}
/// `w × h × channels` samples. `Limits::none()` lets any header through, so the product is
/// checked here rather than trusted to the limits.
fn sample_count(w: u32, h: u32, channels: usize) -> Result<usize, CodecError> {
(w as usize)
.checked_mul(h as usize)
.and_then(|p| p.checked_mul(channels))
.ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{h}x{channels} samples do not fit in memory")))
}
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecError> {
if maxval < 256 {
if data.len() < n {
@@ -100,10 +109,10 @@ fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecErro
}
Ok(data[..n].iter().map(|&v| v as u32).collect())
} else {
if data.len() < n * 2 {
let Some(len) = n.checked_mul(2).filter(|&len| data.len() >= len) else {
return Err(err("truncated raster"));
}
Ok(data[..n * 2].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
};
Ok(data[..len].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
}
}
@@ -117,15 +126,16 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
b'1' | b'4' => {
let (w, h) = (r.uint()?, r.uint()?);
limits.check(w, h, ChannelLayout::Gray, SampleType::U8)?;
let n = w as usize * h as usize;
let mut px = Vec::with_capacity(n);
let n = sample_count(w, h, 1)?;
let mut px;
if kind == b'4' {
r.single_ws()?;
let row = (w as usize).div_ceil(8);
let data = r.rest();
if data.len() < row * h as usize {
if row.checked_mul(h as usize).is_none_or(|len| data.len() < len) {
return Err(err("truncated raster"));
}
px = Vec::with_capacity(n);
for y in 0..h as usize {
for x in 0..w as usize {
let bit = (data[y * row + x / 8] >> (7 - x % 8)) & 1;
@@ -133,6 +143,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
}
} else {
// Each ASCII pixel takes at least a byte, so the file bounds the reservation (#1110).
px = Vec::with_capacity(n.min(r.rest().len()));
while px.len() < n {
r.skip_ws_and_comments();
match r.b.get(r.pos) {
@@ -150,12 +162,14 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let (w, h, maxval) = (r.uint()?, r.uint()?, r.uint()?);
let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb };
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = w as usize * h as usize * layout.channels();
let n = sample_count(w, h, layout.channels())?;
let values = if matches!(kind, b'5' | b'6') {
r.single_ws()?;
read_binary(r.rest(), n, maxval)?
} else {
let mut v = Vec::with_capacity(n);
// Each ASCII sample is a digit and a separator (the last may lack one), so the
// file, not the header, bounds the reservation (#1110).
let mut v = Vec::with_capacity(n.min(r.rest().len() / 2 + 1));
for _ in 0..n {
v.push(r.uint()?);
}
@@ -177,7 +191,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let nc = layout.channels();
let row = w as usize * nc;
let data = r.rest();
if data.len() < row * h as usize * 4 {
if sample_count(w, h, nc)?.checked_mul(4).is_none_or(|len| data.len() < len) {
return Err(err("truncated raster"));
}
let le = scale < 0.0;
@@ -235,7 +249,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
}
};
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = w as usize * h as usize * layout.channels();
let n = sample_count(w, h, layout.channels())?;
let values = read_binary(r.rest(), n, maxval)?;
from_values(w, h, layout, maxval, values)
}
+36
View File
@@ -100,6 +100,42 @@ fn pnm_bomb_header_rejected() {
assert!(is_limit(decode(b"PF\n100000 100000\n-1.0\n")));
}
#[test]
fn pnm_header_alone_reserves_nothing_without_limits() {
// #1110: ASCII PGM/PPM reserved four bytes per declared sample before reading any. A
// 2³¹ × 2³¹ header asked for more than isize::MAX ("capacity overflow"), and PBM reserved
// 2⁶² bytes before finding the raster missing (an abort).
let none = opts(Limits::none());
let side = 1u32 << 31;
for f in [format!("P1\n{side} {side}\n"), format!("P2\n{side} {side}\n255\n"), format!("P3\n{side} {side}\n255\n"), format!("P4\n{side} {side}\n")] {
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
}
}
#[test]
fn pnm_header_sizes_that_overflow_usize_are_errors_without_limits() {
let m = u32::MAX;
let none = opts(Limits::none());
for f in [
format!("P3\n{m} {m}\n255\n"),
format!("P6\n{m} {m}\n255\n"),
format!("P5\n{m} {m}\n65535\n"),
format!("PF\n{m} {m}\n-1.0\n"),
format!("P7\nWIDTH {m}\nHEIGHT {m}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n"),
] {
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
}
}
#[test]
fn ascii_pnm_with_the_tightest_layout_still_decodes() {
// The capacity bound for #1110 must not cut off files with no slack: PBM digits need no
// separator, and the last ASCII sample needs no trailing whitespace.
assert_eq!(decode(b"P1\n3 1\n010").unwrap().data(), &[255, 0, 255]);
assert_eq!(decode(b"P2\n3 1\n255\n1 2 3").unwrap().data(), &[1, 2, 3]);
assert_eq!(decode(b"P3\n1 1\n255\n9 8 7").unwrap().data(), &[9, 8, 7]);
}
#[test]
fn jpeg_bomb_header_rejected() {
// SOI + SOF0 claiming 65535x65535x3 with no scan data.
+170 -6
View File
@@ -1,6 +1,6 @@
//! Paint helpers: Paint Bucket and Gradient tool.
use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_src, paint_gradient};
use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_region_src, bucket_fill_src, paint_gradient};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, blend_from_str};
@@ -40,6 +40,16 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
let c = color(p.get("color"), fg);
let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32);
let (tol, contiguous, aa, opacity) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), f(p, "opacity", 100.0) / 100.0);
let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
// Other visible layers can define the region, but the fill still writes only to the target.
let region = if all {
let (area, img) = crate::selection_cmds::sample_rgba8(s, true)?;
let region = photocraft_algo::selection::wand_region(&img, area, (x, y), tol, contiguous, aa);
drop(img);
region
} else {
None
};
// Fill source: foreground colour (default) or a pattern (the Paint Bucket "Fill" dropdown).
let source = p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str).unwrap_or("foreground");
let pattern = if source == "pattern" {
@@ -55,14 +65,23 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
let area = doc.bounds();
let sel = doc.selection.clone();
let (surf, lock) = crate::channel_cmds::target_surface(doc, *active, p)?;
let ok = if let Some((tile, scale, angle, phase)) = &pattern {
let ok = if all && region.is_none() {
false
} else if let Some((tile, scale, angle, phase)) = &pattern {
// Render the pattern over the canvas once, then sample it at each filled pixel.
let place = photocraft_compose::pattern::Placement::new(photocraft_geom::Rect::EMPTY, false, *phase, *scale, *angle);
let rendered = photocraft_compose::pattern::render(tile, &place, area);
let w = area.width() as usize;
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |px, py| {
rendered[(py - area.y0) as usize * w + (px - area.x0) as usize]
})
let src = |px: i32, py: i32| rendered[(py - area.y0) as usize * w + (px - area.x0) as usize];
if let Some(region) = &region {
bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, src);
true
} else {
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src)
}
} else if let Some(region) = &region {
bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, |_, _| c);
true
} else {
bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref(), lock)
};
@@ -117,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Paint Bucket",
menu: &[],
shortcut: None,
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
enabled: crate::commands::has_paintable,
run: bucket,
journal: true,
@@ -151,6 +170,151 @@ mod tests {
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)
}
/// A visible wall on the Background with an empty, active layer above it.
fn bucket_layers(depth: u32) -> (Session, photocraft_doc::LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 20, "height": 10, "depth": depth, "background": "white"})).unwrap();
let bottom = s.active().unwrap().active_layer.unwrap();
s.edit("wall", |doc, _| {
doc.layer_mut(bottom).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 0, 11, 10), &[0.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
(s, bottom)
}
#[test]
fn bucket_sample_all_layers_uses_visible_boundaries_at_all_depths() {
for depth in [8, 16, 32] {
for all in [None, Some(false), Some(true)] {
let (mut s, bottom) = bucket_layers(depth);
let before = s.active().unwrap().doc.layer(bottom).unwrap().clone();
let mut p = json!({"x": 2, "y": 2, "color": "#ff0000", "antiAlias": false, "tolerance": 0});
if let Some(all) = all {
p["sampleAllLayers"] = json!(all);
}
assert_eq!(s.execute("paint.bucket", p.clone()).unwrap()["filled"], true);
assert_eq!(px(&s, 5, 5), vec![1.0, 0.0, 0.0, 1.0], "depth {depth}, all {all:?}");
let beyond = if all == Some(true) { vec![0.0; 4] } else { vec![1.0, 0.0, 0.0, 1.0] };
assert_eq!(px(&s, 15, 5), beyond, "depth {depth}, all {all:?}: sample flag controls the boundary");
assert_eq!(s.active().unwrap().doc.layer(bottom).unwrap(), &before, "only the active layer is written");
if all == Some(true) {
assert_eq!(px(&s, 10, 5), vec![0.0; 4], "wall itself is untouched");
assert!(s.undo());
p["contiguous"] = json!(false);
s.execute("paint.bucket", p).unwrap();
assert_eq!(px(&s, 15, 5), vec![1.0, 0.0, 0.0, 1.0], "noncontiguous finds both white regions");
assert_eq!(px(&s, 10, 5), vec![0.0; 4]);
assert!(s.undo());
assert_eq!(s.execute("paint.bucket", json!({"x": -1, "y": 2, "sampleAllLayers": true})).unwrap()["filled"], false);
assert_eq!(px(&s, 5, 5), vec![0.0; 4], "an off-canvas sample does not fill the layer");
}
}
}
}
#[test]
fn bucket_sample_all_layers_ignores_hidden_layers() {
let (mut s, _) = bucket_layers(8);
s.edit("hidden wall", |doc, active| {
let l = doc.layer_mut(active.unwrap()).unwrap();
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 5, 10), &[0.0, 0.0, 0.0, 1.0]);
l.visible = false;
Ok(())
})
.unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("paint.bucket", json!({"x": 2, "y": 2, "color": "#ff0000", "antiAlias": false, "tolerance": 0, "sampleAllLayers": true})).unwrap();
assert_eq!(px(&s, 4, 5), vec![1.0, 0.0, 0.0, 1.0], "hidden wall does not bound the fill");
assert_eq!(px(&s, 8, 5), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&s, 15, 5), vec![0.0; 4], "visible wall still bounds the fill");
}
#[test]
fn bucket_sample_all_layers_keeps_selection_opacity_and_history() {
for depth in [8, 16, 32] {
let (mut s, _) = bucket_layers(depth);
s.execute("select.rect", json!({"x": 0, "y": 2, "width": 20, "height": 6})).unwrap();
s.edit("feather", |doc, _| {
doc.selection.as_mut().unwrap().fill_rect(Rect::new(0, 2, 4, 8), &[0.5]);
Ok(())
})
.unwrap();
let selection = s.active().unwrap().doc.selection.clone();
s.execute("paint.bucket", json!({"x": 2, "y": 5, "color": "#ff0000", "antiAlias": false, "tolerance": 0, "opacity": 50, "sampleAllLayers": true}))
.unwrap();
let (feathered, full) = (px(&s, 2, 5), px(&s, 6, 5));
assert!((feathered[3] - 0.25).abs() < 0.005 && (full[3] - 0.5).abs() < 0.005, "depth {depth}: selection coverage multiplies opacity");
assert_eq!(full[..3], [1.0, 0.0, 0.0]);
assert_eq!(px(&s, 6, 1), vec![0.0; 4], "outside selection");
assert_eq!(px(&s, 15, 5), vec![0.0; 4], "outside sampled region");
assert_eq!(s.active().unwrap().history.undo_label(), Some("Paint Bucket"));
assert!(s.undo());
assert_eq!(px(&s, 2, 5), vec![0.0; 4]);
assert_eq!(s.active().unwrap().doc.selection, selection);
assert!(s.redo());
assert_eq!(px(&s, 2, 5), feathered);
assert_eq!(px(&s, 6, 5), full);
assert_eq!(s.active().unwrap().doc.selection, selection);
}
}
#[test]
fn bucket_sample_all_layers_pattern_uses_the_same_region() {
let (mut s, _) = bucket_layers(8);
s.execute(
"paint.bucket",
json!({"x": 2, "y": 2, "contents": "pattern", "pattern": "Diagonal Lines", "antiAlias": false, "tolerance": 0, "sampleAllLayers": true}),
)
.unwrap();
let left: Vec<f32> = (0..10).map(|x| px(&s, x, 0)[0]).collect();
assert!(left.iter().any(|&r| r < 0.3) && left.iter().any(|&r| r > 0.9), "pattern varies inside the region: {left:?}");
assert_eq!(px(&s, 10, 5), vec![0.0; 4], "wall untouched");
assert_eq!(px(&s, 15, 5), vec![0.0; 4], "region past the wall untouched");
}
#[test]
fn bucket_sample_all_layers_mask_and_channel_targets_keep_their_own_boundaries() {
for target in [json!("mask"), json!({"channel": 0}), json!("quickMask")] {
for all in [false, true] {
let (mut s, _) = bucket_layers(8);
match crate::channel_cmds::target_of(&json!({"target": target})) {
crate::channel_cmds::Target::Mask => {
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
}
crate::channel_cmds::Target::Alpha(_) => {
s.execute("channel.new", json!({"fill": "white"})).unwrap();
}
crate::channel_cmds::Target::QuickMask => {
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
}
_ => panic!("expected a grayscale target"),
}
let p = json!({"x": 2, "y": 2, "color": "#000000", "antiAlias": false, "tolerance": 0, "target": target, "sampleAllLayers": all});
s.edit("target wall", |doc, active| {
let area = doc.bounds();
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &p).unwrap();
surf.fill_rect(area, &[1.0]);
surf.fill_rect(Rect::new(5, 0, 6, 10), &[0.0]);
Ok(())
})
.unwrap();
s.execute("paint.bucket", p.clone()).unwrap();
let d = &s.active().unwrap().doc;
let surf = match crate::channel_cmds::target_of(&p) {
crate::channel_cmds::Target::Mask => &d.layer(s.active().unwrap().active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface,
crate::channel_cmds::Target::Alpha(i) => &d.channels[i].surface,
crate::channel_cmds::Target::QuickMask => &d.quick_mask.as_ref().unwrap().surface,
_ => panic!("expected a grayscale target"),
};
assert_eq!(surf.sample_channel(2, 5, 0), 0.0, "{target}, all {all}: filled target");
assert_eq!(surf.sample_channel(7, 5, 0), 1.0, "{target}, all {all}: stopped at target wall");
assert_eq!(px(&s, 2, 5), vec![0.0; 4], "layer pixels remain untouched");
}
}
}
#[test]
fn bucket_fill_contiguous() {
let mut s = session();
+40 -8
View File
@@ -296,16 +296,17 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
fn modify(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
// The dialogs' ranges (Photoshop's limits); anything else is refused, never clamped silently.
let max = match op {
"border" => 200.0,
"feather" => 1000.0,
_ => 500.0,
let (min, max): (f32, f32) = match op {
"border" => (1.0, 200.0),
"feather" => (0.1, 1000.0),
_ => (1.0, 500.0),
};
let r = f(p, "radius", 1.0);
// A negative radius is refused too (#993): it used to be clamped to 0, which left the
// selection alone (Expand, Contract) or cleared it (Border) while reporting success.
if !r.is_finite() || !(0.0..=max).contains(&r) {
return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: format!("radius must be a number in 0..{max}") });
// Below the dialog's minimum (a negative or zero radius, #993) or above its maximum is
// refused: a clamp to 0 left the selection alone (Expand, Contract) or cleared it (Border)
// while reporting success.
if !(min..=max).contains(&r) {
return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: format!("radius must be a number in {min}..{max}") });
}
// Photoshop's "Apply effect at canvas bounds": when on, the canvas edge is a selection edge
// (Select All then Contract shrinks from the edges); when off, the selection is taken to
@@ -801,6 +802,37 @@ mod tests {
}
}
#[test]
fn modify_refuses_radii_below_the_minimum() {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
// Below each dialog's minimum (1 px, Feather 0.1 px) is refused, not clamped to 0: a
// negative Border radius used to clear the selection and report success (#993).
let below = [
("border", -5.0),
("border", 0.5),
("smooth", -5.0),
("smooth", 0.0),
("expand", -5.0),
("expand", 0.0),
("contract", -5.0),
("contract", 0.9),
("feather", -5.0),
("feather", 0.0),
("feather", 0.05),
];
let before = current_mask(&s.active().unwrap().doc).1;
for (op, r) in below {
let err = s.execute(&format!("select.modify.{op}"), json!({"radius": r})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{op} {r}: {err}");
assert!(current_mask(&s.active().unwrap().doc).1 == before, "{op} {r}: the selection changed");
}
// The minimum itself is still accepted.
s.execute("select.modify.feather", json!({"radius": 0.1})).unwrap();
s.execute("select.modify.expand", json!({"radius": 1})).unwrap();
assert_eq!(coverage(&s, 9, 15), 1.0);
}
#[test]
fn geometry_params_that_would_wrap_are_rejected() {
let mut s = session();
+1 -1
View File
@@ -2967,7 +2967,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
Tool::PaintBucket => {
let o = app.ui.tool_options.clone();
let contents = if o.bucket_fill_pattern { "pattern" } else { "foreground" };
let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)}));
let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)}));
return;
}
Tool::RedEye => {
+88 -7
View File
@@ -74,16 +74,19 @@ fn n(f: &Map<String, Value>, k: &str, d: f64) -> f64 {
fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>) -> Result<Document, String> {
let mut s = photocraft_engine::Session::new();
s.add_document(doc.clone(), None);
let scale = n(f, "scale", 100.0) / 100.0;
let mut w = (doc.size.width as f64 * scale).round().max(1.0);
let mut scale = n(f, "scale", 100.0) / 100.0;
if let Some(m) = max_side {
let long = doc.size.width.max(doc.size.height) as f64 * scale;
if long > m as f64 {
w = (w * m as f64 / long).round().max(1.0);
scale *= m as f64 / long;
}
}
if (w - doc.size.width as f64).abs() >= 1.0 {
s.execute("image.imageSize", json!({"width": w, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})).map_err(|e| e.to_string())?;
// Both dimensions are sent: a scale can leave a 1 px width unchanged while halving the
// height, and the engine would otherwise keep the aspect ratio from the width alone.
let (w, h) = scaled_size(doc.size.width as f64, doc.size.height as f64, scale);
if (w - doc.size.width as f64).abs() >= 1.0 || (h - doc.size.height as f64).abs() >= 1.0 {
s.execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }}))
.map_err(|e| e.to_string())?;
}
let fmt = s_fmt(f);
if !f.get("transparency").and_then(Value::as_bool).unwrap_or(true) || fmt == "jpg" {
@@ -92,6 +95,12 @@ fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>
s.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into())
}
/// The exported pixel size: each side scaled and rounded on its own, never below 1 px. The
/// dialog's size label and `export_document` share it so the label names the file's real size.
fn scaled_size(w: f64, h: f64, scale: f64) -> (f64, f64) {
((w * scale).round().max(1.0), (h * scale).round().max(1.0))
}
/// Export As already supports lossy WebP; reuse the Quick Export defaults when
/// choosing that format so both flows expose the same quality and lossless options.
fn set_format_defaults(f: &mut Map<String, Value>, fmt: &str, prefs: &photocraft_engine::prefs::Export) {
@@ -188,8 +197,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0);
});
f.insert("scale".into(), json!(sc.round()));
let (bw, bh) = (n(f, "__w", f64::from(doc.size.width)) as f32, n(f, "__h", f64::from(doc.size.height)) as f32);
let (w, h) = ((bw * sc / 100.0).round(), (bh * sc / 100.0).round());
let (bw, bh) = (n(f, "__w", f64::from(doc.size.width)), n(f, "__h", f64::from(doc.size.height)));
let (w, h) = scaled_size(bw, bh, f64::from(sc) / 100.0);
ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text_dim).size(11.5));
});
ui.add_space(12.0);
@@ -283,6 +292,51 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result<Value, String> {
mod tests {
use super::*;
#[test]
fn export_document_scales_height_when_rounded_width_is_unchanged() {
use photocraft_doc::{Color, ColorMode, SampleType, Size};
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for (width, height, scale, expected) in [
(1, 100, 50, (1, 50)),
(1, 200, 50, (1, 100)),
(10, 100, 99, (10, 99)),
(10, 100, 101, (10, 101)),
(400, 300, 50, (200, 150)),
(400, 300, 100, (400, 300)),
] {
let doc = Document::with_background("x", Size::new(width, height), ColorMode::Rgb, depth, Color::WHITE);
let before = doc.clone();
let mut f = Map::new();
f.insert("format".into(), json!("png"));
f.insert("scale".into(), json!(scale));
let out = export_document(&doc, &f, None).unwrap();
assert_eq!((out.size.width, out.size.height), expected, "{width} × {height} at {scale}%, {depth:?}");
assert_eq!(out.depth, depth, "export preserves the document's depth");
assert_eq!(doc, before, "export leaves the source document unchanged");
}
}
}
#[test]
fn export_document_proxy_limits_both_dimensions_before_rounding() {
use photocraft_doc::{Color, ColorMode, SampleType, Size};
for (width, height, scale, max_side, expected) in
[(1, 1000, 100, 360, (1, 360)), (1, 1000, 50, 360, (1, 360)), (1, 1000, 25, 360, (1, 250)), (13, 20, 50, 8, (5, 8)), (20, 13, 50, 8, (8, 5))]
{
let doc = Document::with_background("x", Size::new(width, height), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let before = doc.clone();
let mut f = Map::new();
f.insert("format".into(), json!("png"));
f.insert("scale".into(), json!(scale));
let out = export_document(&doc, &f, Some(max_side)).unwrap();
assert_eq!((out.size.width, out.size.height), expected, "{width} × {height} at {scale}%, proxy limit {max_side}");
assert!(out.size.width.max(out.size.height) <= max_side, "proxy respects its longest-side limit");
assert_eq!(doc, before, "preview leaves the source document unchanged");
}
}
#[test]
fn export_document_scales_and_flattens() {
let doc = Document::with_background(
@@ -303,6 +357,33 @@ mod tests {
assert_eq!(settings(&f).jpeg_quality, Some(85));
}
/// #1353: a scale that changes only the height (the width rounds back to the original)
/// used to be skipped, so the exported file kept the original size while the dialog
/// labelled it as scaled.
#[test]
fn export_document_scales_when_only_the_height_changes() {
let mut f = Map::new();
f.insert("format".into(), json!("png"));
f.insert("scale".into(), json!(50));
for (src_h, want_h) in [(100u32, 50u32), (200, 100)] {
let doc = Document::with_background(
"x",
photocraft_doc::Size::new(1, src_h),
photocraft_doc::ColorMode::Rgb,
photocraft_doc::SampleType::U8,
photocraft_doc::Color::WHITE,
);
let out = export_document(&doc, &f, None).unwrap();
assert_eq!((out.size.width, out.size.height), (1, want_h), "1 × {src_h} at 50%");
// The preview proxy takes the same path.
let proxy = export_document(&doc, &f, Some(40)).unwrap();
assert_eq!((proxy.size.width, proxy.size.height), (1, 40), "proxy of 1 × {src_h}");
}
// The label and the export agree, and neither side drops below 1 px.
assert_eq!(scaled_size(1.0, 100.0, 0.5), (1.0, 50.0));
assert_eq!(scaled_size(10.0, 300.0, 0.01), (1.0, 3.0));
}
#[test]
fn switching_export_formats_uses_independent_quality_preferences() {
let prefs = photocraft_engine::prefs::Export { jpeg_quality: 43, webp_quality: 72, webp_lossless: false, ..Default::default() };
+91 -8
View File
@@ -121,6 +121,46 @@ fn clipboard_preset(f: &Map<String, Value>) -> Option<Preset> {
Some((CLIPBOARD, dim(0)?, dim(1)?, 72.0))
}
/// Set Width or Height (`key`) from a value typed in `unit`: whole pixels for `file.new`, plus the
/// typed value so the field keeps showing it (see [`shown_size`]).
pub fn set_size(f: &mut Map<String, Value>, key: &str, v: f32, unit: &str, ppi: f32) {
f.insert(key.into(), px_value(from_unit(v, unit, ppi)));
f.insert(typed_key(key), json!({"value": v, "unit": unit}));
f.remove("__preset");
}
/// What Width or Height (`key`, `d` pixels when unset) shows in `unit`: the value the user typed
/// while it still gives the field's pixel count, else the pixels converted. Converting the rounded
/// pixels back on every keystroke turned a typed `5` mm into 14 px and the text into `4.9` (#1147).
/// A size over the 300000 px limit shows the limit, not the typed value the document won't have.
pub fn shown_size(f: &Map<String, Value>, key: &str, d: f32, unit: &str, ppi: f32) -> f32 {
let px = get_f(f, key, d);
let typed = f.get(&typed_key(key)).and_then(Value::as_object).filter(|t| unit != "px" && t.get("unit").and_then(Value::as_str) == Some(unit));
match typed.and_then(|t| t.get("value")).and_then(Value::as_f64) {
Some(v) if from_unit(v as f32, unit, ppi).max(1.0) == px => v as f32,
_ => to_unit(px, unit, ppi),
}
}
fn typed_key(key: &str) -> String {
format!("__{key}Typed")
}
/// Swap Width and Height (the Orientation buttons), with the values typed for them, so a typed
/// 841 x 1189 mm turns into 1189 x 841 mm, not 1188.9 x 841.
pub fn swap_size(f: &mut Map<String, Value>) {
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
f.insert("width".into(), px_value(h));
f.insert("height".into(), px_value(w));
let (typed_w, typed_h) = (f.remove(&typed_key("width")), f.remove(&typed_key("height")));
if let Some(t) = typed_h {
f.insert(typed_key("width"), t);
}
if let Some(t) = typed_w {
f.insert(typed_key("height"), t);
}
}
/// Apply a preset to the dialog fields.
pub fn apply_preset(f: &mut Map<String, Value>, p: &Preset) {
f.insert("width".into(), json!(p.1));
@@ -260,10 +300,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let mut unit = get_s(f, "__unit", "px");
small_label(ui, tl!("Width"));
ui.horizontal(|ui| {
let mut w = to_unit(get_f(f, "width", 1920.0), &unit, ppi);
let mut w = shown_size(f, "width", 1920.0, &unit, ppi);
if widgets::value_field(ui, &mut w, 0.01..=300_000.0, "", 110.0).changed() {
f.insert("width".into(), px_value(from_unit(w, &unit, ppi)));
f.remove("__preset");
set_size(f, "width", w, &unit, ppi);
}
let opts: Vec<(String, &str)> = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect();
if widgets::dropdown(ui, "nd-unit", &mut unit, &opts, 120.0) {
@@ -272,18 +311,16 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
});
small_label(ui, tl!("Height"));
ui.horizontal(|ui| {
let mut h = to_unit(get_f(f, "height", 1080.0), &unit, ppi);
let mut h = shown_size(f, "height", 1080.0, &unit, ppi);
if widgets::value_field(ui, &mut h, 0.01..=300_000.0, "", 110.0).changed() {
f.insert("height".into(), px_value(from_unit(h, &unit, ppi)));
f.remove("__preset");
set_size(f, "height", h, &unit, ppi);
}
ui.add_space(6.0);
small_label(ui, tl!("Orientation"));
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
for (icon, portrait) in [("rectangle-vertical", true), ("rectangle-horizontal", false)] {
if icons::button(ui, icon, 24.0, (h > w) == portrait, if portrait { "Portrait" } else { "Landscape" }).clicked() && (h > w) != portrait {
f.insert("width".into(), px_value(h));
f.insert("height".into(), px_value(w));
swap_size(f);
}
}
});
@@ -353,6 +390,38 @@ mod tests {
assert_eq!(to_unit(640.0, "px", 72.0), 640.0);
}
/// #1147: 5 mm at 72 ppi is 14 px, which is 4.94 mm. The field must keep showing the typed 5.
#[test]
fn typed_size_in_a_physical_unit_shows_as_typed() {
let mut f = crate::state::UiState::new_document_fields();
set_size(&mut f, "height", 5.0, "mm", 72.0);
assert_eq!(command_params(&f)["height"], json!(14));
assert_eq!(shown_size(&f, "height", 1080.0, "mm", 72.0), 5.0);
// Another unit, or pixels changed elsewhere (a preset, the orientation swap): converted.
assert_eq!(shown_size(&f, "height", 1080.0, "cm", 72.0), to_unit(14.0, "cm", 72.0));
f.insert("height".into(), json!(1080));
assert_eq!(shown_size(&f, "height", 1080.0, "mm", 72.0), to_unit(1080.0, "mm", 72.0));
// Pixels show whole pixels.
set_size(&mut f, "width", 512.4, "px", 72.0);
assert_eq!(shown_size(&f, "width", 1920.0, "px", 72.0), 512.0);
// Over the 300000 px limit the field shows the limit.
set_size(&mut f, "width", 300_000.0, "mm", 72.0);
assert_eq!(command_params(&f)["width"], json!(300_000));
assert_eq!(shown_size(&f, "width", 1920.0, "mm", 72.0), to_unit(300_000.0, "mm", 72.0));
}
/// #1147: the Orientation buttons swap the typed values too: 841 x 1189 mm becomes 1189 x 841.
#[test]
fn orientation_swap_keeps_the_typed_values() {
let mut f = crate::state::UiState::new_document_fields();
set_size(&mut f, "width", 841.0, "mm", 72.0);
set_size(&mut f, "height", 1189.0, "mm", 72.0);
swap_size(&mut f);
let p = command_params(&f);
assert_eq!((p["width"].clone(), p["height"].clone()), (json!(3370), json!(2384)));
assert_eq!((shown_size(&f, "width", 1920.0, "mm", 72.0), shown_size(&f, "height", 1080.0, "mm", 72.0)), (1189.0, 841.0));
}
#[test]
fn resolution_keeps_physical_size_in_physical_units_and_pixels_in_px() {
let a4 = CATEGORIES.iter().find(|c| c.0 == "Print").unwrap().1.iter().find(|p| p.0 == "A4").unwrap();
@@ -558,6 +627,20 @@ mod tests {
assert_eq!(created(&h), (600, 450, 300.0));
}
/// #1147: typed digit by digit, millimetres used to be rewritten after each keystroke
/// (`8` became `8.1`), so 841 never arrived.
#[test]
fn typed_size_in_millimetres_keeps_every_digit() {
let mut h = harness();
let mut f = fields(&h);
f.insert("__unit".into(), serde_json::json!("mm"));
set_fields(&mut h, f);
type_into(&mut h, 0, "841");
type_into(&mut h, 1, "1189");
enter(&mut h);
assert_eq!(created(&h), (2384, 3370, 72.0));
}
#[test]
fn changing_units_keeps_the_typed_pixel_size() {
let mut h = harness();
+83 -9
View File
@@ -51,12 +51,38 @@ fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool
ui.data(|d| d.get_temp::<Tool>(key)).filter(|t| slot.contains(t)).unwrap_or(slot[0])
}
/// [`TOOL_SECTIONS`] without the tools hidden in Edit › Toolbar (`hidden` holds tool names: the
/// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A
/// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is
/// dropped, and so is a section with no slots. Each slot carries its index in `TOOL_SECTIONS`,
/// so its remembered tool and flyout stay put when other slots are hidden.
fn visible_sections(hidden: &[String]) -> Vec<Vec<(usize, Vec<Tool>)>> {
let hidden: Vec<Tool> = hidden.iter().map(String::as_str).filter_map(Tool::from_name).collect();
let mut index = 0usize;
let mut sections = Vec::new();
for section in TOOL_SECTIONS {
let mut slots = Vec::new();
for slot in section.iter() {
let tools: Vec<Tool> = slot.iter().copied().filter(|tool| !hidden.contains(tool)).collect();
if !tools.is_empty() {
slots.push((index, tools));
}
index += 1;
}
if !slots.is_empty() {
sections.push(slots);
}
}
sections
}
pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) };
let sections = visible_sections(&app.session.prefs().toolbar.hidden);
// Two columns when the header chevron asks for them, or when one column doesn't fit.
let slots: usize = TOOL_SECTIONS.iter().map(|g| g.len()).sum();
let double = app.ui.panels.toolbar_double || toolbar_needs_double(slots, TOOL_SECTIONS.len(), bx, t.pro, ui.available_rect_before_wrap().height());
let slots: usize = sections.iter().map(Vec::len).sum();
let double = app.ui.panels.toolbar_double || toolbar_needs_double(slots, sections.len(), bx, t.pro, ui.available_rect_before_wrap().height());
let w = if double { w1 + bx + 2.0 } else { w1 };
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show(
ui,
@@ -94,10 +120,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if ui.input(|i| i.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
let mut slot_index = 0usize;
// Pro has no group dividers, so its two columns fill every row across groups.
let flat: Vec<&[Tool]> = TOOL_SECTIONS.iter().flat_map(|s| s.iter().copied()).collect();
let sections: Vec<&[&[Tool]]> = if t.pro { vec![&flat[..]] } else { TOOL_SECTIONS.to_vec() };
let sections: Vec<Vec<(usize, Vec<Tool>)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections };
for (si, section) in sections.iter().enumerate() {
// Photoshop 2026 draws one uninterrupted column (no group dividers).
if si > 0 && !t.pro {
@@ -106,12 +130,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator));
ui.add_space(4.0);
}
let rows: Vec<&[&[Tool]]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() };
let rows: Vec<&[(usize, Vec<Tool>)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() };
for row in rows {
ui.horizontal(|ui| {
for slot in row.iter() {
let key = egui::Id::new(("tool-slot", slot_index));
slot_index += 1;
for (slot_index, slot) in row.iter() {
let key = egui::Id::new(("tool-slot", *slot_index));
let tool = slot_tool(ui, app.ui.tool, slot, key);
let sel = slot.contains(&app.ui.tool);
let tip = if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) };
@@ -3237,6 +3260,57 @@ mod type_flyout_tests {
}
}
#[cfg(test)]
mod toolbar_hidden_tests {
use super::*;
/// Clickable tool-sized buttons the toolbar drew in its last pass.
fn tool_buttons(app: &mut PhotocraftApp) -> usize {
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
for time in [0.0, 0.1] {
let mut out = ctx.run_ui(
egui::RawInput { time: Some(time), screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() },
|ui| toolbar(app, ui),
);
out.textures_delta.clear();
}
let size = Vec2::splat(if Tokens::get(&ctx).pro { 30.0 } else { 36.0 });
ctx.viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size && w.sense.senses_click()).count())
}
fn hide(app: &mut PhotocraftApp, tools: &[Tool]) {
let names: Vec<String> = tools.iter().map(|tool| format!("{tool:?}")).collect();
app.session.edit_prefs(|p| p.toolbar.hidden = names);
}
#[test]
fn hidden_tools_leave_the_toolbar() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let all = tool_buttons(&mut app);
let slots: usize = TOOL_SECTIONS.iter().map(|section| section.len()).sum();
hide(&mut app, &Tool::ALL);
assert_eq!(tool_buttons(&mut app), all - slots, "every tool slot is gone when every tool is hidden");
hide(&mut app, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush]);
assert_eq!(tool_buttons(&mut app), all - 1, "a slot whose tools are all hidden is gone");
hide(&mut app, &[Tool::Sponge]);
assert_eq!(tool_buttons(&mut app), all, "a slot with a visible tool stays");
}
#[test]
fn a_slot_keeps_only_its_visible_tools_and_its_index() {
let sections = visible_sections(&["Sponge".to_string(), "Move".to_string()]);
assert_eq!(sections.len(), TOOL_SECTIONS.len() - 1, "the Move section has no tools left");
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Sponge)).unwrap();
let slot = sections.iter().flatten().find(|(i, _)| *i == index).map(|(_, tools)| tools.clone());
assert_eq!(slot, Some(vec![Tool::Dodge, Tool::Burn]));
assert!(sections.iter().flatten().all(|(_, tools)| !tools.contains(&Tool::Move) && !tools.contains(&Tool::Sponge)));
assert_eq!(visible_sections(&[]).iter().map(Vec::len).sum::<usize>(), TOOL_SECTIONS.iter().map(|section| section.len()).sum::<usize>());
let by_command = visible_sections(&["sponge".to_string(), "move".to_string()]);
assert_eq!(by_command, sections, "edit.toolbar names are read like `ui.set {{tool}}` names");
}
}
#[cfg(test)]
mod properties_card_tests {
use super::*;
+123
View File
@@ -0,0 +1,123 @@
//! #1353: Export As must scale the height even when the rounded width stays unchanged.
//! Drive the real Scale field and Export button, then decode the PNG the writer received.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use egui_kittest::kittest::Queryable;
use photocraft_ui_egui::{PhotocraftApp, Services, menus};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
type Writes = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
fn harness() -> Option<(Harness, Writes)> {
let built = std::panic::catch_unwind(|| {
let writes = Writes::default();
let captured = writes.clone();
let h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(
move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let services = Services {
pick_save: Some(Box::new(|suggested| Some(suggested.to_string()))),
export: Some(Box::new(|doc, path, settings| {
let opts = photocraft_io::ExportOptions {
xmp: if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None },
..Default::default()
};
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
write: Some(Box::new(move |path, bytes| {
captured.borrow_mut().push((path.to_string(), bytes.to_vec()));
Ok(())
})),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
app.session.prefs.edit(|p| p.interface.language = "en".into());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}
app
},
);
(h, writes)
});
match built {
Ok(h) => Some(h),
Err(_) => {
eprintln!("skipping: no GPU adapter");
None
}
}
}
fn evidence(h: &mut Harness, depth: u8, stage: &str) {
if let Some(dir) = std::env::var_os("PHOTOCRAFT_EXPORT_EVIDENCE_DIR") {
std::fs::create_dir_all(&dir).expect("evidence directory");
h.render().expect("render UI").save(std::path::Path::new(&dir).join(format!("{stage}-{depth}bit.png"))).expect("save UI evidence");
}
}
#[test]
fn export_as_scales_a_narrow_image_without_editing_the_source() {
let Some((mut h, writes)) = harness() else { return };
h.run_steps(4);
for depth in [8, 16, 32] {
writes.borrow_mut().clear();
{
let app = h.state_mut();
while app.session.active().is_some() {
app.run("file.close", json!({"discard": true})).expect("close");
}
app.run("file.new", json!({"width": 1, "height": 100, "depth": depth, "background": "white"})).expect("new narrow document");
app.sync_views();
}
let st = h.state().session.active().expect("document");
let source = st.doc.clone();
let history = st.history.entries();
let revision = st.revision;
let ctx = h.ctx.clone();
menus::invoke(h.state_mut(), &ctx, "file.export.exportAs", json!({})).expect("open Export As");
h.run_steps(6);
let label = h.get_by_label("Scale").rect();
let field = h
.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| {
let r = n.rect();
r.left() >= label.right() && r.left() < label.right() + 100.0 && (r.center().y - label.center().y).abs() < 3.0
})
.expect("Scale numeric field");
field.click();
h.run_steps(2);
// Clicking a numeric field selects its current value; type through egui's text input.
h.event(egui::Event::Text("50".into()));
h.run_steps(3);
// Click away to commit the field without Enter confirming the dialog.
h.get_by_label("Image Size").click();
h.run_steps(3);
assert!(h.query_by_label("1 × 50 px").is_some(), "{depth}-bit: dialog shows the requested output size");
evidence(&mut h, depth, "dialog");
h.get_by_label("Export").click();
h.run_steps(6);
evidence(&mut h, depth, "result");
let written = writes.borrow();
assert_eq!(written.len(), 1, "{depth}-bit: Export writes one file");
let (path, bytes) = &written[0];
assert!(path.ends_with(".png"), "PNG export path: {path}");
if let Some(dir) = std::env::var_os("PHOTOCRAFT_EXPORT_EVIDENCE_DIR") {
std::fs::write(std::path::Path::new(&dir).join(format!("exported-{depth}bit.png")), bytes).expect("save exported PNG evidence");
}
let png = photocraft_codecs::decode_as(photocraft_codecs::Format::Png, bytes).expect("decode the written PNG");
assert_eq!(png.dimensions(), (1, 50), "{depth}-bit: written dimensions match the dialog");
let st = h.state().session.active().expect("source remains open");
assert!(Arc::ptr_eq(&st.doc, &source), "{depth}-bit: source document is unchanged");
assert_eq!((st.doc.size.width, st.doc.size.height), (1, 100));
assert_eq!(st.history.entries(), history, "export must not add source history");
assert_eq!(st.revision, revision, "export must not change source revision");
}
}
@@ -0,0 +1,119 @@
//! #1118: Paint Bucket's All Layers checkbox must sample the visible image while painting
//! only the active layer. Drive the checkbox and canvas through real egui pointer events.
use egui::{Modifiers, PointerButton, Pos2};
use egui_kittest::kittest::Queryable;
use photocraft_doc::LayerId;
use photocraft_geom::Rect;
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::canvas::ViewXform;
use photocraft_ui_egui::control::{ControlRequest, Outcome, handle};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
fn harness() -> Option<Harness> {
let built = std::panic::catch_unwind(|| {
egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 720.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.prefs.edit(|p| p.interface.language = "en".into());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}
app
})
});
match built {
Ok(h) => Some(h),
Err(_) => {
eprintln!("skipping: no GPU adapter");
None
}
}
}
fn setup(h: &mut Harness) -> LayerId {
let background = {
let app = h.state_mut();
while app.session.active().is_some() {
app.run("file.close", json!({"discard": true})).expect("close");
}
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).expect("new");
let background = app.session.active().expect("document").active_layer.expect("background");
app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).expect("select left half");
app.run("edit.fill", json!({"contents": "black"})).expect("black left half");
app.run("select.deselect", json!({})).expect("deselect");
app.run("layer.new.layer", json!({"name": "Bucket fill"})).expect("empty active layer");
app.run("tools.setColors", json!({"foreground": [1.0, 0.0, 0.0, 1.0]})).expect("red foreground");
app.sync_views();
app.ui.extras.rulers = false;
let o = &mut app.ui.tool_options;
o.tolerance = 0.0;
o.anti_alias = false;
o.contiguous = true;
o.sample_all_layers = false;
o.bucket_fill_pattern = false;
o.fill_opacity = 100.0;
background
};
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new("ui.set", json!({"tool": "paintBucket", "zoom": 1.0, "center": [200, 150]}));
assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::Done(_)));
h.run_steps(6);
background
}
fn screen(h: &Harness, x: f32, y: f32) -> Pos2 {
let app = h.state();
let v = &app.ui.views[0];
ViewXform { rect: photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }
.to_screen(x, y)
}
fn pixels(h: &Harness, layer: LayerId) -> Vec<[f32; 4]> {
let st = h.state().session.active().expect("document");
let surface = st.doc.layer(layer).expect("layer").surface().expect("pixel layer");
let mut pixels = vec![[0.0; 4]; 400 * 300];
surface.read_rgba_into(Rect::new(0, 0, 400, 300), &mut pixels);
pixels
}
#[test]
fn all_layers_checkbox_limits_bucket_fill_to_the_visible_color() {
let Some(mut h) = harness() else { return };
h.run_steps(4);
for all_layers in [false, true] {
let background = setup(&mut h);
let before = pixels(&h, background);
if all_layers {
h.get_by_label("All Layers").click();
h.run_steps(3);
}
assert_eq!(h.state().ui.tool_options.sample_all_layers, all_layers, "checkbox changed the option");
let pos = screen(&h, 300.0, 150.0);
h.event(egui::Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(6);
// Optional real UI evidence, saved before assertions so a failing baseline has a frame.
if let Some(dir) = std::env::var_os("PHOTOCRAFT_BUCKET_EVIDENCE_DIR") {
std::fs::create_dir_all(&dir).expect("evidence directory");
let name = if all_layers { "all-layers-on.png" } else { "all-layers-off.png" };
h.render().expect("render UI").save(std::path::Path::new(&dir).join(name)).expect("save UI evidence");
}
assert_eq!(pixels(&h, background), before, "bucket must leave the sampled background unchanged");
let active = h.state().session.active().expect("document").active_layer.expect("active layer");
for (i, pixel) in pixels(&h, active).into_iter().enumerate() {
let x = i % 400;
let expected = if all_layers && x < 200 { [0.0; 4] } else { [1.0, 0.0, 0.0, 1.0] };
assert_eq!(pixel, expected, "All Layers={all_layers}, active-layer pixel ({x}, {})", i / 400);
}
}
}
+2 -1
View File
@@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 3 | 11 | 13 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 8 | 14 | 8 | |
| [Tools](#tools) | 9 | 14 | 8 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 56 of 140 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
@@ -151,6 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog |
| TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog |
| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo |
| TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error |
## Workspace and UI