Merge pull request #7432 from aiongg/integration-2

Integration batch: 11 PRs on main
This commit is contained in:
aiongg
2026-10-18 11:41:49 -04:00
committed by GitHub
22 changed files with 559 additions and 22 deletions
+63 -6
View File
@@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane {
Plane { w, h, v: out }
}
/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the
/// zero-stuffed row is all zeros).
fn up_rows(p: &Plane, h: usize, y: usize) -> [Option<usize>; 5] {
let lh = p.h.min(h.div_ceil(2));
std::array::from_fn(|k| {
let m = mirror(y as isize + k as isize - 2, h);
(m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2)
})
}
/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`.
/// It adds the same terms in the same order as `up`, so it gives the same bits.
fn up_at(p: &Plane, w: usize, rows: &[Option<usize>; 5], x: usize) -> f32 {
let lw = p.w.min(w.div_ceil(2));
let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() };
if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2))
})
.sum()
} else {
(0..5)
.filter_map(|k| {
let m = mirror(x as isize + k as isize - 2, w);
(m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2))
})
.sum()
}
}
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
@@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]);
rows(&mut lap[l], lw, |y, row| {
let taps = up_rows(coarse, lh, y);
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
*o += wgt * (fine[i] - up_at(coarse, lw, &taps, x));
}
}
});
@@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
// Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a
// fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element.
let i = ((s.len() - 1) as f32 * q).round() as usize;
*s.select_nth_unstable_by(i, f32::total_cmp).1
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
@@ -444,11 +479,33 @@ mod tests {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
let full = up(&p, uw, uh);
assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
// The filter samples `up` one pixel at a time; every sample has the same bits.
for y in 0..uh {
let taps = up_rows(&p, uh, y);
for x in 0..uw {
assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}");
}
}
}
}
}
#[test]
fn quantile_is_the_sorted_element() {
let p = noise_plane(301, 211, 9);
let mut v = p.v.clone();
// Ties and signed zeros, which `total_cmp` orders by their bits.
v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]);
let mut sorted = v.clone();
sorted.sort_by(f32::total_cmp);
for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] {
let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize];
assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}");
}
}
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
+6 -2
View File
@@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
}
let (fg, bg) = grabcut(&img, &mut trimap, 8)?;
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
let low = super::clean_mask(&low, lw, lh, 0.1, 0.0);
// Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open.
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
@@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
let mut seeds = vec![false; w * h];
seeds[sy * w + sx] = true;
let low = super::keep_seeded(&low, &seeds, w, h);
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
let low = super::clean_mask(&low, w, h, 0.1, 0.0);
// As in the rectangle mode: enclosed holes fill only when they look like the object.
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
if !low.iter().any(|v| *v) {
return None;
}
+58
View File
@@ -912,3 +912,61 @@ fn select_subject_dump_pictures() {
write("ring", &img, None);
write("ring-selected", &img, reg.as_ref());
}
/// A dark square on a light background with a square hole that shows the background colour.
fn square_with_hole(hole: usize) -> RgbImage {
let (w, h) = (400usize, 300usize);
let mut rng = Rng::new(11);
let (x0, y0) = (100usize, 50usize);
RgbImage::from_fn(w, h, |x, y| {
let n = 0.02 * rng.normal();
let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200;
let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole;
let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
})
}
#[test]
fn object_select_keeps_small_background_hole_open() {
let img = square_with_hole(8);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap();
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected");
}
#[test]
fn object_at_keeps_small_background_hole_open() {
let img = square_with_hole(16);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap();
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected");
}
#[test]
fn hole_fill_without_specks_fills_object_coloured_holes_only() {
// The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does.
let (w, h) = (40usize, 40usize);
let mut rng = Rng::new(5);
let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y);
let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y);
let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y);
let img = RgbImage::from_fn(w, h, |x, y| {
let n = 0.02 * rng.normal();
let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
});
let mask: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect();
// The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour.
let dark: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect();
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m);
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 3, gmm::DEFAULT_REG).unwrap();
let (fg, bg) = (fit(fs), fit(bs));
let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05);
assert!(out[25 * w + 25], "a speck of the object's colour fills");
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
}
+9 -2
View File
@@ -757,7 +757,12 @@ fn count_add(s: &mut Session, p: &Value) -> Result<Value> {
if !at[0].is_finite() || !at[1].is_finite() {
return Err(bad("count.add", "coordinates must be finite"));
}
let gi = p.get("group").and_then(Value::as_u64).map(|v| v as usize);
let gi = p.get("group").and_then(Value::as_u64).map(|v| usize::try_from(v).map_err(|_| bad("count.add", "no such group"))).transpose()?;
// The first marker creates group zero, but an explicit target must never be redirected.
let group_count = doc(s)?.measurement.count_groups.len().max(1);
if gi.is_some_and(|i| i >= group_count) {
return Err(bad("count.add", "no such group"));
}
let n = s.edit("Count", |d, _| {
if d.measurement.count_groups.is_empty() {
let g = new_group(d, None, None);
@@ -858,7 +863,9 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Delete Count Group", |d, _| {
d.measurement.count_groups.remove(g);
let n = d.measurement.count_groups.len();
d.measurement.active_count_group = d.measurement.active_count_group.min(n.saturating_sub(1));
// Removing a group before the active one shifts it down a slot.
let a = d.measurement.active_count_group;
d.measurement.active_count_group = (if g < a { a - 1 } else { a }).min(n.saturating_sub(1));
Ok(())
})?;
Ok(count_json(doc(s)?.as_ref()))
+65
View File
@@ -291,6 +291,27 @@ fn count_groups_markers_and_undo() {
assert!(s.execute("count.add", json!({"x": 1})).is_err());
}
#[test]
fn deleting_an_earlier_count_group_keeps_the_active_group() {
let mut s = session(100, 100, 8);
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
for _ in 0..3 {
s.execute("count.newGroup", json!({})).unwrap();
}
s.execute("count.setGroup", json!({"group": 2})).unwrap();
let r = s.execute("count.deleteGroup", json!({"group": 0})).unwrap();
assert_eq!(r["activeGroup"], 1);
assert_eq!(r["groups"][1]["name"], "Count Group 3");
// A later group leaves the active index alone.
let r = s.execute("count.deleteGroup", json!({"group": 2})).unwrap();
assert_eq!(r["activeGroup"], 1);
assert_eq!(r["groups"][1]["name"], "Count Group 3");
// Deleting the active last group selects the new last group.
let r = s.execute("count.deleteGroup", json!({})).unwrap();
assert_eq!(r["activeGroup"], 0);
assert_eq!(r["groups"][0]["name"], "Count Group 2");
}
#[test]
fn log_delete_and_csv_export() {
let mut s = session(10, 10, 8);
@@ -470,3 +491,47 @@ fn count_set_group_rejects_sizes_that_are_not_whole_numbers() {
assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing");
assert_eq!(s.active().unwrap().history.past_len(), past);
}
#[test]
fn count_add_rejects_unknown_groups_without_changing_the_document() {
for groups in [0, 1, 3] {
for group in [(groups as u64).max(1), u64::MAX] {
let mut s = session(10, 10, 8);
for _ in 0..groups {
s.execute("count.newGroup", json!({})).unwrap();
}
let before = d(&s);
let history = s.active().unwrap().history.past_len();
let journal = s.journal.len();
let error = s.execute("count.add", json!({"x": 2, "y": 3, "group": group})).unwrap_err();
assert!(error.to_string().contains("no such group"), "{error}");
assert!(Arc::ptr_eq(&before, &d(&s)), "an invalid target must not create a group or add a marker");
assert_eq!(s.active().unwrap().history.past_len(), history);
assert_eq!(s.journal.len(), journal);
}
}
}
#[test]
fn count_add_keeps_first_group_creation_and_targets_the_requested_group() {
let mut s = session(10, 10, 8);
// Group zero can be requested before the first marker creates it.
let r = s.execute("count.add", json!({"x": 2, "y": 3, "group": 0})).unwrap();
assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0]]));
s.execute("count.newGroup", json!({})).unwrap();
let before = d(&s).measurement.clone();
let history = s.active().unwrap().history.past_len();
let r = s.execute("count.add", json!({"x": 4, "y": 5, "group": 0})).unwrap();
assert_eq!(r["activeGroup"], 1);
assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0], [4.0, 5.0]]));
assert_eq!(r["groups"][1]["count"], 0);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let after = d(&s).measurement.clone();
assert!(s.undo());
assert_eq!(d(&s).measurement, before);
assert!(s.redo());
assert_eq!(d(&s).measurement, after);
// Omitting the group still uses the active group.
let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap();
assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]]));
}
+1 -1
View File
@@ -313,7 +313,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
}
/// Lock transparency: put the layer's alpha back after a filter, in the tiles it changed.
fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) {
pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) {
if old.format() != new.format() || !new.format().alpha {
return;
}
+2 -1
View File
@@ -76,7 +76,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
if deg.rem_euclid(360.0).abs() < 1e-9 {
return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)}));
}
let bg = s.tools.background;
// The Background stays opaque, whatever the background colour's alpha.
let bg = [s.tools.background[0], s.tools.background[1], s.tools.background[2], 1.0];
// Color Sampler points turn with the canvas (the same map the edit applies to the geometry).
// They are pixel indices, so map the pixel's centre, as the warp samples the pixels.
{
+9
View File
@@ -81,6 +81,15 @@ fn rotate_arbitrary_other_modes() {
}
}
#[test]
fn rotate_arbitrary_keeps_the_background_opaque_with_a_translucent_background_colour() {
let mut s = session(40, 20, 8, "rgb");
s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap();
s.execute("image.rotation.arbitrary", json!({"angle": 30})).unwrap();
let corner = doc(&s).layers[0].surface().unwrap().rgba(0, 0);
assert_eq!(corner, [0.0, 1.0, 0.0, 1.0], "the uncovered corner is opaque background colour");
}
#[test]
fn rotate_arbitrary_keeps_the_full_precision_angle() {
// Rounded to f32 this angle crosses a whole pixel: 1003 px instead of 1002, so a rotated crop
+14 -1
View File
@@ -188,7 +188,8 @@ fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect,
fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32), interp: Interp, bg: [f32; 4]) -> Result<()> {
let (w, ht) = size;
let clip = Rect::from_xywh(0, 0, w, ht);
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg);
// The Background stays opaque, whatever the background colour's alpha.
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), [bg[0], bg[1], bg[2], 1.0]);
let background_id = doc.background().map(|l| l.id);
warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?;
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
@@ -298,6 +299,18 @@ mod tests {
}
}
/// Corners past the canvas: the uncovered Background is the background colour, opaque even
/// when that colour has alpha (the Background stays opaque).
#[test]
fn uncovered_background_is_opaque() {
let q = [[-20.0, -20.0], [180.0, -20.0], [180.0, 140.0], [-20.0, 140.0]];
let mut s = skewed_ramp(SampleType::U8, &q, 100.0, 80.0);
s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap();
s.execute(CMD, json!({"corners": q, "width": 100, "height": 80})).unwrap();
let corner = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0);
assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]);
}
/// Bad quads and params are refused with an error, and the document is untouched.
#[test]
fn perspective_crop_rejects_bad_quads() {
+7
View File
@@ -77,10 +77,17 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
*surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?;
return Ok(());
}
crate::commands::check_pixels_unlocked(doc, layer)?;
let keep_alpha = doc.effective_locks(layer).transparency;
let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?;
match &mut l.content {
LayerContent::Raster(surf) => {
// Lock Transparency: the plug-in may change colour but not alpha (as built-in filters).
let before = keep_alpha.then(|| surf.clone());
*surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?;
if let Some(before) = &before {
crate::filters::keep_alpha(before, surf);
}
Ok(())
}
LayerContent::Smart(_) => {
+50
View File
@@ -155,6 +155,56 @@ fn params_are_validated_and_passed() {
Session::new().execute("plugin.remove", json!({"id": "test.cmds.grey"})).unwrap();
}
#[test]
fn locked_layers_refuse_plugin_filters() {
// Like built-in filters: locked pixels (or Lock All) refuse the run with no undo step.
let id = "test.cmds.locks";
Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, "{}", GREY))})).unwrap();
for lock_all in [false, true] {
let mut s = doc(16);
let layer = s.active().unwrap().active_layer.unwrap();
s.edit("lock", |doc, _| {
let l = doc.layer_mut(layer).unwrap();
if lock_all {
l.locks.all = true;
} else {
l.locks.pixels = true;
}
Ok(())
})
.unwrap();
let before = pixels(&s);
let r = s.execute("plugin.run", json!({"id": id}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("is locked")), "lock_all {lock_all}: {r:?}");
assert_eq!(pixels(&s), before, "lock_all {lock_all}");
s.execute("edit.undo", json!({})).unwrap();
let l = s.active().unwrap().doc.layer(layer).unwrap();
assert!(!l.locks.pixels && !l.locks.all, "the refused run left no undo step");
// Unlocked, the plug-in runs.
s.execute("plugin.run", json!({"id": id})).unwrap();
assert_ne!(pixels(&s), before);
}
// Lock Transparency: the run changes colour but keeps every pixel's alpha (GREY sets alpha too).
let mut s = doc(16);
let layer = s.active().unwrap().active_layer.unwrap();
s.edit("lock", |doc, _| {
doc.layer_mut(layer).unwrap().locks.transparency = true;
Ok(())
})
.unwrap();
let before = pixels(&s);
s.execute("plugin.run", json!({"id": id})).unwrap();
let after = pixels(&s);
assert_ne!(after, before);
for (a, b) in after.as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) {
assert_eq!(a[3], b[3], "alpha kept");
if b[3] > 0.0 {
assert!((a[0] - 0.5).abs() < 1e-3, "colour changed: {a:?}");
}
}
Session::new().execute("plugin.remove", json!({"id": id})).unwrap();
}
#[test]
fn hostile_plugins_fail_without_touching_the_document() {
for (id, body) in [
+19 -3
View File
@@ -1091,9 +1091,25 @@ fn path_rename(s: &mut Session, p: &Value) -> Result<Value> {
fn path_to_selection(s: &mut Session, p: &Value) -> Result<Value> {
let path = resolve_path(s, p.get("name").and_then(Value::as_str))?;
let feather = f64p(p, "feather").unwrap_or(0.0) as f32;
let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true);
let mode = SelectionMode::parse(p.get("mode").and_then(Value::as_str).unwrap_or("replace"));
// Validate before rasterizing or touching selection history. An unchecked feather value
// could silently become zero (negative/string), or expand into an excessive blur.
let feather = match p.get("feather") {
None => 0.0,
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && (0.0..=1000.0).contains(n))
.ok_or_else(|| bad("path.toSelection", "`feather` must be a finite number within 0..1000"))? as f32,
};
let aa = match p.get("antiAlias") {
None => true,
Some(Value::Bool(v)) => *v,
_ => return Err(bad("path.toSelection", "`antiAlias` must be a boolean")),
};
let mode = match p.get("mode") {
None => SelectionMode::Replace,
Some(Value::String(v)) if matches!(v.as_str(), "replace" | "add" | "subtract" | "intersect") => SelectionMode::parse(v),
_ => return Err(bad("path.toSelection", "`mode` must be replace, add, subtract or intersect")),
};
let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds();
let mut mask = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE).render(area);
if !aa {
+57
View File
@@ -335,6 +335,63 @@ fn path_to_selection_matches_analytic_shape() {
assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8);
}
#[test]
fn path_make_selection_rejects_invalid_options_without_changing_history() {
let mut s = session(80, 80, 8);
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60], [12, 60]]}]}})).unwrap();
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 12, "height": 12})).unwrap();
let before = doc(&s).clone();
let history = s.active().unwrap().history.past_len();
for opts in [
json!({"feather": -1}),
json!({"feather": 1001}),
json!({"feather": 1e100}),
json!({"feather": "4"}),
json!({"feather": true}),
json!({"antiAlias": "false"}),
json!({"antiAlias": 1}),
json!({"mode": "union"}),
json!({"mode": 42}),
] {
assert!(s.execute("path.toSelection", opts.clone()).is_err(), "accepted invalid Make Selection options: {opts}");
assert_eq!(*doc(&s), before, "invalid Make Selection modified the document: {opts}");
assert_eq!(s.active().unwrap().history.past_len(), history, "invalid Make Selection changed history: {opts}");
}
}
#[test]
fn open_pen_paths_make_the_same_selection_as_closed_paths() {
// The pen's unfinished work path should be implicitly closed when rasterized into a selection.
let mut s = session(80, 80, 8);
let knots = json!([[12, 12], [52, 12], [52, 52], [12, 52]]);
for (aa, feather) in [(false, 0.0), (true, 0.0), (false, 3.0), (true, 3.0)] {
let mut masks = Vec::new();
for closed in [false, true] {
s.execute("path.set", json!({"path": {"subpaths": [{"closed": closed, "knots": knots}]}})).unwrap();
s.execute("path.toSelection", json!({"antiAlias": aa, "feather": feather, "mode": "replace"})).unwrap();
masks.push(selection_mask(&s));
}
assert_eq!(masks[0], masks[1], "open and closed paths differ (antiAlias={aa}, feather={feather})");
assert!(masks[0][32 * 80 + 32] > 0.99);
assert_eq!(masks[0][4 * 80 + 4], 0.0);
if feather > 0.0 {
assert!(masks[0].iter().any(|v| *v > 0.0 && *v < 1.0), "feather did not soften the selection");
}
}
// All boolean operations should use the same selection semantics for an open Pen path.
for mode in ["replace", "add", "subtract", "intersect"] {
s.execute("select.rect", json!({"x": 30, "y": 30, "width": 34, "height": 25})).unwrap();
s.execute("path.set", json!({"path": {"subpaths": [{"closed": false, "knots": knots}]}})).unwrap();
s.execute("path.toSelection", json!({"mode": mode, "antiAlias": false})).unwrap();
let mask = selection_mask(&s);
let at = |x: usize, y: usize| mask[y * 80 + x];
assert_eq!(at(20, 20), if matches!(mode, "replace" | "add") { 1.0 } else { 0.0 }, "{mode}: path-only");
assert_eq!(at(60, 40), if matches!(mode, "add" | "subtract") { 1.0 } else { 0.0 }, "{mode}: previous-only");
assert_eq!(at(40, 40), if mode == "subtract" { 0.0 } else { 1.0 }, "{mode}: overlap");
}
}
#[test]
fn selection_to_work_path_roundtrip_iou() {
let mut s = session(240, 180, 8);
+3 -1
View File
@@ -426,7 +426,9 @@ pub fn render_video_with(
if frames > crate::timeline_cmds::MAX_DURATION {
return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION)));
}
let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0);
// Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are
// valid timelines, so only an unusable loaded rate falls back to 30.
let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0);
let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string();
let fmt = base.pixel_format();
let bounds = base.bounds();
+33
View File
@@ -151,6 +151,39 @@ fn a_fully_transparent_animated_gif_frame_encodes() {
assert!(f.indices.iter().all(|&i| Some(i) == f.transparent));
}
/// The GIF frame delays (centiseconds, from each Graphic Control Extension) of a Render Video.
fn rendered_gif_delays(s: &Session) -> Vec<u16> {
let mut gif = Vec::new();
render_video_with(
s,
&json!({"format": "gif"}),
|_, bytes| {
gif = bytes.to_vec();
Ok(())
},
|_, _| true,
)
.unwrap();
gif.windows(6).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| u16::from_le_bytes([w[4], w[5]])).collect()
}
#[test]
fn animated_gif_delay_follows_timelines_under_one_fps() {
// 0.5 fps is a valid timeline rate; its frames last 2 s, not the 1 s a 1 fps floor gave.
let mut s = session();
s.execute("timeline.create", json!({"duration": 2, "fps": 0.5})).unwrap();
assert_eq!(rendered_gif_delays(&s), [200, 200]);
// A loaded timeline with an unusable rate falls back to 30 fps (3 cs), as with no timeline.
let mut doc = (*s.active().unwrap().doc).clone();
doc.timeline = Some(Timeline::new(2, f32::NAN));
{
let st = s.active_mut().unwrap();
st.doc = std::sync::Arc::new(doc);
st.revision += 1;
}
assert_eq!(rendered_gif_delays(&s), [3, 3]);
}
#[test]
fn inspect_reports_video_layer() {
let dir = tmpdir("vinsp");
+4
View File
@@ -163,6 +163,10 @@ pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result<
let layers = take_array(&mut v, "/document/layers");
let mut m: Manifest = serde_json::from_value(v)?;
m.document.layers = layers_from_json(layers)?;
// A number past f32's range decodes as infinity, which no save could write back (#6862).
if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) {
return Err(FormatError::corrupt(format!("`{path}` is out of range")));
}
Ok(m)
}
+18
View File
@@ -94,6 +94,24 @@ fn bad_manifest_json() {
assert!(matches!(load_from_bytes(&b), Err(FormatError::Json(_))));
}
/// A number past f32's range would load as infinity, and every later save would then fail
/// (#6862): it is refused when the file is opened, naming the value.
#[test]
fn out_of_range_number_refused_on_open() {
let b = rebuild(&sample(), |n, d| {
Some(if n == "manifest.json" {
let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap();
v["document"]["layers"][0]["opacity"] = serde_json::json!(1e39);
serde_json::to_vec(&v).unwrap()
} else {
d
})
});
let Err(e) = load_from_bytes(&b) else { panic!("a manifest with opacity 1e39 opened") };
assert!(matches!(e, FormatError::Corrupt(_)), "{e}");
assert!(e.to_string().contains("document.layers[0].opacity"), "{e}");
}
/// Hostile nesting fails with an error instead of overflowing the stack; nesting as deep as the
/// deepest groups a bundle may hold (three JSON levels each) parses, and is then rejected for
/// lacking a format version.
@@ -0,0 +1,63 @@
//! Opening and re-saving a Photoshop 16/32-bit file whose merged transparency is marked only by
//! its name keeps the channel count: the "Transparency" channel is the composite's alpha, not a
//! user alpha channel that the next save writes a second time (#4654).
//!
//! Photoshop's 16-bit PSB in the report has four channels, a positive layer count in `Lr16`, no
//! `Mt16` block, and one alpha channel name, "Transparency". Re-saving it wrote five channels.
mod common;
use common::raster;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Document;
use photocraft_geom::{Rect, Size};
use photocraft_io::*;
use photocraft_psd::PsdFile;
/// The unicode alpha channel names (resource 1045), empty without it.
fn names(file: &PsdFile) -> Vec<String> {
let Some(data) = file.resources.iter().find(|r| r.id == 1045).map(|r| &r.data) else { return Vec::new() };
let mut out = Vec::new();
let mut at = 0;
while at + 4 <= data.len() {
let n = u32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as usize;
let units: Vec<u16> = data[at + 4..at + 4 + n * 2].chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
out.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string());
at += 4 + n * 2;
}
out
}
#[test]
fn resave_keeps_a_name_marked_merged_transparency() {
for depth in [SampleType::U16, SampleType::F32] {
for path in ["x.psd", "x.psb"] {
let case = format!("{depth:?} {path}");
let mut doc = Document::new("t", Size::new(8, 6), ColorMode::Rgb, depth);
let fmt = doc.pixel_format();
doc.layers.push(raster("shape", fmt, Rect::new(1, 1, 5, 4), 3, true));
let ours = PsdFile::from_bytes(&export(&doc, path, &ExportOptions::default()).expect("export").bytes).expect("parse");
assert_eq!(ours.header.channels, 4, "{case}: colour + merged transparency");
// Photoshop's form: a positive layer count, no `Mt16`/`Mt32` marker, and the merged
// transparency named in the alpha channel names.
let mut ps = ours.clone();
ps.layer_info.as_mut().expect("layers").merged_alpha = false;
ps.global_blocks.retain(|b| !matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32"));
assert!(!ps.merged_has_alpha(), "{case}: nothing marks it yet");
let name: Vec<u8> = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect();
ps.resources.retain(|r| !matches!(r.id, 1006 | 1045));
ps.resources.push(photocraft_psd::ImageResource::new(1045, name));
assert!(ps.merged_has_alpha(), "{case}: the name marks the merged transparency");
let opened = import(path, &ps.to_bytes().expect("write")).expect("import").document;
let user: Vec<_> = opened.channels.iter().map(|c| c.name.as_str()).collect();
assert!(user.is_empty(), "{case}: no user alpha channel from the transparency: {user:?}");
let resaved = PsdFile::from_bytes(&export(&opened, path, &ExportOptions::default()).expect("re-export").bytes).expect("reparse");
assert_eq!(resaved.header.channels, 4, "{case}: a save with no edits keeps the channel count");
let resaved_names = names(&resaved);
assert!(resaved_names.iter().filter(|n| *n == "Transparency").count() <= 1, "{case}: no duplicate Transparency: {resaved_names:?}");
}
}
}
+2 -2
View File
@@ -226,11 +226,11 @@ fn mt32_marks_the_merged_transparency() {
let plane = |v: [f32; 2]| v.into_iter().flat_map(f32::to_be_bytes).collect::<Vec<u8>>();
file.image_data.data = [plane([0.0, 1.0]), plane([0.0, 1.0]), plane([0.0, 1.0]), plane([1.0, 0.0])].concat();
let name: Vec<u8> = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect();
file.resources.retain(|r| r.id != 1045);
file.resources.push(photocraft_psd::ImageResource::new(1045, name));
file.resources.retain(|r| !matches!(r.id, 1006 | 1045));
assert!(!file.merged_has_alpha(), "a positive layer count alone");
file.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"Mt32", Vec::new()));
assert!(file.merged_has_alpha());
file.resources.push(photocraft_psd::ImageResource::new(1045, name));
let merged = merged_composite(&file).unwrap();
assert_eq!(merged.iter().map(|p| p[3]).collect::<Vec<_>>(), vec![1.0, 0.0]);
let opened = import("transparent.psd", &file.to_bytes().unwrap()).unwrap();
+34 -2
View File
@@ -354,7 +354,10 @@ impl PsdFile {
/// `true` if the merged image carries a transparency channel: its first extra channel, when
/// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`,
/// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive
/// layer count (psd-tools transparentbg.psd).
/// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency
/// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit
/// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that
/// name with one name per extra channel marks it too.
pub fn merged_has_alpha(&self) -> bool {
let Some(color) = self.header.color_mode.color_channels() else {
return false;
@@ -364,11 +367,40 @@ impl PsdFile {
}
let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32"));
match &self.layer_info {
Some(li) => li.merged_alpha || marked,
Some(li) => {
let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. });
li.merged_alpha || marked || (in_block && self.names_merged_transparency(self.header.channels - color))
}
None => true,
}
}
/// `true` when the alpha channel names (resource 1045, else 1006) hold exactly `extra` names
/// and the first is "Transparency", Photoshop's name for the merged transparency.
fn names_merged_transparency(&self, extra: u16) -> bool {
const NAME: &str = "Transparency";
let mut names = Vec::new();
if let Some(r) = self.resource(1045) {
let mut at = 0usize;
while let Some(len) = r.data.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes) {
at = at.saturating_add(4);
let end = (len as usize).checked_mul(2).and_then(|n| at.checked_add(n));
let Some(units) = end.and_then(|end| r.data.get(at..end)) else { break };
let units: Vec<u16> = units.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
names.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string());
at = at.saturating_add(units.len() * 2);
}
} else if let Some(r) = self.resource(1006) {
let mut at = 0usize;
while let Some(&len) = r.data.get(at) {
let Some(name) = r.data.get(at + 1..at + 1 + usize::from(len)) else { break };
names.push(String::from_utf8_lossy(name).into_owned());
at += 1 + usize::from(len);
}
}
names.len() == usize::from(extra) && names.first().is_some_and(|n| n == NAME)
}
/// Checks structural invariants that the writer relies on.
pub fn validate(&self) -> Result<()> {
self.header.validate()?;
+4 -1
View File
@@ -336,7 +336,10 @@ impl Nav {
let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return };
let level = self.level.min(self.rows.len().saturating_sub(1));
self.level = level;
let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
// A text field in the menu (Help › Search) keeps the keys that edit it: ← / → move its
// cursor and Space types a space.
let typing = ctx.text_edit_focused();
let press = |k: Key| !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned();
let switch = |by: usize| {
let n = tops.len();
+38
View File
@@ -265,6 +265,44 @@ fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() {
assert!(!is_open(&h.ctx), "shortcuts work again");
}
/// In Help › Search, ← / → move the field's cursor instead of switching to Window / File, and
/// Space types a space instead of running the lit row.
#[test]
fn help_search_keeps_the_arrows_and_space_that_edit_it() {
let mut h = harness((1280.0, 720.0, 1.0));
open(&mut h, "Help");
let field = egui::Id::new("help-menu-search").with("field");
assert_eq!(h.ctx.memory(|m| m.focused()), Some(field), "the search field has the keyboard");
let text = |h: &Harness<'static, PhotocraftApp>| h.ctx.data(|d| d.get_temp::<String>(egui::Id::new("help-menu-search"))).unwrap_or_default();
let typing = |h: &mut Harness<'static, PhotocraftApp>, s: &str| {
h.event(egui::Event::Text(s.to_string()));
h.run_steps(3);
};
typing(&mut h, "levls");
key(&mut h, egui::Key::ArrowLeft);
assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "← keeps Help open");
key(&mut h, egui::Key::ArrowLeft);
typing(&mut h, "e");
assert_eq!(text(&h), "levels", "← moved the cursor");
// The cursor sits at `leve|ls`: two → take it to the end.
key(&mut h, egui::Key::ArrowRight);
assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "→ keeps Help open");
key(&mut h, egui::Key::ArrowRight);
// The pointer resting on a result lights it; a space typed then is text, not a click.
// "levels" finds both Image › Adjustments › Levels… and the Levels adjustment layer.
let levels = h.get_all_by_label_contains("› Levels").next().expect("a Levels result").rect().center();
h.hover_at(levels);
h.run_steps(3);
assert!(Nav::current(&h.ctx).highlighted(0).is_some(), "a result is lit");
h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
h.event(egui::Event::Text(" ".into()));
h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
assert!(is_open(&h.ctx) && h.query_by_label_contains("About PhotoCraft").is_some(), "Space keeps Help open");
assert!(h.state().ui.dialogs.is_empty(), "Space ran no command: {:?}", h.state().ui.dialogs);
assert_eq!(text(&h), "levels ");
}
/// A level's visible height (its view) and the full height of its rows: equal means no scrolling.
fn fits(h: &Harness<'static, PhotocraftApp>, level: usize) -> (f32, f32) {
let nav = Nav::current(&h.ctx);