Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
David Mann
2026-11-06 14:03:38 +01:00
192 changed files with 10476 additions and 520 deletions
+17 -1
View File
@@ -288,6 +288,21 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect {
Rect::new((r.x0 as f64 * sx).floor() as i32, (r.y0 as f64 * sy).floor() as i32, (r.x1 as f64 * sx).ceil() as i32, (r.y1 as f64 * sy).ceil() as i32)
}
/// The largest rectangle a resize of content at `r` by `(sx, sy)` with `filter` stores: the
/// result, or the image between the passes of a two-pass bicubic. That one keeps an axis at its
/// old size, so when both axes shrink it is larger than the result (#9212).
pub fn largest_resize_rect(r: Rect, sx: f64, sy: f64, filter: Resample) -> Rect {
let area = |r: Rect| u64::from(r.width()) * u64::from(r.height());
let (mut cur, mut largest) = (r, Rect::EMPTY);
for p in plan(filter, sx, sy) {
cur = scaled_rect(cur, p.sx, p.sy);
if area(cur) > area(largest) {
largest = cur;
}
}
largest
}
/// Resizes a surface by `(sx, sy)` about the document origin. The surface's
/// default pixel is kept (masks keep their "reveal all" background).
pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface {
@@ -346,7 +361,8 @@ fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>)
/// Runs `filter`'s passes. Between two passes the image is stored in its own format, which
/// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so
/// the intermediate image is never larger than the result.
/// the intermediate image is never larger than the result when an axis grows, but is when both
/// shrink ([`largest_resize_rect`]).
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>, budget: usize) -> Surface {
resize_controlled(s, sx, sy, filter, canvas, budget, &photocraft_raster::Interrupt::NONE)
}
+19
View File
@@ -401,6 +401,25 @@ fn nearest_neighbor_reduction_does_not_average() {
}
}
/// #9212: a bicubic shrink of both axes stores the image between its passes with one axis at the
/// old size, larger than the result; single-pass filters and growing axes store nothing larger.
#[test]
fn largest_resize_rect_counts_the_image_between_the_passes() {
let r = Rect::new(0, 0, 100, 80);
for f in [Resample::Bicubic, Resample::BicubicSharper, Resample::BicubicAutomatic, Resample::PreserveDetails] {
assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 80), "{f:?}");
}
// The axis with the smaller scale goes first.
assert_eq!(largest_resize_rect(r, 0.5, 0.25, Resample::Bicubic), Rect::new(0, 0, 100, 20));
for f in [Resample::Bilinear, Resample::Nearest, Resample::Lanczos] {
assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 40), "{f:?}");
}
for f in [Resample::Bicubic, Resample::BicubicAutomatic] {
assert_eq!(largest_resize_rect(r, 2.0, 3.0, f), Rect::new(0, 0, 200, 240), "{f:?} grows");
assert_eq!(largest_resize_rect(r, 0.5, 2.0, f), Rect::new(0, 0, 50, 160), "{f:?} shrinks x, grows y");
}
}
#[test]
fn controlled_resize_matches_pixels_and_discards_cancelled_work() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
+2
View File
@@ -778,6 +778,8 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
#[cfg(test)]
mod ellipse_aa_edge_tests;
mod inverse;
mod lasso;
mod marquee;
@@ -0,0 +1,100 @@
//! The Elliptical Marquee's anti-aliased edge against the reference measurement (#11391):
//! document 400 × 350, ellipse 100,100–300,250, the patch x 127–132, y 119–124 on the 45° part
//! of its top-left edge. Grids hold what a black fill of the selection reads there (255 outside,
//! 0 fully selected), as the issue's tables do.
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use super::ellipse_surface;
const AREA: Rect = Rect { x0: 0, y0: 0, x1: 400, y1: 350 };
const ELLIPSE: Rect = Rect { x0: 100, y0: 100, x1: 300, y1: 250 };
const PATCH: (i32, i32) = (127, 119);
/// The reference with Anti-alias on.
const SOFT: [[u8; 6]; 6] = [
[255, 255, 255, 255, 255, 152],
[255, 255, 255, 212, 106, 0],
[255, 255, 172, 64, 0, 0],
[247, 137, 26, 0, 0, 0],
[105, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
];
/// Anti-alias off: the reference and PhotoCraft before #11391 agree.
const HARD: [[u8; 6]; 6] =
[[255, 255, 255, 255, 255, 255], [255, 255, 255, 255, 0, 0], [255, 255, 255, 0, 0, 0], [255, 255, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]];
/// The grey a black fill of selection `s` gives at `(x, y)` on white.
fn grey(s: &Surface, x: i32, y: i32) -> u8 {
let v = s.pixel(x, y).first().copied().unwrap_or(0.0);
(255.0 - v * 255.0).round() as u8
}
fn patch(s: &Surface) -> [[u8; 6]; 6] {
std::array::from_fn(|j| std::array::from_fn(|i| grey(s, PATCH.0 + i as i32, PATCH.1 + j as i32)))
}
#[test]
fn the_anti_aliased_patch_matches_the_reference_within_three_levels() {
let got = patch(&ellipse_surface(ELLIPSE, AREA, true));
for (j, (row, want)) in got.iter().zip(&SOFT).enumerate() {
for (i, (g, w)) in row.iter().zip(want).enumerate() {
assert!(g.abs_diff(*w) <= 3, "{},{}: {g} vs reference {w}\n{got:?}", PATCH.0 + i as i32, PATCH.1 + j as i32);
}
}
}
#[test]
fn the_other_three_quadrants_mirror_the_measured_one() {
let s = ellipse_surface(ELLIPSE, AREA, true);
// Symmetric about x = 200 and y = 175: pixel x mirrors to 399 - x, y to 349 - y.
for y in PATCH.1..PATCH.1 + 6 {
for x in PATCH.0..PATCH.0 + 6 {
let here = s.pixel(x, y);
assert_eq!(s.pixel(399 - x, y), here, "{x},{y} mirrored across x");
assert_eq!(s.pixel(x, 349 - y), here, "{x},{y} mirrored across y");
assert_eq!(s.pixel(399 - x, 349 - y), here, "{x},{y} mirrored through the centre");
}
}
// A few partial pixels, so the mirror is not of 0s and 1s alone.
assert_eq!(grey(&s, 399 - 129, 122), grey(&s, 129, 122));
assert!((1..255).contains(&grey(&s, 399 - 129, 349 - 121)));
}
#[test]
fn the_hard_edge_is_unchanged() {
assert_eq!(patch(&ellipse_surface(ELLIPSE, AREA, false)), HARD);
}
/// Fully inside reads exactly 1 and outside exactly 0, so Add / Subtract / Intersect combine as
/// before; the edge is soft only in a band at most √2 px wide.
#[test]
fn inside_is_exactly_selected_and_outside_exactly_empty() {
let s = ellipse_surface(ELLIPSE, AREA, true);
// Every pixel whose square lies inside, here all of the axes but the end pixels (whose
// corners poke out by a sliver).
for x in 101..299 {
assert_eq!(s.pixel(x, 175), vec![1.0], "{x},175 on the horizontal axis");
}
for y in 101..249 {
assert_eq!(s.pixel(200, y), vec![1.0], "200,{y} on the vertical axis");
}
for (x, y) in [(100, 175), (299, 175), (200, 100), (200, 249)] {
assert!(s.pixel(x, y)[0] > 0.99, "{x},{y}: the axis ends are all but full");
}
for (x, y) in [(99, 175), (300, 175), (200, 99), (200, 250), (100, 100), (299, 249), (0, 0)] {
assert_eq!(s.pixel(x, y), vec![0.0], "{x},{y} outside");
}
let partial = (0..350).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| (1..255).contains(&grey(&s, x, y))).count();
let (hard_in, soft_in) = {
let h = ellipse_surface(ELLIPSE, AREA, false);
let sum = |s: &Surface| s.read_region(AREA).iter().map(|v| f64::from(*v)).sum::<f64>();
(sum(&h), sum(&s))
};
// The perimeter (≈ 556 px) times a band 1 to √2 px across, and the hard ellipse's area.
assert!((556..787).contains(&partial), "{partial} partial pixels");
let exact = std::f64::consts::PI * 100.0 * 75.0;
assert!((soft_in - exact).abs() < 0.002 * exact && (hard_in - exact).abs() < 0.002 * exact, "{soft_in} {hard_in} vs {exact}");
}
+71 -19
View File
@@ -30,14 +30,22 @@ fn decode8(b: u8) -> f32 {
}
/// An ellipse inscribed in a rectangle, tested in `f32` exactly as the Elliptical Marquee
/// always has (so its anti-aliased edge keeps the same bytes).
/// always has (so its hard edge keeps the same bytes).
struct Ellipse {
cx: f32,
cy: f32,
rx: f32,
ry: f32,
/// At most 3 px across: the anti-aliased edge keeps its 4×4 sub-samples (#11391 measured
/// only larger ellipses).
small: bool,
/// `1 / rx`, `1 / ry`, `1 / rx²` and `1 / ry²` for [`Ellipse::ramp`].
inv: [f64; 4],
}
/// The widest an ellipse is across and still [`Ellipse::small`].
const SMALL: u32 = 3;
impl Ellipse {
fn new(r: Rect) -> Self {
Self {
@@ -45,42 +53,82 @@ impl Ellipse {
cy: (i64::from(r.y0) + i64::from(r.y1)) as f32 / 2.0,
rx: r.width() as f32 / 2.0,
ry: r.height() as f32 / 2.0,
small: r.width().min(r.height()) <= SMALL,
inv: {
let (rx, ry) = (f64::from(r.width()) / 2.0, f64::from(r.height()) / 2.0);
[1.0 / rx, 1.0 / ry, 1.0 / (rx * rx), 1.0 / (ry * ry)]
},
}
}
/// The anti-aliased edge's coverage of pixel `(x, y)`, as Photoshop draws it (#11391): with
/// `F` the normalised radius at the pixel's centre, `(F - 1) / (|∂F/∂x| + |∂F/∂y|)` is the
/// centre's distance to the edge measured in the pixel's own width across the edge, and the
/// coverage ramps linearly over that width. An axis-aligned edge gets the pixel's exact
/// area; a 45° edge ramps over √2 px. 1 exactly at and around the centre.
#[inline]
fn ramp(&self, x: i32, y: i32) -> f64 {
let [irx, iry, irx2, iry2] = self.inv;
let (dx, dy) = (f64::from(x) + 0.5 - f64::from(self.cx), f64::from(y) + 0.5 - f64::from(self.cy));
let (u, v) = (dx * irx, dy * iry);
let f = (u * u + v * v).sqrt();
// `f · (|∂F/∂x| + |∂F/∂y|)`; 0 only at the centre itself.
let across = dx.abs() * irx2 + dy.abs() * iry2;
if across <= 0.0 {
return 1.0;
}
(0.5 - (f - 1.0) * f / across).clamp(0.0, 1.0)
}
#[inline]
fn inside(&self, x: f32, y: f32) -> bool {
let (dx, dy) = ((x - self.cx) / self.rx, (y - self.cy) / self.ry);
dx * dx + dy * dy <= 1.0
}
/// Whether every point of pixel `(x, y)` that the coverage samples is inside: its four
/// corners (anti-aliased) or its centre. Along a row the pixels where this holds form one
/// run: each test is monotonic in the distance from the centre (IEEE rounding is monotonic).
/// Whether pixel `(x, y)` is fully selected: its centre is inside (hard edge), its four
/// corners are (a small anti-aliased ellipse), or its [`Ellipse::ramp`] is 1. Along a row
/// the pixels where this holds form one run: each test grows with the distance from the
/// centre; [`row_span`] checks both ends of the run.
#[inline]
fn full(&self, x: i32, y: i32, aa: bool) -> bool {
let (fx, fy) = (x as f32, y as f32);
if !aa {
return self.inside(fx + 0.5, fy + 0.5);
}
self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0)
if self.small {
return self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0);
}
self.ramp(x, y) >= 1.0
}
/// Coverage of pixel `(x, y)` in sixteenths: 16 when [`Ellipse::full`], else (anti-aliased)
/// the number of its 4×4 sub-samples inside.
/// The byte pixel `(x, y)` stores: `one` when [`Ellipse::full`], 0 outside a hard edge, and
/// otherwise the fraction of its 4×4 sub-samples inside (small) or its [`Ellipse::ramp`].
#[inline]
fn coverage(&self, x: i32, y: i32, aa: bool) -> u8 {
fn coverage(&self, x: i32, y: i32, aa: bool, enc: &[u8; 17]) -> u8 {
let one = enc[16];
if aa && !self.small {
// One ramp: a full pixel's is 1, which encodes as `one`.
// `encode8`'s rounding, inline: this runs for every edge pixel.
return (self.ramp(x, y) as f32 * 255.0 + 0.5) as u8;
}
if self.full(x, y, aa) {
return 16;
return one;
}
if !aa {
return 0;
}
let (fx, fy) = (x as f32, y as f32);
(0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count() as u8
let n = (0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count();
enc.get(n).copied().unwrap_or(one)
}
}
/// The bytes for 0/16 … 16/16 coverage.
fn sixteenths() -> [u8; 17] {
std::array::from_fn(|n| encode8(n as f32 / 16.0))
}
/// One row of the ellipse: nothing outside `lo..hi`, fully selected in `full_lo..full_hi`, and
/// the edge pixels in between (`lo..full_lo`, `full_hi..hi`) need the exact coverage test.
#[derive(Clone, Copy)]
@@ -94,7 +142,7 @@ struct RowSpan {
/// Bounds row `y` of `e` within `cut.x0..cut.x1` from the exact (f64) ellipse, widened by a
/// margin that covers the f32 tests' rounding. The bounds are then checked with the exact tests
/// (the fully selected run at both ends, nothing just outside); any doubt evaluates the whole row.
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool, enc: &[u8; 17]) -> RowSpan {
let exact = RowSpan { lo: cut.x0, full_lo: cut.x1, full_hi: cut.x1, hi: cut.x1 };
let (cx, cy, rx, ry) = (f64::from(e.cx), f64::from(e.cy), f64::from(e.rx), f64::from(e.ry));
// Every point the tests sample in this row lies in `y..y + 1`.
@@ -118,14 +166,15 @@ fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
if inner < 0.0 || full_lo >= full_hi {
(full_lo, full_hi) = (hi, hi);
}
let empty = |x: i32| e.coverage(x, y, aa) == 0;
let empty = |x: i32| e.coverage(x, y, aa, enc) == 0;
let ok = (lo <= cut.x0 || empty(lo - 1)) && (hi >= cut.x1 || empty(hi)) && (full_lo >= full_hi || (e.full(full_lo, y, aa) && e.full(full_hi - 1, y, aa)));
if ok { RowSpan { lo, full_lo, full_hi, hi } } else { exact }
}
/// The Elliptical Marquee's shape: the ellipse inscribed in `r`, cut to `clip`, as a GRAY8
/// selection. Anti-aliased edge pixels hold the fraction of their 4×4 sub-samples inside;
/// without anti-aliasing a pixel is selected when its centre is inside.
/// selection. Anti-aliased edge pixels ramp across the edge as in Photoshop ([`Ellipse::ramp`];
/// an ellipse at most 3 px across keeps the fraction of 4×4 sub-samples inside); without
/// anti-aliasing a pixel is selected when its centre is inside.
pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
let mut s = Surface::new(PixelFormat::GRAY8);
let cut = r.intersect(&clip);
@@ -133,9 +182,9 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
return s;
}
let e = Ellipse::new(r);
let enc: [u8; 17] = std::array::from_fn(|n| encode8(n as f32 / 16.0));
let enc = sixteenths();
let one = encode8(1.0);
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias));
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias, &enc));
let span = |y: i32| rows.get(usize::try_from(y - cut.y0).ok()?);
let full = s.solid_tile(&[1.0]);
let blank = s.solid_tile(&[0.0]);
@@ -159,11 +208,11 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
d.fill(one);
}
for x in (sp.lo.max(tr.x0)..sp.full_lo.min(tr.x1)).chain(sp.full_hi.max(tr.x0)..sp.hi.min(tr.x1)) {
let n = e.coverage(x, y, anti_alias);
let n = e.coverage(x, y, anti_alias, &enc);
if n > 0
&& let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(x))
{
*d = enc.get(usize::from(n)).copied().unwrap_or(one);
*d = n;
}
}
}
@@ -302,7 +351,8 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32)
mod tests {
use super::*;
/// The Elliptical Marquee as it was: every pixel tested and written one at a time.
/// The Elliptical Marquee one pixel at a time: every pixel tested and written on its own (the
/// anti-aliased ramp through [`Ellipse::ramp`]).
fn old_ellipse(r: Rect, area: Rect, aa: bool) -> Surface {
let cut = r.intersect(&area);
let mut shape = Surface::new(PixelFormat::GRAY8);
@@ -318,6 +368,8 @@ mod tests {
let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count();
let cov = if !aa {
if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 }
} else if r.width().min(r.height()) > SMALL {
Ellipse::new(r).ramp(x, y) as f32
} else if corners == 4 {
1.0
} else {
+2
View File
@@ -424,6 +424,8 @@ impl Headless {
if let Some(st) = this.session.active_mut() {
st.saved_to(target.to_string_lossy().into_owned());
}
// History Options › Automatically Create New Snapshot When Saving (#2966).
photocraft_engine::history_options_cmds::snapshot_on_save(&mut this.session);
Ok(())
})?;
}
+2
View File
@@ -762,6 +762,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "layer.combineShapes.excludeOverlappingShapes"
| "layer.combineShapes.mergeShapeComponents"
| "layer.new.frame"
// `"stepName":"Transform Path"` names the History step, not a host file.
| "edit.transform"
) || id.starts_with("layer.newFillLayer.")
|| id.starts_with("layer.newAdjustmentLayer.")
{
+47
View File
@@ -0,0 +1,47 @@
//! History Options › Automatically Create New Snapshot When Saving (#2966) through the headless
//! server: a save of the document to its own layered file adds a snapshot when the option is on;
//! a flat export is a copy and adds none, and with the option off no save does.
use photocraft_automation::Headless;
use serde_json::json;
fn tmp() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"pc-save-snapshot-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn snapshots(h: &Headless) -> usize {
h.session.active().unwrap().snapshots.len()
}
#[test]
fn a_document_save_adds_a_snapshot_only_with_the_option() {
let dir = tmp();
let psd = dir.join("a.psd");
let png = dir.join("a.png");
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
assert_eq!(snapshots(&h), 1, "the first snapshot");
// Off (the default): saving adds nothing.
h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap();
assert_eq!(snapshots(&h), 1);
h.handle("engine.execute", json!({"command": "history.options", "params": {"automaticallyCreateNewSnapshotWhenSaving": true}})).unwrap();
h.handle("engine.execute", json!({"command": "image.adjustments.invert", "params": {}})).unwrap();
h.handle("doc.save", json!({})).unwrap();
assert_eq!(snapshots(&h), 2, "the save added one");
let st = h.session.active().unwrap();
assert!(std::sync::Arc::ptr_eq(&st.snapshots[1].doc, &st.doc), "of the document as saved");
assert!(!st.is_dirty(), "and the document stays saved");
// A flat export is a copy, not a save of the document.
h.handle("doc.save", json!({"path": png.to_str().unwrap()})).unwrap();
assert_eq!(snapshots(&h), 2);
std::fs::remove_dir_all(dir).unwrap();
}
+3
View File
@@ -46,6 +46,9 @@ semver = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "3"
# `clock`: the local time that names History Options' snapshot on save (#2966); already in the
# tree through rmcp. The web build has no local clock here and names it "Snapshot N".
chrono = { workspace = true, features = ["clock"] }
# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a
# file is that file (std's `MetadataExt::file_index` is unstable).
+3
View File
@@ -188,6 +188,8 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
// Color Sampler points are view state a step may move (crop, rotation): roll them back too, so
// a failed second step can't leave them pinned to pixels the restored document no longer has.
let samplers = st.color_samplers.clone();
// So is the painting symmetry, which a rotation moves with the pixels.
let symmetry = st.symmetry.clone();
let r = f(s);
if r.is_err() {
s.journal.truncate(journal);
@@ -212,6 +214,7 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
st.channel_view = channel_view;
st.coalesce = coalesce;
st.color_samplers = samplers;
st.symmetry = symmetry;
st.revision += 1;
// A failed compound does not make an otherwise untouched document dirty.
if was_clean {
+26 -1
View File
@@ -289,10 +289,19 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
};
let background = background_param(p, cmd)?.unwrap_or_default();
let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string);
let take_background = match p.get("takeBackground") {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "`takeBackground` must be true or false"))?,
};
let id = s.edit("New Artboard", |doc, active| {
let mut g = Layer::group(name.unwrap_or_else(|| next_artboard_name(doc)), vec![]);
set_artboard(&mut g, Artboard { rect, background, preset });
// Only the first board takes the Background in (decided before the board exists).
let take = take_background && doc.artboards().is_empty() && doc.background().is_some();
let id = doc.insert_above(None, g);
if take {
move_background_into(doc, id);
}
fit_canvas(doc);
*active = Some(id);
Ok(id)
@@ -301,6 +310,19 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
/// Turns the Background into a normal layer ("Layer 0") and makes it the child of board `board`,
/// as the reference does when the first artboard is drawn. Does nothing without a Background.
fn move_background_into(doc: &mut Document, board: LayerId) {
let Some(bg) = doc.background().map(|l| l.id) else { return };
let Some(mut layer) = doc.remove(bg) else { return };
crate::extra_cmds::unlock_background(&mut layer);
match doc.layer_mut(board).and_then(Layer::children_mut) {
Some(children) => children.push(layer),
// Unreachable for a group; put the layer back rather than lose it.
None => doc.layers.insert(0, layer),
}
}
/// Convert a plain group at its content bounds; recorded guide IDs are ignored session metadata.
fn artboard_from_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "layer.new.artboardFromGroup";
@@ -705,7 +727,7 @@ pub fn specs() -> Vec<CommandSpec> {
"layer.new.artboard",
"Artboard…",
&["Layer", "New"],
r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom)} → {layer, rect}"##,
r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom),"takeBackground":bool=false (the first board in the document also takes in the Background as a normal layer "Layer 0"; the Artboard tool's drag sets it)} → {layer, rect}"##,
has_doc,
new_artboard
),
@@ -768,6 +790,9 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
#[cfg(test)]
#[path = "artboard_cmds/first_board_background_tests.rs"]
mod first_board_background_tests;
#[cfg(test)]
#[path = "artboard_cmds/tests.rs"]
mod tests;
@@ -0,0 +1,127 @@
//! The first drawn artboard takes the Background in as "Layer 0" (#11387): `takeBackground` on
//! `layer.new.artboard`, at 8, 16 and 32 bit, with the cases that keep today's result.
use super::*;
use photocraft_geom::Rect;
/// 800 x 600 with the white Background and a pixel layer "Red" at 100,100 to 300,250.
fn session(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 800, "height": 600, "depth": depth, "background": "white"})).unwrap();
s.edit("Red", |doc, active| {
let fmt = doc.pixel_format();
let mut l = Layer::raster("Red", fmt);
l.surface_mut().unwrap().fill_rect(Rect { x0: 100, y0: 100, x1: 300, y1: 250 }, &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
let id = doc.insert_above(None, l);
*active = Some(id);
Ok(id)
})
.unwrap();
s
}
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
fn doc(s: &Session) -> &Document {
&s.active().unwrap().doc
}
fn names(layers: &[Layer]) -> Vec<&str> {
layers.iter().map(|l| l.name.as_str()).collect()
}
fn steps(s: &Session) -> Vec<String> {
s.active().unwrap().history.entries()
}
fn draw(s: &mut Session, take: serde_json::Value) -> Result<Value> {
s.execute("layer.new.artboard", json!({"rect": [400, 300, 200, 200], "takeBackground": take}))
}
#[test]
fn the_first_board_takes_the_background_in_as_layer_0() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let before = steps(&s).len();
let r = draw(&mut s, json!(true)).unwrap();
let board = LayerId(r["layer"].as_u64().unwrap());
let d = doc(&s);
assert_eq!(names(&d.layers), ["Red", "Artboard 1"], "{depth} bit");
assert!(d.background().is_none(), "{depth} bit");
let b = d.layer(board).unwrap();
assert_eq!(r["rect"], json!([400, 300, 200, 200]));
let kids = b.children().unwrap();
assert_eq!(names(kids), ["Layer 0"], "{depth} bit");
let l0 = kids.first().unwrap();
assert!(!l0.locks.transparency && !l0.locks.position, "{depth} bit");
// The Background's own pixels travel with it: white at both samples, and Red still red.
assert_eq!(l0.surface().unwrap().rgba(150, 150), WHITE, "{depth} bit");
assert_eq!(l0.surface().unwrap().rgba(700, 500), WHITE, "{depth} bit");
let red = d.layers.first().unwrap();
assert_eq!(red.surface().unwrap().rgba(150, 150), [1.0, 0.0, 0.0, 1.0], "{depth} bit");
assert_eq!((d.size.width, d.size.height), (800, 600), "{depth} bit");
assert_eq!(s.active().unwrap().active_layer, Some(board));
let after = steps(&s);
assert_eq!(after.len(), before + 1, "one history step");
assert_eq!(after.last().map(String::as_str), Some("New Artboard"));
// One undo brings the Background back at the bottom with its name and locks.
s.undo();
let d = doc(&s);
assert_eq!(names(&d.layers), ["Background", "Red"], "{depth} bit");
let bg = d.background().unwrap();
assert!(bg.locks.transparency && bg.locks.position, "{depth} bit");
assert_eq!(bg.surface().unwrap().rgba(150, 150), WHITE, "{depth} bit");
assert_eq!(bg.surface().unwrap().rgba(700, 500), WHITE, "{depth} bit");
assert!(d.artboards().is_empty());
assert_eq!((d.size.width, d.size.height), (800, 600));
}
}
#[test]
fn without_the_param_or_with_false_the_background_stays_outside() {
for p in [json!({"rect": [400, 300, 200, 200]}), json!({"rect": [400, 300, 200, 200], "takeBackground": false})] {
let mut s = session(8);
s.execute("layer.new.artboard", p).unwrap();
assert_eq!(names(&doc(&s).layers), ["Background", "Red", "Artboard 1"]);
assert!(doc(&s).background().is_some());
}
}
#[test]
fn with_a_board_already_present_the_result_is_as_today() {
let mut s = session(8);
s.execute("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap();
draw(&mut s, json!(true)).unwrap();
let d = doc(&s);
assert_eq!(names(&d.layers), ["Background", "Red", "Artboard 1", "Artboard 2"]);
assert!(d.background().is_some());
assert!(d.artboards().iter().all(|b| b.1 != "Layer 0"));
}
#[test]
fn without_a_background_the_result_is_as_today() {
let mut s = session(8);
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
assert!(doc(&s).background().is_none());
draw(&mut s, json!(true)).unwrap();
assert_eq!(names(&doc(&s).layers), ["Layer 0", "Red", "Artboard 1"]);
assert!(doc(&s).layers.last().unwrap().children().unwrap().is_empty());
}
#[test]
fn a_non_boolean_take_background_is_an_error_and_changes_nothing() {
for bad in [json!("true"), json!(1), json!([true])] {
let mut s = session(8);
let (n, count) = (steps(&s).len(), doc(&s).layers.len());
assert!(draw(&mut s, bad.clone()).is_err(), "{bad}");
assert_eq!(steps(&s).len(), n, "{bad}");
assert_eq!(doc(&s).layers.len(), count, "{bad}");
assert!(doc(&s).background().is_some(), "{bad}");
}
}
#[test]
fn the_param_is_listed_in_the_command_params_doc() {
let spec = specs().into_iter().find(|c| c.id == "layer.new.artboard").unwrap();
assert!(spec.params.contains("takeBackground"));
}
+3
View File
@@ -48,13 +48,16 @@ mod steps_layer;
mod steps_layer_style;
mod steps_lookup;
mod steps_make;
mod steps_perspective_warp;
mod steps_type_path;
mod steps_other;
mod steps_render;
mod steps_selection;
mod steps_shape_blur;
mod steps_stack;
mod steps_stylize;
mod steps_video;
mod tables;
#[cfg(test)]
+5 -1
View File
@@ -77,6 +77,8 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve
let (new_steps, enabled) = match mapped {
Mapped::Steps(mut s) => {
super::steps_gallery::remember_event(item, &mut s);
super::steps_stack::remember_item(item, &mut s);
super::steps_video::remember(item, &mut s);
(s, item.enabled)
}
Mapped::Unsupported { harmless } => {
@@ -165,7 +167,9 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem
return Some(AtnItem { event, dictionary: name, descriptor: Some(descriptor), ..Default::default() });
}
let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() })
let omit_descriptor = super::steps_stack::without_descriptor(id, &params) || super::steps_video::omitted(id, &params);
let descriptor = (!omit_descriptor).then_some(descriptor);
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor, ..Default::default() })
}
/// Photoshop applies only the rename when a Set mixes it with opacity or blend mode (#8304).
+48
View File
@@ -786,6 +786,30 @@ pub(super) const EVENTS: &[Event] = &[
target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"),
target_event!("rasterizeAll", "Rasterize All Layers", "layer.rasterize.allLayers"),
target_event!("addBlankVideoLayer", "New Blank Video Layer", "layer.videoLayers.newBlankVideoLayer"),
Event {
code: "",
name: "insertBlankFrame",
label: "Insert Blank Frame",
ids: &["layer.videoLayers.insertBlankFrame"],
from: |d| super::steps_video::from(d, "layer.videoLayers.insertBlankFrame"),
to: |_, p| super::steps_video::to(p),
},
Event {
code: "",
name: "duplicateFrame",
label: "Duplicate Frame",
ids: &["layer.videoLayers.duplicateFrame"],
from: |d| super::steps_video::from(d, "layer.videoLayers.duplicateFrame"),
to: |_, p| super::steps_video::to(p),
},
Event {
code: "",
name: "deleteFrame",
label: "Delete Frame",
ids: &["layer.videoLayers.deleteFrame"],
from: |d| super::steps_video::from(d, "layer.videoLayers.deleteFrame"),
to: |_, p| super::steps_video::to(p),
},
target_event!("isolateLayers", "Isolate Layers", "select.isolateLayers"),
target_event!("textLoadDefaultTypeStyles", "Load Default Type Styles", "type.loadDefaultTypeStyles"),
target_event!("textSaveDefaultTypeStyles", "Save Default Type Styles", "type.saveDefaultTypeStyles"),
@@ -931,6 +955,22 @@ pub(super) const EVENTS: &[Event] = &[
ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]),
ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]),
ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]),
Event {
code: "",
name: "applyImageStackPluginRenderer",
label: "Stack Mode",
ids: super::steps_stack::MODE_IDS,
from: super::steps_stack::mode_from,
to: super::steps_stack::mode_to,
},
Event {
code: "",
name: "imageStackConvertSmartObject",
label: "Stack Mode: None",
ids: &[super::steps_stack::NONE],
from: super::steps_stack::none_from,
to: super::steps_stack::none_to,
},
target_event!("placedLayerMakeCopy", "New Smart Object via Copy", "layer.smartObjects.newSmartObjectViaCopy"),
target_event!("placedLayerConvertToLayers", "Convert to Layers", "layer.smartObjects.convertToLayers"),
target_event!("placedLayerConvertToEmbedded", "Convert to Embedded", "layer.smartObjects.convertToEmbedded"),
@@ -1068,6 +1108,14 @@ pub(super) const EVENTS: &[Event] = &[
ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]),
Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to },
// Edit.
Event {
code: "",
name: "perspectiveWarpTransform",
label: "Perspective Warp",
ids: &[crate::distort_cmds::PERSPECTIVE],
from: super::steps_perspective_warp::from,
to: super::steps_perspective_warp::to,
},
Event {
code: "Grdn",
name: "gradientClassEvent",
@@ -0,0 +1,171 @@
//! Recorded Perspective Warp layout and warp planes (#10710).
use std::collections::BTreeSet;
use photocraft_algo::perspective::{Plane, linked_corners};
use photocraft_algo::transform::{WARP_REACH, convex_quad};
use photocraft_io::smart_map::{MAX_PERSPECTIVE_WARP_PLANES as MAX_PLANES, perspective_warp_geometry, perspective_warp_indexed_geometry};
use super::convert::normalise;
use super::descriptors::{desc, enm, get_obj, get_ref, layer_ref, layer_target, px, ref_class};
use super::events::pixel_target;
use super::fields::{Mapped, step};
use super::{D, Descriptor, ReferenceItem, Value, json};
use crate::distort_cmds::PERSPECTIVE;
const EVENT: &str = "perspectiveWarpTransform";
const SOURCE: &str = "__atnPerspectiveWarp";
const RECT: &str = "__atnPerspectiveWarpRect";
const MAX_DESCRIPTOR_BYTES: usize = 256 * 1024;
/// Allows exactly the measured keys, once each. The preserved descriptor retains their order.
fn exact_keys(d: &Descriptor, keys: &[&str]) -> bool {
d.name.0.len() <= 1024 && d.items.len() == keys.len() && keys.iter().all(|key| d.items.iter().filter(|(id, _)| id.is(key)).count() == 1)
}
fn coordinate(value: f64) -> bool {
value.abs() <= f64::from(WARP_REACH)
}
fn rect_valid([left, top, right, bottom]: [f64; 4]) -> bool {
[left, top, right, bottom].into_iter().all(coordinate) && left < right && top < bottom
}
fn reference_rect(d: &Descriptor) -> Option<[f64; 4]> {
let r = get_obj(d, "referenceRect")?;
if !r.class_id.is("Rctn") || !exact_keys(r, &["Top ", "Left", "Btom", "Rght"]) {
return None;
}
let number = |key| match r.get(key)? {
D::UnitFloat { unit: [b'#', b'P', b'x', b'l'], value } => Some(*value),
_ => None,
};
let r = [number("Left")?, number("Top ")?, number("Rght")?, number("Btom")?];
rect_valid(r).then_some(r)
}
fn objects<'a>(d: &'a Descriptor, key: &str, class: &str, keys: &[&str], max: usize) -> Option<&'a [D]> {
let D::List(items) = d.get(key)? else { return None };
(!items.is_empty() && items.len() <= max && items.iter().all(|item| matches!(item, D::Descriptor(d) if d.class_id.is(class) && exact_keys(d, keys))))
.then_some(items)
}
/// Each recorded index must link exactly the corners the native command would link. Edges
/// shared by two faces must have opposite winding, since the native map has one neighbour per edge.
fn same_topology(planes: &[Plane], indices: &[[usize; 4]], vertices: usize) -> Option<()> {
let at = |plane: usize, corner: usize| indices.get(plane)?.get(corner);
let mut linked = BTreeSet::new();
for group in linked_corners(planes) {
let &(plane, corner) = group.first()?;
let index = at(plane, corner)?;
if !linked.insert(*index) || !group.iter().all(|&(p, c)| at(p, c) == Some(index)) {
return None;
}
}
let mut edges = BTreeSet::new();
let distinct = indices.iter().all(|&[a, b, c, d]| [(a, b), (b, c), (c, d), (d, a)].into_iter().all(|edge| edges.insert(edge)));
(linked.len() == vertices && distinct).then_some(())
}
/// Decodes the recorded target and its document-space planes, retaining the reference rectangle
/// for export. Unmodelled references and geometry stay as kept steps.
fn parameters(d: &Descriptor) -> Option<Value> {
if !(d.class_id.is(EVENT) || d.class_id.is("null")) || !exact_keys(d, &["null", "Md ", "referenceRect", "vertices", "warpedVertices", "quads"]) {
return None;
}
if !matches!(d.get("Md ")?, D::Enumerated { type_id, value } if type_id.is("perspectiveWarpMode") && value.is("warp")) {
return None;
}
let target = get_ref(d, "null")?;
if target.len() != 1 || ref_class(target).as_deref() != Some("Lyr") {
return None;
}
match target.first()? {
ReferenceItem::Enumerated { type_id, value, .. } if type_id.is("Ordn") && value.is("Trgt") => {}
ReferenceItem::Name { name, .. } if name.0.len() <= 1024 && String::from_utf16(&name.0).is_ok() && !name.to_string_lossy().is_empty() => {}
ReferenceItem::Property { key, .. } if key.is("Bckg") => {}
_ => return None,
}
let mut p = layer_target(target)?;
let rect = reference_rect(d)?;
let src = objects(d, "vertices", "Pnt ", &["Hrzn", "Vrtc"], 4 * MAX_PLANES)?;
objects(d, "warpedVertices", "Pnt ", &["Hrzn", "Vrtc"], 4 * MAX_PLANES)?;
objects(d, "quads", "perspectiveWarpQuad", &["indices"], MAX_PLANES)?;
let (planes, indices) = perspective_warp_indexed_geometry(d)?;
if !planes.iter().all(|p| p.src.iter().chain(&p.dst).flatten().copied().all(coordinate) && convex_quad(&p.src) && convex_quad(&p.dst)) {
return None;
}
same_topology(&planes, &indices, src.len())?;
p.insert("planes".into(), json!(planes));
p.insert("target".into(), json!("pixels"));
p.insert(RECT.into(), json!(rect));
Some(Value::Object(p))
}
/// Keep the exact recorded descriptor after decoding its executable settings.
pub(super) fn from(d: &Descriptor) -> Mapped {
let mapped = || {
let mut p = parameters(d)?;
let bytes = d.to_bytes();
if bytes.len() > MAX_DESCRIPTOR_BYTES {
return None;
}
p[SOURCE] = json!(crate::paint::tile::b64_encode(&bytes));
Some(p)
};
mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step(PERSPECTIVE, p))
}
/// Emits the measured field order when the caller supplies the original reference rectangle.
fn canonical(p: &Value) -> Option<Descriptor> {
let [left, top, right, bottom] = serde_json::from_value(p.get(RECT)?.clone()).ok()?;
if !rect_valid([left, top, right, bottom]) {
return None;
}
let planes = p.get("planes")?.as_array().filter(|planes| (1..=MAX_PLANES).contains(&planes.len()))?;
let planes: Vec<Plane> = serde_json::from_value(Value::Array(planes.clone())).ok()?;
let rect = desc("Rctn").with("Top ", px(top)).with("Left", px(left)).with("Btom", px(bottom)).with("Rght", px(right));
let (src, dst, quads) = perspective_warp_geometry(&planes);
Some(
desc(EVENT)
.with("null", layer_ref(p)?)
.with("Md ", enm("perspectiveWarpMode", "warp"))
.with("referenceRect", D::Descriptor(rect))
.with("vertices", src)
.with("warpedVertices", dst)
.with("quads", quads),
)
}
/// Reuses source bytes only while they still describe the same target, rectangle and planes.
/// A changed recorded mesh cannot silently replace its indexed topology.
pub(super) fn to(_: &str, p: &Value) -> Option<Descriptor> {
if !pixel_target(p) || !p.as_object()?.keys().all(|k| ["layer", "planes", "target", "interpolation", RECT, SOURCE].contains(&k.as_str())) {
return None;
}
if p.get("interpolation").is_some_and(|v| v.as_str() != Some("bicubic")) {
return None;
}
let descriptor = if let Some(source) = p.get(SOURCE) {
let text = source.as_str().filter(|v| v.len() <= MAX_DESCRIPTOR_BYTES * 2)?;
let bytes = crate::paint::tile::b64_decode(text)?;
if bytes.len() > MAX_DESCRIPTOR_BYTES {
return None;
}
Descriptor::from_bytes(&bytes).ok()?
} else {
canonical(p)?
};
let mut expected = parameters(&descriptor)?;
let mut actual = p.clone();
let m = actual.as_object_mut()?;
m.remove(SOURCE);
m.remove("interpolation");
if m.get("layer").is_some_and(Value::is_null) {
m.remove("layer");
}
m.insert("target".into(), json!("pixels"));
normalise(&mut actual);
normalise(&mut expected);
(actual == expected).then_some(descriptor)
}
@@ -0,0 +1,128 @@
//! Recorded Smart Object Stack Modes use the existing native mode commands.
use photocraft_doc::StackMode;
use super::descriptors::{class, desc, jstr, text};
use super::fields::{Mapped, step};
use super::{AtnItem, D, Descriptor, Value, json};
const PREFIX: &str = "layer.smartObjects.stackMode.";
const SOURCE: &str = "_atnStackDescriptor";
pub(super) const NONE: &str = "layer.smartObjects.stackMode.none";
pub(super) const MODE_IDS: &[&str] = &[
"layer.smartObjects.stackMode.range",
"layer.smartObjects.stackMode.entropy",
"layer.smartObjects.stackMode.kurtosis",
"layer.smartObjects.stackMode.maximum",
"layer.smartObjects.stackMode.median",
"layer.smartObjects.stackMode.minimum",
"layer.smartObjects.stackMode.mean",
"layer.smartObjects.stackMode.skewness",
"layer.smartObjects.stackMode.standardDeviation",
"layer.smartObjects.stackMode.summation",
"layer.smartObjects.stackMode.variance",
];
const MODES: &[(&str, StackMode)] = &[
("rang", StackMode::Range),
("entr", StackMode::Entropy),
("kurt", StackMode::Kurtosis),
("maxx", StackMode::Maximum),
("medn", StackMode::Median),
("minn", StackMode::Minimum),
("avrg", StackMode::Mean),
("skew", StackMode::Skewness),
("stdv", StackMode::StandardDeviation),
("summ", StackMode::Summation),
("vari", StackMode::Variance),
];
/// The plug-in class selects the mode; its localized display name does not.
fn recorded_mode(d: &Descriptor) -> Option<(&'static str, StackMode)> {
if d.items.len() != 2 {
return None;
}
let (Some(D::Class(plugin)), Some(D::Text(_))) = (d.get("imageStackPlugin"), d.get("Nm ")) else { return None };
MODES.iter().copied().find(|(code, _)| plugin.class_id.is(code))
}
fn descriptor(code: &str, mode: StackMode) -> Descriptor {
desc("null").with("imageStackPlugin", D::Class(class(code))).with("Nm ", text(mode.label()))
}
/// Keep descriptor names, key order, class encodings and original UTF-16 text on unchanged saves.
fn params(d: &Descriptor, canonical: &Descriptor) -> Value {
let mut p = json!({});
if d != canonical {
p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
}
p
}
fn original(p: &Value) -> Option<Descriptor> {
Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()
}
/// The descriptorless form reaches the mapper as a default empty `null` descriptor.
fn is_none_descriptor(d: &Descriptor) -> bool {
d.items.is_empty() && d.class_id.is("null")
}
/// Unknown mode classes, types, duplicate keys and extra settings stay complete kept steps.
pub(super) fn mode_from(d: &Descriptor) -> Mapped {
let Some((code, mode)) = recorded_mode(d) else { return Mapped::Unsupported { harmless: false } };
step(&format!("{PREFIX}{}", mode.id()), params(d, &descriptor(code, mode)))
}
/// The measured form targets the active Smart Object; explicit native targets refuse export.
pub(super) fn mode_to(id: &str, p: &Value) -> Option<Descriptor> {
if p.as_object()?.keys().any(|key| key != SOURCE) {
return None;
}
let suffix = id.strip_prefix(PREFIX)?;
let (code, mode) = MODES.iter().copied().find(|(_, mode)| suffix == mode.id())?;
let canonical = descriptor(code, mode);
if p.get(SOURCE).is_none() {
return Some(canonical);
}
let source = original(p)?;
if is_none_descriptor(&source) {
return Some(canonical);
}
let (_, recorded) = recorded_mode(&source)?;
Some(if recorded == mode { source } else { canonical })
}
/// None has no recorded settings and removes the native Smart Object stack mode.
pub(super) fn none_from(d: &Descriptor) -> Mapped {
if is_none_descriptor(d) { step(NONE, json!({})) } else { Mapped::Unsupported { harmless: false } }
}
pub(super) fn none_to(_: &str, p: &Value) -> Option<Descriptor> {
if p.as_object()?.keys().any(|key| key != SOURCE) {
return None;
}
if p.get(SOURCE).is_none() {
return Some(desc("null"));
}
let source = original(p)?;
if is_none_descriptor(&source) {
return Some(source);
}
recorded_mode(&source).map(|_| desc("null"))
}
/// Import otherwise presents an absent descriptor as an empty one. Retain the distinction for None.
pub(super) fn remember_item(item: &AtnItem, steps: &mut [(String, Value)]) {
if let [(id, Value::Object(p))] = steps
&& id.as_str() == NONE
&& let Some(d) = &item.descriptor
{
p.insert(SOURCE.into(), photocraft_paint::tile::b64_encode(&d.to_bytes()).into());
}
}
pub(super) fn without_descriptor(id: &str, p: &Value) -> bool {
id == NONE && p.get(SOURCE).is_none()
}
@@ -0,0 +1,41 @@
//! The three measured, option-free edits of the active video layer's current frame.
use super::{
AtnItem, Descriptor, Value,
fields::{Mapped, step},
json,
};
const NO_DESCRIPTOR: &str = "_atnVideoFrameNoDescriptor";
/// These events edit video footage, separately from document frame animation.
fn frame_command(id: &str) -> bool {
matches!(id, "layer.videoLayers.insertBlankFrame" | "layer.videoLayers.duplicateFrame" | "layer.videoLayers.deleteFrame")
}
/// Only the measured empty descriptor is executable; extra settings remain kept.
pub(super) fn from(d: &Descriptor, id: &str) -> Mapped {
if *d == Descriptor::default() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } }
}
/// Import's absent-descriptor placeholder must not become a descriptor on save.
pub(super) fn remember(item: &AtnItem, steps: &mut [(String, Value)]) {
if item.descriptor.is_none() {
for (id, params) in steps {
if frame_command(id)
&& let Some(params) = params.as_object_mut()
{
params.insert(NO_DESCRIPTOR.into(), Value::Bool(true));
}
}
}
}
/// Refuse native options and malformed transport data that have no wire field.
pub(super) fn to(p: &Value) -> Option<Descriptor> {
p.as_object()?.iter().all(|(key, value)| key == NO_DESCRIPTOR && value == &Value::Bool(true)).then(Descriptor::default)
}
/// Called only after `to` has validated the complete native parameter object.
pub(super) fn omitted(id: &str, p: &Value) -> bool {
frame_command(id) && p.get(NO_DESCRIPTOR) == Some(&Value::Bool(true))
}
+26 -1
View File
@@ -658,6 +658,11 @@ fn settings_at_the_value_the_event_plays_still_export() {
/// Each mapped command retains its parameters through its corresponding ATN descriptor.
fn every_mapped_command_exports_and_imports_back() {
let cases: Vec<(&str, Value)> = vec![
(
"edit.perspectiveWarp",
json!({"planes":[{"src":[[4,4],[16,4],[16,20],[4,20]],"dst":[[5,5],[17,4],[18,20],[4,19]]}],
"target":"pixels","__atnPerspectiveWarpRect":[0,0,32,24]}),
),
(
"paint.gradient",
json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100,
@@ -680,6 +685,18 @@ fn every_mapped_command_exports_and_imports_back() {
),
("edit.fade", json!({"opacity":50,"mode":"Multiply"})),
("layer.linkLayers", json!({})),
("layer.smartObjects.stackMode.range", json!({})),
("layer.smartObjects.stackMode.entropy", json!({})),
("layer.smartObjects.stackMode.kurtosis", json!({})),
("layer.smartObjects.stackMode.maximum", json!({})),
("layer.smartObjects.stackMode.median", json!({})),
("layer.smartObjects.stackMode.minimum", json!({})),
("layer.smartObjects.stackMode.mean", json!({})),
("layer.smartObjects.stackMode.skewness", json!({})),
("layer.smartObjects.stackMode.standardDeviation", json!({})),
("layer.smartObjects.stackMode.summation", json!({})),
("layer.smartObjects.stackMode.variance", json!({})),
("layer.smartObjects.stackMode.none", json!({})),
("layer.distribute.leftEdges", json!({})),
("layer.distribute.horizontalCenters", json!({})),
("layer.distribute.rightEdges", json!({})),
@@ -826,6 +843,9 @@ fn every_mapped_command_exports_and_imports_back() {
("type.loadDefaultTypeStyles", json!({})),
("type.saveDefaultTypeStyles", json!({})),
("image.analysis.recordMeasurements", json!({})),
("layer.videoLayers.insertBlankFrame", json!({})),
("layer.videoLayers.duplicateFrame", json!({})),
("layer.videoLayers.deleteFrame", json!({})),
("file.automate.cropAndStraightenPhotos", json!({})),
("file.automate.cropAndStraightenPhotos", json!({"_atnNoTarget": true})),
("layer.new.artboardFromGroup", json!({})),
@@ -1054,7 +1074,9 @@ fn every_mapped_command_exports_and_imports_back() {
continue;
}
let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id()));
match (e.from)(item.descriptor.as_ref().unwrap()) {
assert_eq!(item.descriptor.is_none(), *id == super::steps_stack::NONE, "{id}: unexpected descriptor presence");
let descriptor = item.descriptor.unwrap_or_default();
match (e.from)(&descriptor) {
Mapped::Steps(steps) => {
assert_eq!(steps.len(), 1, "{id}: {steps:?}");
let navigation = match *id {
@@ -1080,6 +1102,9 @@ fn every_mapped_command_exports_and_imports_back() {
// Executable values match; independent public tests verify the source carrier.
back.as_object_mut().unwrap().remove("__atnClassicGradient");
}
if *id == "edit.perspectiveWarp" {
back.as_object_mut().unwrap().remove("__atnPerspectiveWarp");
}
normalise(&mut back);
if *id == "actions.play" || *id == "actions.conditional" {
back["set"] = json!("S");
+2 -1
View File
@@ -54,9 +54,10 @@ fn can_split(s: &Session) -> std::result::Result<(), String> {
Ok(())
}
}
/// Merge Channels needs a document per colour channel: three at least (RGB and Lab; CMYK four).
fn can_merge(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if mergeable(s).len() >= 2 { Ok(()) } else { Err("Merge Channels needs at least two open grayscale documents of the same size".into()) }
if mergeable(s).len() >= 3 { Ok(()) } else { Err("Merge Channels needs at least three open grayscale documents of the same size".into()) }
}
// ---------------------------------------------------------------------------------------------
@@ -1238,3 +1238,21 @@ fn split_refuses_an_image_with_more_than_one_layer() {
assert!(s.execute("channel.split", json!({})).is_err());
assert_eq!(s.documents().len(), 1);
}
/// Merge Channels is offered from three grayscale documents of one size, the fewest a mode
/// (RGB or Lab) merges; two never merge (#11451).
#[test]
fn merge_channels_waits_for_three_grayscale_documents() {
let mut s = Session::new();
for _ in 0..2 {
s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
}
s.execute("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap();
s.set_active(0);
let why = s.disabled_reason("channel.merge").expect("two of one size are not enough");
assert!(why.contains("three"), "{why}");
s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
assert!(s.is_enabled("channel.merge"));
s.execute("channel.merge", json!({"mode": "lab"})).unwrap();
assert_eq!(s.documents().len(), 2, "the three of one size merged; the other is left");
}
+2 -2
View File
@@ -407,7 +407,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec {
id: PUPPET_SMART,
label: "Puppet Warp",
menu: &["Layer", "Smart Objects"],
menu: &[],
shortcut: None,
params: P,
enabled: smart_layer,
@@ -427,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec {
id: PERSPECTIVE_SMART,
label: "Perspective Warp",
menu: &["Layer", "Smart Objects"],
menu: &[],
shortcut: None,
params: Q,
enabled: smart_layer,
+1 -1
View File
@@ -279,7 +279,7 @@ fn content_aware_native_with_options(
let window = sampling_window(hole_bounds, canvas);
let n = surf.format().channels();
let (w, h) = window_size(window)?;
let img = surf.read_region(window);
let img = crate::allocation::read_region(surf, window, "Content-Aware fill samples")?;
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the area is too large for Content-Aware fill".into()))?;
let mut hole = crate::allocation::filled(len, false, "Content-Aware fill")?;
let mut source = crate::allocation::filled(len, false, "Content-Aware fill")?;
+9 -4
View File
@@ -243,6 +243,8 @@ pub struct Floating {
/// The piece has been moved at least once, even if it is back where it was cut (nudged away
/// and back): its selection is the moved pixels' then, as each of Photoshop's nudges leaves it.
pub moved: bool,
/// History step name when dropped; `None` is "Move". The Move tool names a ⌥-drag copy "Duplicate".
pub step_name: Option<String>,
}
/// The floating piece of `st`, if it is still valid (the document hasn't changed under it).
@@ -289,12 +291,14 @@ pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
fn float(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (int_param(p, "dx"), int_param(p, "dy"));
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
// A name for the History step that drops the piece: short and not blank, else the default.
let step_name = p.get("stepName").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty() && s.chars().count() <= 64).map(str::to_string);
let background = s.tools.background;
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
if floating(st).is_none() {
let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let parts = CutParts::new(&st.doc, layer, copy, background, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Mask)?;
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false });
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false, step_name });
}
let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?;
f.offset = (f.offset.0.saturating_add(dx), f.offset.1.saturating_add(dy));
@@ -311,20 +315,21 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
return Ok(json!({"dropped": false}));
};
st.floating = None;
let name = f.step_name.clone().unwrap_or_else(|| "Move".to_string());
let (dx, dy) = f.offset;
if (dx, dy) == (0, 0) {
// Back where it was cut: the pixels are as they were. A piece that never moved (a click)
// changes nothing; one nudged away and back keeps the moved pixels' selection (#7538).
if let Some(outline) = f.parts.outline().filter(|_| f.moved) {
let outline = outline.clone();
s.edit("Move", |doc, _| {
s.edit(&name, |doc, _| {
doc.selection = Some(outline);
Ok(())
})?;
}
return Ok(json!({"dropped": true, "offset": [0, 0]}));
}
s.edit("Move", |doc, _| {
s.edit(&name, |doc, _| {
f.parts.place(doc, dx, dy)?;
let sel = f.parts.outline().or(doc.selection.as_ref());
doc.selection = sel.map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy));
@@ -374,7 +379,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Float Selection",
menu: &[],
shortcut: None,
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"stepName":string="Move" (History name of the step that drops the piece; blank or over 64 characters falls back to Move),"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
enabled: can_float,
journal: true,
run: float,
+247 -19
View File
@@ -1,35 +1,113 @@
//! History panel › History Options… (#2966): Allow Non-Linear History and Make Layer Visibility
//! Changes Undoable. The values are preferences (`historyOptions.*`, also `prefs.set`), applied
//! by [`Session::apply_prefs`] and [`Session::edit_visibility`]. Without params the command
//! reports them; the GUI opens its dialog instead and runs it with the dialog's values on OK.
//!
//! Photoshop's three snapshot options (Automatically Create First Snapshot, Automatically
//! Create New Snapshot When Saving, Show New Snapshot Dialog by Default) come with #3634, which
//! makes the snapshots act on them.
//! History panel › History Options… (#2966), Photoshop's five checkboxes: Automatically Create
//! First Snapshot, Automatically Create New Snapshot When Saving, Allow Non-Linear History, Show
//! New Snapshot Dialog by Default and Make Layer Visibility Changes Undoable. The values are
//! preferences (`historyOptions.*`, also `prefs.set`), applied by [`Session::add_document`],
//! [`snapshot_on_save`], [`Session::apply_prefs`], [`Session::edit_visibility`] and the GUI's
//! camera button. Without params the command reports them; the GUI opens its dialog instead and
//! runs it with the dialog's values on OK.
use serde_json::{Map, Value};
use crate::commands::CommandSpec;
use crate::history_cmds::Snapshot;
use crate::prefs::HistoryOptions;
use crate::{EngineError, Result, Session};
const CMD: &str = "history.options";
/// The option keys, as in `prefs.get historyOptions` and the command's params.
pub const OPTIONS: [&str; 2] = ["allowNonLinearHistory", "makeLayerVisibilityChangesUndoable"];
/// The option keys, as in `prefs.get historyOptions` and the command's params, in the order of
/// Photoshop's dialog.
pub const OPTIONS: [&str; 5] = [
"automaticallyCreateFirstSnapshot",
"automaticallyCreateNewSnapshotWhenSaving",
"allowNonLinearHistory",
"showNewSnapshotDialogByDefault",
"makeLayerVisibilityChangesUndoable",
];
/// The option named `key` (one of [`OPTIONS`]).
fn option_mut<'a>(o: &'a mut HistoryOptions, key: &str) -> Option<&'a mut bool> {
Some(match key {
"automaticallyCreateFirstSnapshot" => &mut o.automatically_create_first_snapshot,
"automaticallyCreateNewSnapshotWhenSaving" => &mut o.automatically_create_new_snapshot_when_saving,
"allowNonLinearHistory" => &mut o.allow_non_linear_history,
"showNewSnapshotDialogByDefault" => &mut o.show_new_snapshot_dialog_by_default,
"makeLayerVisibilityChangesUndoable" => &mut o.make_layer_visibility_changes_undoable,
_ => return None,
})
}
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
/// The current History Options as `{allowNonLinearHistory, makeLayerVisibilityChangesUndoable}`.
/// The current History Options, keyed by [`OPTIONS`].
pub fn current(s: &Session) -> Value {
let o = &s.prefs().history_options;
serde_json::json!({
"automaticallyCreateFirstSnapshot": o.automatically_create_first_snapshot,
"automaticallyCreateNewSnapshotWhenSaving": o.automatically_create_new_snapshot_when_saving,
"allowNonLinearHistory": o.allow_non_linear_history,
"showNewSnapshotDialogByDefault": o.show_new_snapshot_dialog_by_default,
"makeLayerVisibilityChangesUndoable": o.make_layer_visibility_changes_undoable,
})
}
/// History Options › Show New Snapshot Dialog by Default: whether a plain click on the History
/// panel's camera button opens New Snapshot.
pub fn show_new_snapshot_dialog(s: &Session) -> bool {
s.prefs().history_options.show_new_snapshot_dialog_by_default
}
/// History Options › Automatically Create New Snapshot When Saving: after the active document
/// was saved to its own file (not a copy or an export), add a snapshot of it named with the local
/// time of the save, as Photoshop does. Not a history state. Returns the new snapshot's id, or
/// `None` when the option is off, no document is active, or the document is unchanged since the
/// snapshot the previous save added (and that snapshot is still there), so saving a clean
/// document again doesn't add a duplicate row. Shells call it where they record a document save.
///
/// Memory: like every History panel snapshot, each one pins a copy of the document for the
/// session (only the tiles that differ from the document and the other snapshots cost memory).
/// [`crate::DocState::trim_history`] counts those tiles against the History & Cache budget by
/// dropping the oldest history states, but never drops a snapshot itself: many saves of a
/// changing document can hold more than the budget until snapshots are deleted or the document
/// is closed.
pub fn snapshot_on_save(s: &mut Session) -> Option<u64> {
if !s.prefs().history_options.automatically_create_new_snapshot_when_saving {
return None;
}
let st = s.active_mut()?;
// Saved again without a change: the previous save's snapshot already shows this document.
if let Some(prev) = st.save_snapshot
&& st.snapshots.iter().any(|snap| snap.id == prev && std::sync::Arc::ptr_eq(&snap.doc, &st.doc))
{
return None;
}
let name = save_snapshot_name().unwrap_or_else(|| crate::history_cmds::next_snapshot_name(st));
let id = st.next_snapshot;
st.next_snapshot = id.saturating_add(1);
let (doc, layers) = (st.doc.clone(), st.layer_target());
// Like the camera button: the document and its saved state are unchanged.
st.snapshots.push(Snapshot { id, name, doc, layers });
st.save_snapshot = Some(id);
// The snapshot's tiles count against the history budget.
st.trim_history();
Some(id)
}
/// The local time as `HH:MM:SS`, the name of a snapshot made on save; `None` without a clock
/// (the web build), where the snapshot gets the camera button's "Snapshot N" instead.
fn save_snapshot_name() -> Option<String> {
#[cfg(not(target_arch = "wasm32"))]
{
Some(chrono::Local::now().format("%H:%M:%S").to_string())
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let given: Map<String, Value> = match p {
Value::Null => Map::new(),
@@ -39,7 +117,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
// Validate everything before changing anything.
let mut set = Vec::new();
for (k, v) in &given {
if !OPTIONS.contains(&k.as_str()) {
if option_mut(&mut HistoryOptions::default(), k).is_none() {
return Err(bad(format!("unknown option `{k}` (expected {})", OPTIONS.join(", "))));
}
let b = v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false")))?;
@@ -48,9 +126,8 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
if !set.is_empty() {
s.edit_prefs(|prefs| {
for (k, b) in &set {
match k.as_str() {
"allowNonLinearHistory" => prefs.history_options.allow_non_linear_history = *b,
_ => prefs.history_options.make_layer_visibility_changes_undoable = *b,
if let Some(o) = option_mut(&mut prefs.history_options, k) {
*o = *b;
}
}
});
@@ -120,7 +197,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "History Options…",
menu: &[],
shortcut: None,
params: r##"{"allowNonLinearHistory":bool?,"makeLayerVisibilityChangesUndoable":bool?} → both values (no params: report them). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##,
params: r##"{"automaticallyCreateFirstSnapshot":bool?,"automaticallyCreateNewSnapshotWhenSaving":bool?,"allowNonLinearHistory":bool?,"showNewSnapshotDialogByDefault":bool?,"makeLayerVisibilityChangesUndoable":bool?} → all five values (no params: report them). Automatically Create First Snapshot: a new or opened document starts with a snapshot of itself, named after it (default on). Automatically Create New Snapshot When Saving: each save of the document (not a copy) adds a snapshot named with the time of the save (default off). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Show New Snapshot Dialog by Default: a click on the History panel's camera button opens New Snapshot, not only ⌥-click (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##,
enabled: always,
run: options,
journal: false,
@@ -148,10 +225,21 @@ mod tests {
#[test]
fn defaults_match_photoshop_and_round_trip_through_prefs() {
let mut s = Session::new();
assert_eq!(s.execute(CMD, json!({})).unwrap(), json!({"allowNonLinearHistory": false, "makeLayerVisibilityChangesUndoable": true}));
let defaults = json!({
"automaticallyCreateFirstSnapshot": true,
"automaticallyCreateNewSnapshotWhenSaving": false,
"allowNonLinearHistory": false,
"showNewSnapshotDialogByDefault": false,
"makeLayerVisibilityChangesUndoable": true,
});
assert_eq!(s.execute(CMD, json!({})).unwrap(), defaults);
assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions"})).unwrap(), current(&s));
let r = s.execute(CMD, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false})).unwrap();
assert_eq!(r, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false}));
let flipped: Map<String, Value> = defaults.as_object().unwrap().iter().map(|(k, v)| (k.clone(), json!(!v.as_bool().unwrap()))).collect();
let r = s.execute(CMD, Value::Object(flipped.clone())).unwrap();
assert_eq!(r, Value::Object(flipped));
for key in OPTIONS {
assert_eq!(s.execute("prefs.get", json!({"path": format!("historyOptions.{key}")})).unwrap(), r[key], "{key}");
}
assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions.allowNonLinearHistory"})).unwrap(), json!(true));
// Saved and restored with the other preferences.
let saved = s.prefs_to_json();
@@ -177,6 +265,11 @@ mod tests {
json!({"allowNonLinearHistory": 1}),
json!({"makeLayerVisibilityChangesUndoable": null}),
json!({"allowNonLinearHistory": true, "snapshots": true}),
json!({"automaticallyCreateFirstSnapshot": "true"}),
json!({"automaticallyCreateNewSnapshotWhenSaving": 0}),
json!({"showNewSnapshotDialogByDefault": [true]}),
json!({"showNewSnapshotDialogByDefault": true, "showNewSnapshotDialog": true}),
json!({"AutomaticallyCreateFirstSnapshot": false}),
json!([true]),
json!("x"),
json!(3),
@@ -185,6 +278,8 @@ mod tests {
assert_eq!(current(&s), before, "{p} changed nothing");
}
assert!(s.execute("prefs.set", json!({"path": "historyOptions.allowNonLinearHistory", "value": "on"})).is_err());
assert!(s.execute("prefs.set", json!({"path": "historyOptions.automaticallyCreateFirstSnapshot", "value": 1})).is_err());
assert!(s.execute("prefs.set", json!({"path": "historyOptions.showNewSnapshotDialogByDefault", "value": null})).is_err());
assert_eq!(current(&s), before);
}
@@ -358,6 +453,139 @@ mod tests {
assert!(s.active().unwrap().history.non_linear);
}
fn snapshot_names(s: &Session) -> Vec<String> {
s.active().unwrap().snapshots.iter().map(|snap| snap.name.clone()).collect()
}
/// PNG bytes of a small image, to open.
fn png() -> Vec<u8> {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
crate::file_cmds::encode(&s.active().unwrap().doc, "a.png", None::<f64>).unwrap().0
}
/// Automatically Create First Snapshot (checked against Photoshop): on (the default), a new or
/// opened document starts with a snapshot named after it; off, it starts with none and the
/// camera's first snapshot is still "Snapshot 1".
#[test]
fn first_snapshot_follows_the_option_on_new_and_open() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let name = s.active().unwrap().doc.name.clone();
assert_eq!(snapshot_names(&s), [name]);
crate::file_cmds::open_bytes_as(&mut s, "photo.png", &png(), None, None).unwrap();
assert_eq!(snapshot_names(&s), ["photo.png"], "named after the file, as Photoshop does");
s.execute(CMD, json!({"automaticallyCreateFirstSnapshot": false})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert!(snapshot_names(&s).is_empty());
assert_eq!(entries(&s).len(), 1, "the opening state is still there");
crate::file_cmds::open_bytes_as(&mut s, "photo.png", &png(), None, None).unwrap();
assert!(snapshot_names(&s).is_empty());
assert_eq!(s.execute("history.newSnapshot", json!({})).unwrap(), json!({"snapshot": 1, "name": "Snapshot 1"}));
assert_eq!(crate::inspect::document(s.active().unwrap())["snapshots"], json!([{"id": 1, "name": "Snapshot 1"}]));
// Documents opened earlier keep theirs: the option applies when a document opens.
s.set_active(0);
assert_eq!(snapshot_names(&s).len(), 1);
}
/// Automatically Create New Snapshot When Saving: off (the default), a save adds nothing; on,
/// each save adds a snapshot of the document as saved, named with the time, and not a
/// history state.
#[test]
fn a_save_adds_a_snapshot_only_with_the_option() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert_eq!(snapshot_on_save(&mut s), None);
assert_eq!(snapshot_names(&s).len(), 1, "only the first snapshot");
s.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.active_mut().unwrap().saved_to("saved.psd".into());
let states = entries(&s);
let id = snapshot_on_save(&mut s).unwrap();
let st = s.active().unwrap();
let snap = st.snapshots.last().unwrap();
assert_eq!(snap.id, id);
assert!(std::sync::Arc::ptr_eq(&snap.doc, &st.doc), "the document as saved");
// HH:MM:SS, the local time of the save.
let parts: Vec<&str> = snap.name.split(':').collect();
assert!(parts.len() == 3 && parts.iter().all(|p| p.len() == 2 && p.bytes().all(|b| b.is_ascii_digit())), "{}", snap.name);
assert_eq!(entries(&s), states, "not a history state");
assert!(!s.active().unwrap().is_dirty(), "the saved document stays clean");
// Each save adds one; selecting it brings that save back.
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
let second = snapshot_on_save(&mut s).unwrap();
assert_eq!(snapshot_names(&s).len(), 3);
s.execute("history.selectSnapshot", json!({"snapshot": id})).unwrap();
assert_eq!(s.active().unwrap().doc.layers[0].surface().unwrap().rgba(1, 1), [1.0, 0.0, 0.0, 1.0]);
assert!(second > id);
// No document: nothing to do, no panic.
let mut empty = Session::new();
empty.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
assert_eq!(snapshot_on_save(&mut empty), None);
}
/// Saving an unchanged document again (⌘S on a clean document) adds no duplicate row; a
/// change, or deleting the previous save's snapshot, makes the next save add one again.
#[test]
fn saving_an_unchanged_document_adds_no_duplicate_snapshot() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
let first = snapshot_on_save(&mut s).unwrap();
assert_eq!(snapshot_on_save(&mut s), None, "unchanged since the last save snapshot");
assert_eq!(snapshot_names(&s).len(), 2, "the first snapshot and one save snapshot");
// A view-only change (no document change) doesn't count either.
s.active_mut().unwrap().revision += 1;
assert_eq!(snapshot_on_save(&mut s), None);
// An edit does.
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
let second = snapshot_on_save(&mut s).unwrap();
assert!(second > first);
assert_eq!(snapshot_on_save(&mut s), None);
// The previous save's snapshot deleted: the next save shows the document again.
s.execute("history.deleteSnapshot", json!({"snapshot": second})).unwrap();
assert!(snapshot_on_save(&mut s).is_some());
assert_eq!(snapshot_names(&s).len(), 3);
}
#[test]
fn show_new_snapshot_dialog_reads_the_option() {
let mut s = Session::new();
assert!(!show_new_snapshot_dialog(&s));
s.execute("prefs.set", json!({"path": "historyOptions.showNewSnapshotDialogByDefault", "value": true})).unwrap();
assert!(show_new_snapshot_dialog(&s));
assert_eq!(crate::inspect::session(&s)["historyOptions"]["showNewSnapshotDialogByDefault"], json!(true));
}
/// The first snapshot and a save snapshot are recorded steps like any other: with Make Layer
/// Visibility Changes Undoable off, selecting one that shows a layer the eye hid, then Undo,
/// hides the layer again, and Redo shows it (the step changed it, #3535's rule).
#[test]
fn selecting_a_snapshot_undoes_its_own_visibility_change() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
let id = s.active().unwrap().active_layer.unwrap().0;
// Red, not the white document as opened (`red` only reads the red channel).
let red = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().surface().unwrap().pixel(1, 1)[1] < 0.5;
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false, "automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.active_mut().unwrap().saved_to("saved.psd".into());
let saved = snapshot_on_save(&mut s).unwrap();
// The eye hides the layer: no state.
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
for (snap, is_red) in [(0, false), (saved, true)] {
s.execute("history.selectSnapshot", json!({"snapshot": snap})).unwrap();
assert!(visible(&s, id) && red(&s) == is_red, "snapshot {snap} shows the layer as it was");
s.execute("edit.undo", json!({})).unwrap();
assert!(!visible(&s, id) && red(&s), "undoing snapshot {snap} hides the layer again");
s.execute("edit.redo", json!({})).unwrap();
assert!(visible(&s, id) && red(&s) == is_red, "redoing snapshot {snap} shows it");
s.execute("edit.undo", json!({})).unwrap();
}
}
/// With the oldest state current, non-linear Delete is still enabled for a later (dimmed)
/// state (the History panel's trash and its drop target ask for it); linear history has
/// nothing to delete there.
+2 -1
View File
@@ -366,6 +366,7 @@ mod tests {
json!({"state": 1.5}),
json!({"state": "1"}),
json!({"state": [1]}),
json!({"snapshot": 99}),
json!({"snapshot": 1}),
json!({"snapshot": -1}),
json!({"snapshot": "Open"}),
@@ -375,7 +376,7 @@ mod tests {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert_eq!(s.active().unwrap().history.brush_source(), Some(0), "nothing changed");
// Snapshot 0 is the document as opened, so it is a valid source.
// Snapshot 0 is the document as opened (Automatically Create First Snapshot), so it is a valid source.
assert_eq!(s.execute(CMD, json!({"snapshot": 0})).unwrap()["snapshot"], 0);
}
}
+114 -23
View File
@@ -1,9 +1,10 @@
//! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and
//! bit depth) conversions, Duplicate.
use std::collections::HashSet;
use std::convert::Infallible;
use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface};
use photocraft_algo::resample::{Resample, crop_surface, largest_resize_rect, resize_surface_in_canvas, scaled_rect, translate_surface};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size};
use photocraft_geom::Rect;
@@ -11,6 +12,7 @@ use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::document_preset_cmds::MAX_RESOLUTION;
use crate::{EngineError, Result, Session};
fn has_doc(s: &Session) -> std::result::Result<(), String> {
@@ -235,15 +237,27 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
pw = pw.or(Some(ow as f64 * pc / 100.0));
ph = ph.or(Some(oh as f64 * pc / 100.0));
}
// The document range File › New and the dialog's field allow; outside it, refuse rather than
// clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). The document's own
// resolution always passes: an imported file may have one outside the range, and the dialog
// sends it back unchanged (exactly: a value that only rounds to it as f32 is refused).
let own_dpi = f64::from(d.doc.resolution_dpi);
let dpi = match p.get("resolution") {
None | Some(Value::Null) => None,
Some(v) => {
let r = v.as_f64().filter(|&r| r.is_finite() && ((1.0..=MAX_RESOLUTION).contains(&r) || r == own_dpi));
Some(r.ok_or_else(|| bad("image.imageSize", format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))?)
}
};
// `keepPrintSize`: only the resolution given, the pixels follow it (Photoshop's Image Size
// with Resample on, as an action records it).
// with Resample on, as an action records it). An unchanged resolution keeps the pixels.
if p.get("keepPrintSize").and_then(Value::as_bool) == Some(true)
&& pw.is_none()
&& ph.is_none()
&& let Some(r) = p.get("resolution").and_then(Value::as_f64)
&& let Some(r) = dpi
&& r != own_dpi
{
let r = r.clamp(1.0, 10_000.0);
let old = f64::from(d.doc.resolution_dpi).max(1.0);
let old = own_dpi.max(1.0);
pw = Some(ow as f64 * r / old);
ph = Some(oh as f64 * r / old);
}
@@ -260,7 +274,6 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) {
check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?;
}
let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32);
let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true);
if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) {
// Before the sampler map or the document change: a resize that doesn't fit would trap on the web (#10024).
@@ -272,7 +285,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Image Size", |doc, _| {
if let Some(r) = dpi {
// Type keeps its pixel size; resampling below then scales it with the pixels.
crate::canvas_geom::set_resolution(doc, r.clamp(1.0, 10_000.0));
crate::canvas_geom::set_resolution(doc, r as f32);
}
let filter = match resample {
Method::None => return Ok(()),
@@ -280,8 +293,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails,
Method::Automatic => Resample::BicubicSharper,
};
resample_doc_with_styles(doc, nw, nh, filter, scale_styles);
Ok(())
resample_doc_with_styles(doc, "image.imageSize", nw, nh, filter, scale_styles)
})?;
// The size the document has now: without resampling `width`/`height` are ignored (#1815).
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
@@ -316,18 +328,20 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32,
/// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to
/// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution
/// mode. Callers check [`check_resample_budget`] first.
pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) {
resample_doc_with_styles(doc, nw, nh, filter, true);
/// mode. Callers check [`check_resample_budget`] for the new canvas first; the pixels outside the
/// canvas are scaled too, so a surface they would grow past the budget is refused here (#9212).
pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample) -> Result<()> {
resample_doc_with_styles(doc, cmd, nw, nh, filter, true)
}
/// Resize document content while optionally retaining layer-effect sizes in pixels.
fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) {
fn resample_doc_with_styles(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample, scale_styles: bool) -> Result<()> {
let (ow, oh) = (doc.size.width, doc.size.height);
let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64);
if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 {
return;
return Ok(());
}
check_hidden_resample_budget(cmd, doc, sx, sy, filter)?;
// Content that reaches the canvas edge keeps it: a Background stays opaque to the border.
let canvas = Rect::from_xywh(0, 0, ow, oh);
let taken = crate::smart_cmds::take_caches(&mut doc.layers);
@@ -358,6 +372,76 @@ fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resamp
doc.size = Size::new(nw, nh);
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
let Ok(()) = crate::smart_cmds::restore_unrendered(doc, taken, |c| Ok::<_, Infallible>(resize_surface_in_canvas(c, sx, sy, filter, canvas)));
Ok(())
}
/// [`check_resample_budget`] for the largest surface [`resample_doc`] stores while scaling by
/// `sx` x `sy` with `filter` (masks, channels and the selection with Bilinear): each keeps its
/// pixels outside the canvas (a crop that didn't delete them, a layer moved off the canvas), and
/// those grow with the scale like the rest. A bicubic shrink also stores the image between its
/// two passes, one axis still at its old size ([`largest_resize_rect`]). The refusal suggests
/// Delete Cropped Pixels only when the surfaces over the budget are all ones it clips
/// ([`delete_cropped_pixels`]).
fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64, filter: Resample) -> Result<()> {
let new_canvas = scaled_rect(doc.bounds(), sx, sy);
// The surfaces Delete Cropped Pixels clips; masks, channels, the selection, type, shapes and
// smart objects keep their pixels outside the crop.
let mut clipped: HashSet<*const Surface> = HashSet::new();
for_each_layer(&mut doc.layers, &mut |l| {
clipped.extend(l.fill_cache.as_ref().map(|fc| std::ptr::from_ref(&fc.surface)));
if let Some(v) = &l.video {
clipped.extend(v.frames.iter().chain(v.original_frames.iter().flatten()).map(std::ptr::from_ref));
}
if let LayerContent::Raster(s) = &l.content {
clipped.insert(std::ptr::from_ref(s));
}
});
let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp);
// The largest surface by bytes, and the largest one Delete Cropped Pixels doesn't clip: its
// width, height and bytes per pixel (a mask has one channel, the layers the document's).
let (mut largest, mut largest_kept) = ((0u32, 0u32, 1u64), (0u32, 0u32, 1u64));
let mut visit = |surf: &Surface, filter: Resample| {
let r = largest_resize_rect(surf.content_bounds(), sx, sy, filter);
// What stays within the new canvas is already budgeted.
if r.is_empty() || new_canvas.union(&r) == new_canvas {
return;
}
let this = (r.width(), r.height(), surf.format().bytes_per_pixel().max(1) as u64);
if bytes(this) > bytes(largest) {
largest = this;
}
if !clipped.contains(&std::ptr::from_ref(surf)) && bytes(this) > bytes(largest_kept) {
largest_kept = this;
}
};
// The surfaces `resample_doc` scales, smart object caches included.
for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| visit(surf, if is_mask { Resample::Bilinear } else { filter }));
for ch in doc.channels.iter().chain(doc.quick_mask.as_ref()) {
visit(&ch.surface, Resample::Bilinear);
}
if let Some(sel) = &doc.selection {
visit(sel, Resample::Bilinear);
}
let (kept, (w, h, bpp)) = if bytes(largest_kept) > MAX_RESAMPLE_BYTES {
(true, largest_kept)
} else if bytes(largest) > MAX_RESAMPLE_BYTES {
(false, largest)
} else {
return Ok(());
};
let hint = if kept {
"choose a smaller size (layer masks, channels, the quick mask, the selection, type, shapes and smart objects keep theirs through Crop with Delete Cropped Pixels)"
} else {
"choose a smaller size, or delete them first (Crop with Delete Cropped Pixels)"
};
let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000;
Err(bad(
cmd,
format!(
"{w} x {h} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. The pixels outside the canvas are resampled too: {hint}",
u64::from(w).saturating_mul(u64::from(h)) / 1_000_000
),
))
}
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
@@ -714,6 +798,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
// Only the Crop tool's frame (an explicit rectangle) resizes a canvas-sized artboard; a
// selection crop leaves boards as they are.
let tool_frame = explicit.is_some();
let mut filled = false;
s.edit("Crop", |doc, active| {
let original = doc.size;
let board = if tool_frame { canvas_artboard(doc) } else { None };
@@ -725,12 +810,14 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
// A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped
// Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060).
if content_aware {
crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?;
filled = crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height), delete)?;
}
doc.selection = None;
target.apply(doc);
Ok(())
target.apply(doc)
})?;
if filled && let Some(st) = s.active_mut() {
st.history.set_current_label("Content-aware Crop");
}
Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })))
}
@@ -744,15 +831,16 @@ struct CropTarget {
impl CropTarget {
/// Applies the target to a document that has just been cropped (inside the crop's history step).
fn apply(&self, doc: &mut Document) {
fn apply(&self, doc: &mut Document) -> Result<()> {
if let Some((w, h)) = self.size {
// As for Image Size: an Indexed Color document holds only its colour table's colours (#6019).
let filter = if doc.mode == ColorMode::Indexed { Resample::Nearest } else { self.filter };
resample_doc(doc, w, h, filter);
resample_doc(doc, "image.crop", w, h, filter)?;
}
if let Some(r) = self.resolution {
doc.resolution_dpi = r;
}
Ok(())
}
/// Where Color Sampler points go: shifted by the crop's origin, then scaled with the pixels
@@ -897,6 +985,7 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
};
// The canvas board, found before the turn, fits the frame as in a straight crop.
let board = s.active().and_then(|d| canvas_artboard(&d.doc));
let mut filled = false;
crate::analysis_cmds::compound(s, "Crop", |s| {
// Pixels the crop deletes needn't be turned first: keeping only what lands in the frame
// makes the rotation cost the frame's size, not the canvas's (a small turned crop of a
@@ -934,13 +1023,15 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
crop_doc(doc, CMD, target, delete, extension)?;
fit_canvas_artboard(doc, board);
if content_aware {
crate::crop_fill::fill_uncovered(doc, *active, &coverage)?;
filled = crate::crop_fill::fill_uncovered(doc, *active, &coverage, delete)?;
}
doc.selection = None;
crop_target.apply(doc);
Ok(())
crop_target.apply(doc)
})
})?;
if filled && let Some(st) = s.active_mut() {
st.history.set_current_label("Content-aware Crop");
}
Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle })))
}
@@ -1109,7 +1200,7 @@ pub fn specs() -> Vec<CommandSpec> {
"image.crop",
"Crop",
["Image"],
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##,
has_doc,
crop
),
+133
View File
@@ -234,6 +234,48 @@ fn resolution_only_change() {
assert_eq!(r, json!({"width": 40, "height": 20}));
}
/// #9260: a resolution outside the document range (1..=30000 ppi, as File › New and the
/// dialog's field) is refused and leaves the document as it was, instead of being clamped;
/// one inside it, also above 10000, is set as given.
#[test]
fn image_size_refuses_a_resolution_out_of_range() {
for extra in [json!({"resample": "none"}), json!({"resample": "bicubic"}), json!({"resample": "bicubic", "keepPrintSize": true})] {
for r in [json!(1e39), json!(-5), json!(0), json!(0.5), json!(30_001), json!("300")] {
let mut s = session();
let (size, dpi, past) = (doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len());
let mut p = extra.clone();
p["resolution"] = r;
assert!(s.execute("image.imageSize", p.clone()).is_err(), "{p}");
assert_eq!((doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()), (size, dpi, past), "{p}: nothing changed");
}
}
let mut s = session();
s.execute("image.imageSize", json!({"resolution": 20_000, "resample": "none"})).unwrap();
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(40, 20), 20_000.0));
s.execute("image.imageSize", json!({"resolution": null, "resample": "none"})).unwrap();
assert_eq!(doc(&s).resolution_dpi, 20_000.0, "null leaves the resolution as it is");
// An imported document's own resolution outside the range (a PNG pHYs of 1 px/m is
// 0.0254 ppi) still resizes: the Image Size dialog sends it back unchanged on OK.
for own in [0.0254_f32, 40_000.0] {
let mut s = session();
s.edit("ppi", |doc, _| {
doc.resolution_dpi = own;
Ok(())
})
.unwrap();
s.execute("image.imageSize", json!({"width": 80, "height": 40, "resolution": f64::from(own), "resample": "bicubic"})).unwrap();
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}");
assert!(s.execute("image.imageSize", json!({"resolution": 0.03, "resample": "none"})).is_err(), "{own}: another value is still checked");
// Only the exact value: one that merely rounds to it as f32 is refused.
let near = if own < 1.0 { f64::from(own) * (1.0 + 1e-9) } else { f64::from(own) + 0.001 };
assert_eq!(near as f32, own);
assert!(s.execute("image.imageSize", json!({"resolution": near, "resample": "none"})).is_err(), "{own}: {near}");
// keepPrintSize with the unchanged resolution keeps the pixels.
s.execute("image.imageSize", json!({"resolution": f64::from(own), "resample": "bicubic", "keepPrintSize": true})).unwrap();
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}: keepPrintSize");
}
}
/// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is
/// twice as tall (not 4x), with Resample off it stays, and a later re-render doesn't jump.
#[test]
@@ -825,6 +867,79 @@ fn crop_target_accepts_whole_number_floats() {
assert_eq!(doc(&s).size, Size::new(40, 20));
}
/// #9212: a non-deleting crop keeps the pixels outside the frame, and W x H resamples them
/// too. A 20 px frame of a 2000 px Background resampled to 2000 px scales its hidden pixels
/// x100, to 200000 px a side (160 GB at 8 bits, past the 8 GiB budget): refused before
/// allocating, like a target size past the budget.
#[test]
fn crop_target_budgets_the_hidden_pixels_it_resamples() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 2000, "height": 2000})).unwrap();
s.execute("paint.setSymmetry", json!({"mode": "vertical", "center": [500, 700]})).unwrap();
let symmetry = s.active().unwrap().symmetry.clone();
let past = s.active().unwrap().history.past_len();
let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "deleteCroppedPixels": false, "targetWidth": 2000, "targetHeight": 2000});
for angle in [None, Some(90)] {
let mut p = p.clone();
if let Some(a) = angle {
p["angle"] = json!(a);
}
let err = s.execute("image.crop", p).unwrap_err().to_string();
// Since the crop turns the Background into Layer 0 (#10867), the turned layer's
// bounds are 2001 px a side, resampled x100 to 200100.
let want = if angle.is_some() { "200100 x 200100 px is 40040 megapixels" } else { "200000 x 200000 px is 40000 megapixels" };
assert!(err.contains(want) && err.contains("Delete Cropped Pixels"), "{angle:?}: {err}");
assert_eq!(doc(&s).size, Size::new(2000, 2000), "{angle:?}: the document is unchanged");
assert_eq!(s.active().unwrap().history.past_len(), past, "{angle:?}: no history step");
// The turned crop rotates first: the painting symmetry turns back with the document.
assert_eq!(s.active().unwrap().symmetry, symmetry, "{angle:?}: the painting symmetry stays");
}
// Deleting the cropped pixels leaves only the frame to resample.
p["deleteCroppedPixels"] = json!(true);
s.execute("image.crop", p).unwrap();
assert_eq!(doc(&s).size, Size::new(2000, 2000));
assert_eq!(s.active().unwrap().history.past_len(), past + 1);
}
/// #9212: Image Size resamples the pixels outside the canvas as well. After a non-deleting
/// crop to 10 px, 20000 px (400 MP, within the budget) scales the hidden 100 px Background x2000.
#[test]
fn image_size_budgets_the_hidden_pixels_it_resamples() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap();
let past = s.active().unwrap().history.past_len();
let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string();
assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}");
// The hidden pixels are a pixel layer's: Delete Cropped Pixels would clear them.
assert!(err.contains("or delete them first (Crop with Delete Cropped Pixels)"), "{err}");
assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (Size::new(10, 10), past));
// Without resampling only the resolution changes.
s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap();
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(10, 10), 300.0));
}
/// Delete Cropped Pixels clips pixel layers only: an alpha channel keeps its pixels outside the
/// crop, so the refusal doesn't send the user to an option that can't clear it.
#[test]
fn hidden_resample_budget_names_what_delete_cropped_pixels_keeps() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
s.edit("channel", |doc, _| {
let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false);
let mut ch = Surface::new(fmt);
ch.write_region(Rect::new(0, 0, 100, 100), &[1.0; 100 * 100]);
doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch));
Ok(())
})
.unwrap();
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": true})).unwrap();
let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string();
assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}");
assert!(err.contains("channels") && !err.contains("delete them first"), "{err}");
assert_eq!(doc(&s).size, Size::new(10, 10));
}
#[test]
fn crop_target_rejects_bad_params_without_changing_the_document() {
let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10});
@@ -973,6 +1088,24 @@ fn duplicate_document() {
assert!(doc(&s).name.ends_with("copy"));
}
/// #9212: a bicubic shrink stores the image between its two passes with one axis at its old
/// size. A Background whose kept pixels reach (69999, 69999), halved from 200 to 100 px, ends at
/// about 35000 x 35000 px (4.9 GB, within the 8 GiB budget) but passes through about
/// 35000 x 70000 px (9.8 GB): refused before allocating. Bilinear has one pass and fits. The
/// check alone runs, so nothing that large is allocated either way.
#[cfg(target_pointer_width = "64")]
#[test]
fn hidden_resample_budget_counts_the_image_between_bicubic_passes() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
let mut d = doc(&s).clone();
let bg = d.background_mut().unwrap().surface_mut().unwrap();
bg.fill_rect(Rect::new(69_999, 69_999, 70_000, 70_000), &[1.0, 0.0, 0.0, 1.0]);
let err = check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bicubic).unwrap_err().to_string();
assert!(err.contains(" x 70000 px is "), "{err}");
check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bilinear).unwrap();
}
#[test]
fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() {
let mut s = Session::new();
+1 -1
View File
@@ -158,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> {
"layer.lockLayers",
"Lock Layers…",
&["Layer"],
None,
Some("Cmd+/"),
r##"{"transparency":bool?,"pixels":bool?,"position":bool?,"artboard":bool?,"all":bool?} (none given: toggle lock all)"##,
has_layer,
lock_layers
+1 -1
View File
@@ -566,7 +566,7 @@ fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit("Bitmap", |doc, active| {
if (nw, nh) != (doc.size.width, doc.size.height) {
crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic);
crate::image_cmds::resample_doc(doc, CMD, nw, nh, photocraft_algo::resample::Resample::Bicubic)?;
}
doc.resolution_dpi = dpi;
let w = doc.size.width as usize;
+12
View File
@@ -452,6 +452,18 @@ fn bitmap_output_resolution_resamples_and_sets_the_resolution() {
assert_eq!(first_white(doc(&s), 0, 1, 0), Some(128));
}
/// #9212: the Output resolution resamples the pixels outside the canvas too. A 10 px crop that
/// kept a 1000 px document's pixels, converted from 1 to 100 ppi, would grow them to 100000 px a
/// side (20 GB of gray and alpha at 8 bits, past the 8 GiB budget): refused before allocating.
#[test]
fn bitmap_output_resolution_budgets_the_hidden_pixels() {
let mut s = ramp(1000, 1000, 8, 1.0);
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap();
let err = s.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 100})).unwrap_err().to_string();
assert!(err.contains("100000 x 100000 px is 10000 megapixels"), "{err}");
assert_eq!((doc(&s).size.width, doc(&s).mode), (10, ColorMode::Grayscale));
}
#[test]
fn bitmap_halftone_screen_is_laid_out_at_the_output_resolution() {
// The same 10 lines/inch screen has twice the cell (in pixels) at twice the resolution: the
+24 -2
View File
@@ -163,6 +163,11 @@ fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result<Homography> {
/// scale/rotate/move, refused when the result would not fit the transform budget.
fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "select.transformSelection";
let step_name = match p.get("stepName") {
None | Some(Value::Null) => "Transform Selection",
Some(Value::String(n)) if n == "Move Selection" => "Move Selection",
Some(_) => return Err(bad(CMD, "`stepName` must be \"Move Selection\"")),
};
let d = s.active().ok_or(EngineError::NoDocument)?;
let sel = d.doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?;
let b = sel.content_bounds();
@@ -206,7 +211,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
if h.0.iter().zip(Homography::IDENTITY.0).all(|(a, b)| (a - b).abs() < 1e-12) {
return Ok(json!({"changed": false, "selected": true}));
}
let selected = s.edit("Transform Selection", |doc, _| {
let selected = s.edit(step_name, |doc, _| {
if let Some(sel) = &doc.selection {
crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection", sel.format())?;
doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty());
@@ -250,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Transform Selection",
menu: &["Select"],
shortcut: None,
params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##,
params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"stepName":"Move Selection"? (History step name for a plain move; the UI sends it for a drag of the outline; default "Transform Selection"; any other value is refused),"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##,
enabled: has_selection,
run: transform_selection,
journal: true,
@@ -422,6 +427,23 @@ mod tests {
assert!(s.execute("select.transformSelection", json!({"scaleX": 0})).is_err());
}
#[test]
fn transform_selection_step_name_is_restricted() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 40})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let last = |s: &Session| s.active().unwrap().history.entries().last().cloned().unwrap();
s.execute("select.transformSelection", json!({"dx": 5, "dy": 0})).unwrap();
assert_eq!(last(&s), "Transform Selection");
s.execute("select.transformSelection", json!({"dx": 5, "dy": 0, "stepName": "Move Selection"})).unwrap();
assert_eq!(last(&s), "Move Selection");
let n = s.active().unwrap().history.past_len();
for bad_name in [json!("Crop"), json!(""), json!(7)] {
assert!(s.execute("select.transformSelection", json!({"dx": 5, "dy": 0, "stepName": bad_name})).is_err());
}
assert_eq!(s.active().unwrap().history.past_len(), n);
}
#[test]
fn transform_selection_needs_a_selection() {
let mut s = Session::new();
+4 -2
View File
@@ -621,7 +621,9 @@ fn lasso(s: &mut Session, p: &Value) -> Result<Value> {
if pts.len() < 3 {
return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "needs at least 3 points".into() });
}
polygon_selection(s, "select.lasso", "Lasso", &pts, p)
// The History step is named after the tool: the Polygonal Lasso sends `polygonal` (Photoshop).
let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { "Lasso" };
polygon_selection(s, "select.lasso", step, &pts, p)
}
/// Selects the polygon `pts` with the lasso tools' `mode`, `antiAlias` and `feather` params, as
@@ -754,7 +756,7 @@ pub fn specs() -> Vec<CommandSpec> {
"select.lasso",
"Lasso",
[],
r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0}"##,
r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false}"##,
has_doc,
lasso
),
+51 -2
View File
@@ -101,6 +101,48 @@ fn has_linked(s: &Session) -> std::result::Result<(), String> {
_ => Err("the smart object is embedded".into()),
}
}
fn linked_layers(doc: &Document) -> Vec<LayerId> {
doc.walk()
.into_iter()
.filter(|(_, _, l)| matches!(&l.content, LayerContent::Smart(sm) if matches!(sm.source, SmartSource::Linked { .. })))
.map(|(_, _, l)| l.id)
.collect()
}
fn has_any_linked(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if linked_layers(&d.doc).is_empty() { Err("the document has no linked smart object".into()) } else { Ok(()) }
}
/// Embed All Linked: reads every linked file first, so a missing one refuses the whole step.
fn embed_all_linked(s: &mut Session, _p: &Value) -> Result<Value> {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let ids = linked_layers(doc);
if ids.is_empty() {
return Err(other("the document has no linked smart object"));
}
let mut sources = Vec::with_capacity(ids.len());
for id in &ids {
let src = &smart(doc, *id)?.source;
let (file_name, bytes) = source_bytes(&doc.metadata, src).ok_or_else(|| {
let path = match src {
SmartSource::Linked { path } => path.as_str(),
SmartSource::Embedded { .. } => "",
};
other(format!("the linked file can't be read: {path}"))
})?;
sources.push(SmartSource::Embedded { file_name, bytes });
}
let parent = doc.id;
let contents: Vec<SmartContentsId> = ids.iter().map(|id| smart(doc, *id).map(|sm| sm.contents_id)).collect::<Result<_>>()?;
s.edit("Embed All Linked", |doc, _| {
for (id, src) in ids.iter().zip(sources) {
smart_mut(doc, *id)?.source = src;
refresh_or_fail(doc, *id)?;
}
Ok(())
})?;
s.smart_links.retain(|l| l.parent != parent || !contents.contains(&l.contents_id));
Ok(json!({"embedded": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
@@ -146,7 +188,6 @@ mod source;
pub(crate) use contents::on_close;
pub use contents::{commit_child, contents_of, replace_contents_bytes, replace_contents_from_bytes};
use contents::{edit_contents, export_contents, is_smart_child, path_param, replace_contents, set_source, update_all};
#[cfg(test)]
use filters::smart_mut;
pub(crate) use filters::{add_smart_filter, add_smart_filter_in, filter_mask_paintable, filter_mask_surface, refresh_after_filter_mask_edit};
use filters::{blending_options, delete_filter, edit_filters, move_filter, set_filter_params, set_filter_visible, smart, transfer_filters};
@@ -244,12 +285,20 @@ pub fn specs() -> Vec<CommandSpec> {
has_smart,
unpack::convert_to_layers
),
spec!("layer.smartObjects.convertToEmbedded", "Convert to Embedded", SO, r##"{"layer":id?}"##, has_linked, |s, p| {
spec!("layer.smartObjects.convertToEmbedded", "Embed Linked", SO, r##"{"layer":id?}"##, has_linked, |s, p| {
set_source(s, p, "Convert to Embedded", true, |meta, src| {
let (file_name, bytes) = source_bytes(meta, src).ok_or_else(|| other("the linked file can't be read"))?;
Ok(SmartSource::Embedded { file_name, bytes })
})
}),
spec!(
"layer.smartObjects.embedAllLinked",
"Embed All Linked",
SO,
"{} (every linked smart object of the active document becomes embedded in one history step; a linked file that can't be read refuses the whole step and the error names it) -> {embedded: [layer ids]}",
has_any_linked,
embed_all_linked
),
spec!(
"layer.smartObjects.convertToLinked",
"Convert to Linked…",
+12 -2
View File
@@ -17,6 +17,9 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> {
s.active().and_then(|d| d.active_layer).map(|_| ()).ok_or_else(|| "no active layer".into())
}
/// The History step name of a shape-tool bounding box scale (`edit.transform` `stepName`).
pub const TRANSFORM_PATH: &str = "Transform Path";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: "edit.transform".into(), msg: msg.into() }
}
@@ -841,6 +844,13 @@ fn with_panel_target(s: &Session, p: &Value) -> Value {
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
// The shape tools' bounding box scales live shapes with this command and names the step as
// the reference does. Passed in, not renamed after: the edit may land later as a job.
let step_name = match p.get("stepName") {
None | Some(Value::Null) => "Free Transform",
Some(Value::String(n)) if n == TRANSFORM_PATH => TRANSFORM_PATH,
Some(_) => return Err(bad("`stepName` must be \"Transform Path\"")),
};
let p = &with_panel_target(s, p);
let st = s.active().ok_or(EngineError::NoDocument)?;
let many = layers_param(&st.doc, p)?;
@@ -920,7 +930,7 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
// on a worker with progress, and can be cancelled.
crate::jobs::edit_job(
s,
"Free Transform",
step_name,
move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &ids, &p, lone, &h, affine, interp, ctl)),
move |()| json!({"rect": rect, "layers": layers}),
)
@@ -1006,7 +1016,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Free Transform",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
enabled: has_layer,
journal: true,
run: transform,
@@ -970,3 +970,24 @@ fn split_selected_lifts_up_to_the_selection_and_leaves_the_rest() {
assert!((l + r - a).abs() < 1e-6, "k = {k}: lifted {l} + left {r} != {a}");
}
}
/// `stepName` names the step "Transform Path" (the shape tools' bounding box); without it the
/// step stays "Free Transform"; any other name is refused and changes nothing.
#[test]
fn step_name_transform_path_is_the_only_name_taken() {
let mut s = session();
let p = json!({"scaleX": 50, "scaleY": 50});
s.execute("edit.transform", p.clone()).unwrap();
assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Free Transform"));
let mut named = p.clone();
named["stepName"] = json!("Transform Path");
s.execute("edit.transform", named).unwrap();
assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Transform Path"));
let steps = s.active().unwrap().history.past_len();
for bad_name in [json!("Free Transform"), json!("Move"), json!(""), json!(3), json!(["Transform Path"])] {
let mut wrong = p.clone();
wrong["stepName"] = bad_name.clone();
assert!(s.execute("edit.transform", wrong).is_err(), "{bad_name}");
}
assert_eq!(s.active().unwrap().history.past_len(), steps, "a refused name changes nothing");
}
+2 -2
View File
@@ -315,13 +315,13 @@ pub fn specs() -> Vec<CommandSpec> {
"subtract",
"Subtract Front Shape"
)),
spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(
spec!("layer.combineShapes.intersectShapeAreas", "Unite Shapes at Overlap", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(
s,
p,
"intersect",
"Intersect Shape Areas"
)),
spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], COMBINE_PARAMS, has_layer, |s, p| {
spec!("layer.combineShapes.excludeOverlappingShapes", "Subtract Shapes at Overlap", [], COMBINE_PARAMS, has_layer, |s, p| {
combine_shapes(s, p, "exclude", "Exclude Overlapping Shapes")
}),
spec!(
+14 -2
View File
@@ -110,6 +110,15 @@ fn blank_of(fmt: photocraft_color::PixelFormat) -> Surface {
Surface::new(fmt)
}
/// Both the altered and original footage stacks must have room for an added frame.
fn check_frame_growth(frames: usize, original_frames: Option<usize>) -> Result<()> {
let limit = crate::timeline_cmds::MAX_DURATION;
if frames.max(original_frames.unwrap_or(0)) >= limit {
return Err(EngineError::Other(format!("a video layer is limited to {limit} frames")));
}
Ok(())
}
fn new_blank(s: &mut Session, _p: &Value) -> Result<Value> {
let id = s.edit("New Blank Video Layer", |doc, active| {
store(doc);
@@ -138,7 +147,8 @@ fn new_blank(s: &mut Session, _p: &Value) -> Result<Value> {
fn insert_blank(s: &mut Session, _p: &Value) -> Result<Value> {
let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format();
edit_video(s, "Insert Blank Frame", move |v, cur| {
let at = (cur + 1).min(v.frames.len());
check_frame_growth(v.frames.len(), v.original_frames.as_ref().map(Vec::len))?;
let at = cur.saturating_add(1).min(v.frames.len());
v.frames.insert(at, blank_of(fmt));
if let Some(original) = &mut v.original_frames {
// The stacks stay aligned, but never index past a shorter one.
@@ -150,7 +160,9 @@ fn insert_blank(s: &mut Session, _p: &Value) -> Result<Value> {
fn duplicate_frame(s: &mut Session, _p: &Value) -> Result<Value> {
edit_video(s, "Duplicate Frame", |v, cur| {
if let Some(f) = v.frames.get(cur).cloned() {
if let Some(f) = v.frames.get(cur) {
check_frame_growth(v.frames.len(), v.original_frames.as_ref().map(Vec::len))?;
let f = f.clone();
if let Some(original) = &mut v.original_frames {
let orig = original.get(cur).cloned().unwrap_or_else(|| blank_of(f.format()));
original.insert((cur + 1).min(original.len()), orig);
+11
View File
@@ -29,6 +29,17 @@ fn new_blank_insert_duplicate_delete_frames() {
assert!(s.active().unwrap().doc.timeline.as_ref().unwrap().duration >= frames(&s, id));
}
/// Check the shared growth limit without constructing a million-frame document.
#[test]
fn frame_growth_stops_at_the_limit_of_either_stack() {
let limit = crate::timeline_cmds::MAX_DURATION;
assert!(check_frame_growth(limit - 1, None).is_ok());
assert!(check_frame_growth(limit - 1, Some(limit - 1)).is_ok());
assert!(check_frame_growth(limit, None).is_err());
assert!(check_frame_growth(limit - 1, Some(limit)).is_err());
assert!(check_frame_growth(limit + 1, None).is_err());
}
#[test]
fn rasterize_drops_the_stack() {
let mut s = session();
+1 -1
View File
@@ -664,7 +664,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec {
id: "layer.smartObjects.warp",
label: "Warp",
menu: &["Layer", "Smart Objects"],
menu: &[],
shortcut: None,
params: P,
enabled: active_smart,
+138 -46
View File
@@ -3,9 +3,10 @@
//! strips an extension adds) is filled from its surroundings instead of holding the background
//! colour or transparency.
//!
//! Which layer: the Background when the document has one (the layer the crop filled with the
//! background colour), otherwise the active pixel layer (where the crop left transparency). Other
//! layers keep their transparency there, as after a plain crop. Each connected piece of the
//! Which layers: the Background and the active pixel layer, each sampled independently. Unmeasured
//! cases (Delete Cropped Pixels off, or a hidden, locked or nonpixel active layer) retain the
//! Background-else-active target. Other layers keep their transparency, as after a plain crop.
//! Each connected piece of the
//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling
//! window around that piece, and only pixels the image covers are sampled.
//!
@@ -27,6 +28,9 @@ const SEED: u64 = 1;
/// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap.
const MAX_PIXELS: u64 = 400_000_000;
/// Limit for the dense native-float input read when reaching beyond the normal sampling window.
const MAX_WIDENED_SAMPLE_BYTES: usize = 512 << 20;
/// Where the cropped canvas came from in the original document.
#[derive(Clone, Debug)]
pub(crate) struct Coverage {
@@ -38,10 +42,10 @@ pub(crate) struct Coverage {
pub height: f64,
/// Whether the map rotates (its resampled edge is antialiased against the background colour).
pub rotated: bool,
/// The filled layer's pixels before a turned crop (original document px), set by
/// Each candidate target's pixels before a turned crop (original document px), set by
/// [`Coverage::holding`]: where they lie past the old canvas they are hidden image, not
/// uncovered area. A straight crop has none: Photoshop fills over hidden pixels there.
pub held: Option<Surface>,
pub held: Option<Vec<(LayerId, Surface)>>,
}
impl Coverage {
@@ -53,7 +57,14 @@ impl Coverage {
/// Takes the pixels of the layer the fill will go to from `doc` before a turned crop (the
/// surface is shared, not copied), so hidden pixels past the old canvas count as covered.
pub fn holding(mut self, doc: &Document, active: Option<LayerId>) -> Self {
self.held = target_layer(doc, active).ok().and_then(|id| doc.layer(id)).and_then(|l| l.surface()).cloned();
self.held = Some(
target_layer(doc, active)
.ok()
.into_iter()
.chain(additional_target(doc, active))
.filter_map(|id| doc.layer(id).and_then(|l| l.surface()).map(|surface| (id, surface.clone())))
.collect(),
);
self
}
@@ -61,19 +72,19 @@ impl Coverage {
/// the original canvas by at least half the pixel's projected size (exact for an unrotated
/// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling
/// blended with the background colour.
fn covered(&self, u: i32, v: i32, margin: f64) -> bool {
fn covered(&self, u: i32, v: i32, margin: f64, held: Option<&Surface>) -> bool {
let [a, b, c, d, e, f] = self.map;
let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5);
let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f);
(x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || self.holds(x, y)
(x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || Self::holds(held, x, y)
}
/// Whether the layer held an image pixel at original document point (x, y) past the canvas
/// (#8060): mostly opaque there and one pixel around it too (the turned edge is blended with
/// the background colour, as at the canvas edge). Without an alpha channel
/// hidden pixels can't be told from empty ones: nothing is held.
fn holds(&self, x: f64, y: f64) -> bool {
let Some(surf) = &self.held else { return false };
fn holds(held: Option<&Surface>, x: f64, y: f64) -> bool {
let Some(surf) = held else { return false };
let format = surf.format();
if !(format.alpha && x.is_finite() && y.is_finite()) {
return false;
@@ -100,8 +111,20 @@ const UNLABELLED: u32 = u32::MAX;
/// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the
/// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the
/// caller's history step.
pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &Coverage) -> Result<()> {
/// caller's history step. Returns whether any uncovered pixels were filled, for the history name.
pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &Coverage, delete: bool) -> Result<bool> {
let first = target_layer(doc, active);
let additional = if delete { additional_target(doc, active) } else { None };
let mut filled = fill_layer(doc, first, cov, false)?;
if let Some(id) = additional {
filled |= fill_layer(doc, Ok(id), cov, true)?;
}
Ok(filled)
}
/// One target at a time keeps the label/window allocations bounded to one canvas. The caller's
/// existing edit/compound transaction rolls every target back if a later fill fails.
fn fill_layer(doc: &mut Document, id: Result<LayerId>, cov: &Coverage, own_pixels: bool) -> Result<bool> {
let canvas = doc.bounds();
let (w, h) = (canvas.width(), canvas.height());
if u64::from(w) * u64::from(h) > MAX_PIXELS {
@@ -110,62 +133,131 @@ pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &
let (w, h) = (w as usize, h as usize);
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?;
if len == 0 {
return Ok(());
return Ok(false);
}
let margin = cov.margin();
let held = id.as_ref().ok().and_then(|id| cov.held.as_ref()?.iter().find(|(held_id, _)| held_id == id).map(|(_, surface)| surface));
let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?;
labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
for (x, l) in row.iter_mut().enumerate() {
if !cov.covered(x as i32, y as i32, margin) {
if !cov.covered(x as i32, y as i32, margin, held) {
*l = UNLABELLED;
}
}
});
if !labels.contains(&UNLABELLED) {
return Ok(());
return Ok(false);
}
if !labels.contains(&0) {
return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into()));
return if own_pixels {
Ok(false)
} else {
Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into()))
};
}
let id = target_layer(doc, active)?;
let pieces = label_components(&mut labels, w, h)?;
// Resolve an absent target only after the no-fill/sample checks, preserving the legacy path.
let id = id?;
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
let n = surf.format().channels().max(1);
let has_alpha = surf.format().alpha;
let has_pixel = |x, y| !has_alpha || surf.sample_channel(x, y, n - 1) > 0.0;
let donor_bounds = own_pixels.then(|| if has_alpha { surf.content_bounds().intersect(&canvas) } else { canvas });
// The added target is optional when it has no covered pixels of its own to sample.
// This check does not absorb errors from synthesis or allocation.
if donor_bounds.is_some_and(|bounds| {
!contains_donor(bounds, |x, y| {
let at = usize::try_from(y).ok().and_then(|y| y.checked_mul(w)).zip(usize::try_from(x).ok()).and_then(|(r, x)| r.checked_add(x));
at.and_then(|i| labels.get(i)) == Some(&0) && has_pixel(x, y)
})
}) {
return Ok(false);
}
let pieces = label_components(&mut labels, w, h)?;
let label = |x: i32, y: i32| -> u32 {
if x < 0 || y < 0 {
return 0;
}
(y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0)
};
let n = surf.format().channels().max(1);
let filled: Vec<(Rect, Vec<f32>)> = pieces
.par_iter()
.enumerate()
.map(|(k, bounds)| -> Result<(Rect, Vec<f32>)> {
let k = k as u32 + 1;
let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?;
// The piece's bounds, its pixels from the fill and the rest as they were.
let mut out = surf.read_region(*bounds);
let ww = window.width() as usize;
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
if label(x, y) != k || !window.contains(x, y) {
continue;
}
let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n;
let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n;
if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) {
d.copy_from_slice(s);
}
let fill_piece = |(k, bounds): (usize, &Rect)| -> Result<(Rect, Vec<f32>)> {
let k = k as u32 + 1;
// A partial active layer can have no donor near a distant turned corner. Only that
// added target may widen its sampling envelope; its hole and output stay unchanged.
let usable = |x, y| label(x, y) == 0 && (!own_pixels || has_pixel(x, y));
let sampling_bounds = if let Some(donor_bounds) = donor_bounds {
let window = crate::fill_cmds::sampling_window(*bounds, canvas).intersect(&donor_bounds);
if contains_donor(window, usable) { *bounds } else { widened_sampling_bounds(*bounds, donor_bounds, canvas, n)? }
} else {
*bounds
};
let (window, px) = crate::fill_cmds::content_aware_native(
surf,
sampling_bounds,
canvas,
true,
SEED,
|x, y| label(x, y) == k,
// A partial active layer (EdgeHalf in #10868) must extend its own image, not
// sample its empty canvas rows into the new strip. Keep legacy sampling intact.
usable,
)?;
// The piece's bounds, its pixels from the fill and the rest as they were.
let mut out = crate::allocation::read_region(surf, *bounds, "Content-Aware crop output")?;
let ww = window.width() as usize;
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
if label(x, y) != k || !window.contains(x, y) {
continue;
}
let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n;
let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n;
if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) {
d.copy_from_slice(s);
}
}
Ok((*bounds, out))
})
.collect::<Result<_>>()?;
}
Ok((*bounds, out))
};
let filled: Vec<(Rect, Vec<f32>)> = if own_pixels {
// A fallback window can span the canvas: keep only one added-target window live.
pieces.iter().enumerate().map(fill_piece).collect::<Result<_>>()?
} else {
pieces.par_iter().enumerate().map(fill_piece).collect::<Result<_>>()?
};
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
for (r, px) in filled {
surf.write_region(r, &px);
}
Ok(())
Ok(true)
}
/// Checks the candidate rectangle for a covered pixel that passes the target's donor policy.
fn contains_donor(bounds: Rect, usable: impl Fn(i32, i32) -> bool) -> bool {
(bounds.y0..bounds.y1).any(|y| (bounds.x0..bounds.x1).any(|x| usable(x, y)))
}
/// Widens the sampling envelope to include distant donors, after pricing the actual float read.
fn widened_sampling_bounds(piece: Rect, donors: Rect, canvas: Rect, channels: usize) -> Result<Rect> {
let expanded = piece.union(&donors);
let window = crate::fill_cmds::sampling_window(expanded, canvas);
let bytes = (window.width() as usize)
.checked_mul(window.height() as usize)
.and_then(|pixels| pixels.checked_mul(channels))
.and_then(|samples| samples.checked_mul(std::mem::size_of::<f32>()));
if bytes.is_none_or(|bytes| bytes > MAX_WIDENED_SAMPLE_BYTES) {
return Err(EngineError::Other("the expanded Content-Aware crop sampling window is too large".into()));
}
Ok(expanded)
}
/// Only the measured, editable active-raster case adds a target to the Background. Ancestor
/// visibility and locks count too; all unmeasured cases keep the original target selection.
fn additional_target(doc: &Document, active: Option<LayerId>) -> Option<LayerId> {
let background = doc.background()?.id;
let id = active.filter(|id| *id != background)?;
let path = doc.path_of(id)?;
let layer = doc.layer_at(&path)?;
(matches!(layer.content, photocraft_doc::LayerContent::Raster(_)) && doc.shown_at(&path) && !doc.locks_at(&path).any()).then_some(id)
}
/// The Background, else the active pixel layer.
@@ -242,9 +334,9 @@ mod tests {
fn unrotated_coverage_is_exact() {
let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4);
let m = c.margin();
assert!(!c.covered(1, 0, m), "x = -1");
assert!(c.covered(2, 0, m), "x = 0");
assert!(c.covered(6, 0, m), "x = 4");
assert!(!c.covered(7, 0, m), "x = 5");
assert!(!c.covered(1, 0, m, None), "x = -1");
assert!(c.covered(2, 0, m, None), "x = 0");
assert!(c.covered(6, 0, m, None), "x = 4");
assert!(!c.covered(7, 0, m, None), "x = 5");
}
}
+6 -1
View File
@@ -48,11 +48,15 @@ pub struct DocState {
/// to `.pcraft` or PSD, and not a history step.
pub color_samplers: Vec<[f64; 2]>,
/// History panel snapshots, oldest first (`history.newSnapshot`, #2966): the first (id 0) is
/// the document as opened. Kept for the session only, as in Photoshop: not history states,
/// the document as opened (unless History Options › Automatically Create First Snapshot is
/// off). Kept for the session only, as in Photoshop: not history states,
/// and not saved with the document.
pub snapshots: Vec<history_cmds::Snapshot>,
/// The id the next snapshot gets; ids are never reused, so deleting one keeps the others.
pub(crate) next_snapshot: u64,
/// The snapshot the latest save added (History Options › Automatically Create New Snapshot
/// When Saving), so saving an unchanged document again doesn't add a duplicate row.
pub(crate) save_snapshot: Option<u64>,
/// The snapshot set as the History Brush's source (`history.setSource {snapshot}`); `None`:
/// the source is a state, kept by the history ([`photocraft_ops::History::brush_source`]).
pub(crate) source_snapshot: Option<u64>,
@@ -114,6 +118,7 @@ impl DocState {
color_samplers: Vec::new(),
snapshots: vec![first],
next_snapshot: 1,
save_snapshot: None,
source_snapshot: None,
typed_names: Default::default(),
}
+144
View File
@@ -0,0 +1,144 @@
//! Layer > Smart Objects > Embed Linked and Embed All Linked (#11401).
use photocraft_doc::{LayerContent, SmartSource};
use serde_json::json;
use crate::Session;
use crate::cmd::command_specs;
fn label(id: &str) -> &'static str {
command_specs().iter().find(|c| c.id == id).map(|c| c.label).unwrap_or_else(|| panic!("{id} is not registered"))
}
/// Adds a layer with a few painted pixels (an empty layer can't become a smart object).
fn painted_layer(s: &mut Session, red: f32) {
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("setup", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
surf.fill_rect(photocraft_geom::Rect::new(2, 2, 10, 10), &[red, 1.0 - red, 0.0, 1.0]);
Ok(())
})
.unwrap();
}
/// Three smart objects: the first two linked to files in `dir`, the third embedded.
fn three(dir: &std::path::Path) -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 24})).unwrap();
let mut ids = [0u64; 3];
for (i, slot) in ids.iter_mut().enumerate() {
painted_layer(&mut s, 0.25 * (i + 1) as f32);
*slot = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap();
if i < 2 {
let path = dir.join(format!("linked{i}.pcraft"));
s.execute("layer.smartObjects.convertToLinked", json!({"path": path.to_str().unwrap()})).unwrap();
}
}
(s, ids)
}
fn is_linked(s: &Session, id: u64) -> bool {
let doc = &s.active().unwrap().doc;
let layer = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.id.0 == id).unwrap();
match &layer.content {
LayerContent::Smart(sm) => matches!(sm.source, SmartSource::Linked { .. }),
other => panic!("not smart: {}", other.kind_name()),
}
}
/// The file name and bytes an embedded smart object carries.
fn embedded(s: &Session, id: u64) -> (String, Vec<u8>) {
let doc = &s.active().unwrap().doc;
let layer = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.id.0 == id).unwrap();
match &layer.content {
LayerContent::Smart(sm) => match &sm.source {
SmartSource::Embedded { file_name, bytes } => (file_name.clone(), bytes.to_vec()),
SmartSource::Linked { .. } => panic!("still linked"),
},
other => panic!("not smart: {}", other.kind_name()),
}
}
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pc-embed-all-{name}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn embed_linked_and_embed_all_linked_are_named_in_the_engine() {
assert_eq!(label("layer.smartObjects.convertToEmbedded"), "Embed Linked");
assert_eq!(label("layer.smartObjects.embedAllLinked"), "Embed All Linked");
// The warp commands stay registered for scripts and the Edit menu routes.
for id in ["layer.smartObjects.warp", "layer.smartObjects.perspectiveWarp", "layer.smartObjects.puppetWarp"] {
label(id);
}
}
#[test]
fn embed_all_linked_embeds_both_linked_objects_in_one_undoable_step() {
let dir = temp_dir("both");
let (mut s, ids) = three(&dir);
assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]) && !is_linked(&s, ids[2]));
assert!(s.is_enabled("layer.smartObjects.embedAllLinked"));
let before = s.active().unwrap().history.entries().len();
let files: Vec<Vec<u8>> = (0..2).map(|i| photocraft_format::read_file(&dir.join(format!("linked{i}.pcraft"))).unwrap()).collect();
assert_ne!(files[0], files[1], "the fixtures differ");
let third = embedded(&s, ids[2]);
let r = s.execute("layer.smartObjects.embedAllLinked", json!({})).unwrap();
assert_eq!(r["embedded"], json!([ids[0], ids[1]]));
assert!(ids.iter().all(|&id| !is_linked(&s, id)));
for (i, file) in files.iter().enumerate() {
let (name, bytes) = embedded(&s, ids[i]);
assert_eq!(name, format!("linked{i}.pcraft"), "object {i} keeps its own file name");
assert_eq!(&bytes, file, "object {i} carries its own linked file's bytes");
}
assert_eq!(embedded(&s, ids[2]), third, "the already embedded object is untouched");
let entries = s.active().unwrap().history.entries();
assert_eq!(entries.len(), before + 1, "one history step");
assert_eq!(entries.last().map(String::as_str), Some("Embed All Linked"));
assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "nothing left to embed");
s.execute("edit.undo", json!({})).unwrap();
assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]) && !is_linked(&s, ids[2]));
assert_eq!(s.active().unwrap().history.entries().len(), before);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn embed_all_linked_with_no_linked_object_is_an_error_not_a_panic() {
let mut s = Session::new();
assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "no document");
assert!(s.execute("layer.smartObjects.embedAllLinked", json!({})).is_err());
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
painted_layer(&mut s, 1.0);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "only embedded objects");
let before = s.active().unwrap().history.entries().len();
assert!(s.execute("layer.smartObjects.embedAllLinked", json!({})).is_err());
assert_eq!(s.active().unwrap().history.entries().len(), before);
}
#[test]
fn embed_all_linked_refuses_the_whole_step_when_a_linked_file_is_missing() {
let dir = temp_dir("missing");
let (mut s, ids) = three(&dir);
std::fs::remove_file(dir.join("linked1.pcraft")).unwrap();
let before = s.active().unwrap().history.entries().len();
let err = s.execute("layer.smartObjects.embedAllLinked", json!({})).unwrap_err().to_string();
assert!(err.contains("linked1.pcraft"), "the error names the missing file: {err}");
assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]), "nothing was embedded");
assert_eq!(s.active().unwrap().history.entries().len(), before);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn embed_linked_still_embeds_one_object_under_its_old_id() {
let dir = temp_dir("one");
let (mut s, ids) = three(&dir);
s.execute("layer.smartObjects.convertToEmbedded", json!({"layer": ids[0]})).unwrap();
assert!(!is_linked(&s, ids[0]) && is_linked(&s, ids[1]));
std::fs::remove_dir_all(&dir).ok();
}
+4
View File
@@ -332,11 +332,15 @@ pub struct Session {
pub damage_misses: usize,
}
#[cfg(test)]
mod embed_all_linked_tests;
#[cfg(test)]
mod fill_layer_mode_tests;
#[cfg(test)]
mod pattern_mode_tests;
#[cfg(test)]
mod second_keys_zoom_actions_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
+18 -3
View File
@@ -1119,21 +1119,36 @@ impl Actions {
}
}
/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either).
/// Photoshop's snapshot options come with #3634.
/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either),
/// in the dialog's order.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct HistoryOptions {
/// Automatically Create First Snapshot: a new or opened document starts with a snapshot of
/// itself, named after it (the History panel's top row).
pub automatically_create_first_snapshot: bool,
/// Automatically Create New Snapshot When Saving: every save of the document (not a copy)
/// adds a snapshot named with the time of the save.
pub automatically_create_new_snapshot_when_saving: bool,
/// Allow Non-Linear History: editing after selecting an earlier state keeps the later
/// states (the new step is appended at the end), and deleting a state deletes only it.
pub allow_non_linear_history: bool,
/// Show New Snapshot Dialog by Default: a plain click on the History panel's camera button
/// opens New Snapshot (otherwise only ⌥-click does).
pub show_new_snapshot_dialog_by_default: bool,
/// Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state.
pub make_layer_visibility_changes_undoable: bool,
}
impl Default for HistoryOptions {
fn default() -> Self {
Self { allow_non_linear_history: false, make_layer_visibility_changes_undoable: true }
Self {
automatically_create_first_snapshot: true,
automatically_create_new_snapshot_when_saving: false,
allow_non_linear_history: false,
show_new_snapshot_dialog_by_default: false,
make_layer_visibility_changes_undoable: true,
}
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
section!("edit.preferences.transparencyAndGamut", "Transparency & Gamut…"),
section!("edit.preferences.unitsAndRulers", "Units & Rulers…"),
section!("edit.preferences.guidesGridAndSlices", "Guides, Grid & Slices…"),
section!("edit.preferences.plugIns", "Plug-ins…"),
section!("edit.preferences.plugIns", "Plugins…"),
section!("edit.preferences.type", "Type…"),
section!("edit.preferences.enhancedControls", "Enhanced Controls…"),
section!("edit.preferences.rawDefaults", "Camera Raw…"),
+4
View File
@@ -124,6 +124,8 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[
/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace
/// (Shift+Delete on a Mac) for Fill; ⌘+ beside ⌘= for Zoom In; and off the Mac, Ctrl+F4 beside
/// Ctrl+W for Close (Windows' close-document key; macOS keeps Ctrl+F4 for window focus).
/// Also Alt+Ctrl+0 beside Ctrl+1 for 100% and F9 beside Alt+F9 for Window > Actions (#11396;
/// measured on Windows, so the Mac gets the usual Cmd mapping of the first).
pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
("edit.cut", "F2"),
("edit.copy", "F3"),
@@ -131,6 +133,8 @@ pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
("edit.fill", "Shift+Backspace"),
("select.inverse", "Shift+F7"),
("view.zoomIn", "Cmd++"),
("view.actualPixels", "Cmd+Alt+0"),
("window.panel.actions", "F9"),
#[cfg(not(target_os = "macos"))]
("file.close", "Cmd+F4"),
];
+1 -1
View File
@@ -21,7 +21,7 @@ pub const SECTIONS: [(&str, &str); 18] = [
("transparencyAndGamut", "Transparency & Gamut"),
("unitsAndRulers", "Units & Rulers"),
("guidesGridAndSlices", "Guides, Grid & Slices"),
("plugIns", "Plug-ins"),
("plugIns", "Plugins"),
("type", "Type"),
("enhancedControls", "Enhanced Controls"),
("rawDefaults", "Camera Raw Defaults"),
+2 -2
View File
@@ -305,8 +305,8 @@ pub fn specs() -> Vec<CommandSpec> {
kind_spec!("view.proofSetup.workingMagentaPlate", "Working Magenta Plate"),
kind_spec!("view.proofSetup.workingYellowPlate", "Working Yellow Plate"),
kind_spec!("view.proofSetup.workingBlackPlate", "Working Black Plate"),
kind_spec!("view.proofSetup.workingCmyPlate", "Working CMY Plate"),
kind_spec!("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB"),
kind_spec!("view.proofSetup.workingCmyPlate", "Working CMY Plates"),
kind_spec!("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB (Gamma 1.8)"),
kind_spec!("view.proofSetup.colorBlindnessProtanopia", "Color Blindness — Protanopia-type"),
kind_spec!("view.proofSetup.colorBlindnessDeuteranopia", "Color Blindness — Deuteranopia-type"),
CommandSpec {
@@ -0,0 +1,95 @@
//! The reference application's second default keys for View > 100% and Window > Actions (#11396):
//! Alt+Ctrl+0 beside Ctrl+1, and F9 beside Alt+F9.
//!
//! The engine owns the key tables and the Keyboard Shortcuts command. The zoom itself and the
//! Actions panel are UI state (`window.panel.actions` is a menu item the engine does not run), so
//! these tests check that each key resolves to its command exactly as the main key does and
//! that the user can remove it. They do not exercise key dispatch; `alternate_keys_run_their_command`
//! in the UI crate's `shortcut_dispatch` tests does, and lists both keys.
use crate::Session;
use crate::prefs::{ALTERNATE_SHORTCUTS, default_alternates, normalize_shortcut};
use serde_json::json;
/// The menus' main keys (`menu_catalog.rs`): what View > 100% and Window > Actions show.
const ACTUAL_PIXELS_MAIN: &str = "Cmd+1";
const ACTIONS_MAIN: &str = "Alt+F9";
/// Our notation: `Cmd` is Ctrl off the Mac, so this is Ctrl+Alt+0 on Windows and Linux.
const ACTUAL_PIXELS_ALT: &str = "Cmd+Alt+0";
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
s
}
/// The command a key press runs among the engine's bindable commands and the two this issue
/// adds keys to: a command's main key (given for the UI-only one), else its alternates in effect.
fn resolve(s: &Session, key: &str) -> Vec<&'static str> {
let want = normalize_shortcut(key).unwrap();
let mains = [("view.actualPixels", Some(ACTUAL_PIXELS_MAIN)), ("window.panel.actions", Some(ACTIONS_MAIN))];
let mut ids: Vec<&'static str> = Vec::new();
for (id, main) in mains {
let main_in_effect = s.prefs().shortcut(id, main);
let is_main = main_in_effect.and_then(normalize_shortcut).as_ref() == Some(&want);
if is_main || s.prefs().alternates(id, main_in_effect).contains(&want) {
ids.push(id);
}
}
ids
}
#[test]
fn both_keys_are_default_alternates() {
assert!(ALTERNATE_SHORTCUTS.contains(&("view.actualPixels", "Cmd+Alt+0")));
assert!(ALTERNATE_SHORTCUTS.contains(&("window.panel.actions", "F9")));
assert_eq!(default_alternates("view.actualPixels").collect::<Vec<_>>(), ["Cmd+Alt+0"]);
assert_eq!(default_alternates("window.panel.actions").collect::<Vec<_>>(), ["F9"]);
let s = session();
let zoom = s.prefs().alternates("view.actualPixels", Some(ACTUAL_PIXELS_MAIN));
assert_eq!(zoom, [normalize_shortcut(ACTUAL_PIXELS_ALT).unwrap()]);
assert_eq!(s.prefs().alternates("window.panel.actions", Some(ACTIONS_MAIN)), ["F9"]);
}
#[test]
fn each_key_resolves_to_its_command_like_the_main_key() {
let s = session();
assert_eq!(resolve(&s, ACTUAL_PIXELS_ALT), ["view.actualPixels"]);
assert_eq!(resolve(&s, ACTUAL_PIXELS_MAIN), ["view.actualPixels"]);
assert_eq!(resolve(&s, "F9"), ["window.panel.actions"]);
assert_eq!(resolve(&s, ACTIONS_MAIN), ["window.panel.actions"]);
}
#[test]
fn the_menu_shows_only_the_main_key() {
// The menus print the catalogue's key, and an alternate is never the main key in effect.
let s = session();
assert_eq!(s.prefs().shortcut("view.actualPixels", Some(ACTUAL_PIXELS_MAIN)), Some(ACTUAL_PIXELS_MAIN));
assert_eq!(s.prefs().shortcut("window.panel.actions", Some(ACTIONS_MAIN)), Some(ACTIONS_MAIN));
assert_eq!(crate::shell_commands::SHELL_COMMANDS.iter().find(|c| c.0 == "view.actualPixels").and_then(|c| c.3), Some(ACTUAL_PIXELS_MAIN));
}
#[test]
fn keyboard_shortcuts_lists_the_second_key_and_removing_it_stops_it() {
let mut s = session();
let r = s.execute("edit.keyboardShortcuts", json!({"filter": "view.actualPixels"})).unwrap();
let row = r["commands"].as_array().unwrap().iter().find(|c| c["id"] == "view.actualPixels").unwrap().clone();
assert_eq!(row["shortcut"], ACTUAL_PIXELS_MAIN);
assert_eq!(row["alternates"], json!([normalize_shortcut(ACTUAL_PIXELS_ALT).unwrap()]));
assert_eq!(row["defaultAlternates"], json!(["Cmd+Alt+0"]));
s.execute("edit.keyboardShortcuts", json!({"alternates": {"view.actualPixels": []}})).unwrap();
assert!(resolve(&s, ACTUAL_PIXELS_ALT).is_empty());
assert_eq!(resolve(&s, ACTUAL_PIXELS_MAIN), ["view.actualPixels"], "the main key still works");
// Window > Actions is a menu item, so it is not in the engine's list, but its second key can
// still be set and removed, as the Keyboard Shortcuts dialog does.
s.execute("edit.keyboardShortcuts", json!({"alternates": {"window.panel.actions": []}})).unwrap();
assert!(resolve(&s, "F9").is_empty());
assert_eq!(resolve(&s, ACTIONS_MAIN), ["window.panel.actions"]);
s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap();
assert_eq!(resolve(&s, "F9"), ["window.panel.actions"]);
assert_eq!(resolve(&s, ACTUAL_PIXELS_ALT), ["view.actualPixels"]);
}
+5
View File
@@ -142,6 +142,11 @@ impl Session {
st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize;
st.history.max_bytes = self.prefs.get().performance.history_cap_bytes();
st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history;
// History Options › Automatically Create First Snapshot off: no snapshot of the document
// as opened, so the History panel starts at the opening state (#2966).
if !self.prefs.get().history_options.automatically_create_first_snapshot {
st.snapshots.clear();
}
self.docs.push(st);
let i = self.docs.len() - 1;
self.active = Some(i);
+9 -7
View File
@@ -42,7 +42,9 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
("view.lockGuides", "Lock Guides", &["View", "Guides"], Some("Cmd+Alt+;")),
("view.editSelectedGuides", "Edit Selected Guides", &["View", "Guides"], None),
("view.clearSelectedGuides", "Clear Selected Guides", &["View", "Guides"], None),
("view.extras", "Extras", &["View"], Some("Cmd+H")),
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
@@ -57,7 +59,7 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
("view.rotateView", "Rotate View", &[], None),
("view.resetView", "Reset View", &["View"], None),
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
("window.newWindowForDocument", "New Window", &["Window", "Arrange"], None),
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
("window.toggle.history", "History", &["Window"], None),
("window.toggle.properties", "Properties", &["Window"], None),
@@ -122,8 +124,8 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.count", "Count Tool", Some("I")),
("tools.select.brush", "Brush Tool", Some("B")),
("tools.select.pencil", "Pencil Tool", Some("B")),
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
("tools.select.colorReplacement", "Color Replacement Tool", Some("B")),
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
("tools.select.eraser", "Eraser Tool", Some("E")),
("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")),
("tools.select.magicEraser", "Magic Eraser Tool", Some("E")),
@@ -136,8 +138,8 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.hand", "Hand Tool", Some("H")),
("tools.select.rotateView", "Rotate View Tool", Some("R")),
("tools.select.zoom", "Zoom Tool", Some("Z")),
("tools.select.remove", "Remove Tool", Some("J")),
("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")),
("tools.select.remove", "Remove Tool", Some("J")),
("tools.select.healing", "Healing Brush Tool", Some("J")),
("tools.select.patch", "Patch Tool", Some("J")),
("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")),
@@ -157,9 +159,9 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.pen", "Pen Tool", Some("P")),
("tools.select.freeformPen", "Freeform Pen Tool", Some("P")),
("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")),
("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")),
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")),
("tools.select.convertPoint", "Convert Point Tool", Some("P")),
("tools.select.addAnchorPoint", "Add Anchor Point Tool", None),
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", None),
("tools.select.convertPoint", "Convert Point Tool", None),
("tools.select.pathSelection", "Path Selection Tool", Some("A")),
("tools.select.directSelection", "Direct Selection Tool", Some("A")),
("tools.select.rectangle", "Rectangle Tool", Some("U")),
@@ -0,0 +1,202 @@
//! The measured Perspective Warp descriptor shape through ATN conversion and action playback.
use photocraft_doc::{LayerContent, LayerId};
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
use photocraft_geom::Rect;
use photocraft_psd::{
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D},
};
use serde_json::{Value, json};
const EVENT: &str = "perspectiveWarpTransform";
const COMMAND: &str = "edit.perspectiveWarp";
fn px(value: f64) -> D {
D::UnitFloat { unit: *b"#Pxl", value }
}
fn points(points: &[[f64; 2]]) -> D {
D::List(points.iter().map(|&[x, y]| D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", px(x)).with("Vrtc", px(y)))).collect())
}
fn quad(indices: [i32; 4]) -> D {
D::Descriptor(Descriptor::new("perspectiveWarpQuad").with("indices", D::List(indices.into_iter().map(D::Integer).collect())))
}
/// Two planes sharing a seam. Vertices appear out of corner order to distinguish exact export.
fn fixture(name: Option<&str>) -> Descriptor {
let class = Class { class_id: Id::new("Lyr "), name: UnicodeString::new_nul("") };
let target = match name {
Some(name) => ReferenceItem::Name { class, name: UnicodeString::new_nul(name) },
None => ReferenceItem::Enumerated { class, type_id: Id::new("Ordn"), value: Id::new("Trgt") },
};
let rect = Descriptor::new("Rctn").with("Top ", px(0.0)).with("Left", px(0.0)).with("Btom", px(24.0)).with("Rght", px(32.0));
Descriptor::new(EVENT)
.with("null", D::Reference(vec![target]))
.with("Md ", D::Enumerated { type_id: Id::new("perspectiveWarpMode"), value: Id::new("warp") })
.with("referenceRect", D::Descriptor(rect))
.with("vertices", points(&[[16.0, 20.0], [4.0, 4.0], [28.0, 4.0], [4.0, 20.0], [16.0, 4.0], [28.0, 20.0]]))
.with("warpedVertices", points(&[[17.0, 20.0], [5.5, 5.0], [27.0, 6.0], [4.0, 19.0], [18.0, 4.0], [26.0, 17.0]]))
.with("quads", D::List(vec![quad([1, 4, 0, 3]), quad([4, 2, 5, 0])]))
}
fn imported(d: Descriptor, with_dialog: bool, enabled: bool) -> atn_cmds::Imported {
let item =
AtnItem { event: AtnEvent::name(EVENT), dictionary: b"Perspective Warp".to_vec(), descriptor: Some(d), with_dialog, enabled, ..Default::default() };
let file = AtnFile {
name: UnicodeString::new_nul("Set"),
actions: vec![AtnAction { name: UnicodeString::new_nul("Warp"), items: vec![item], ..Default::default() }],
..Default::default()
};
atn_cmds::import(&atn::write(&file).unwrap()).unwrap()
}
fn saved(action: &Action) -> AtnItem {
let (bytes, skipped) = atn_cmds::export("Set", &[action], &[], false).unwrap();
assert!(skipped.is_empty());
atn::parse(&bytes).unwrap().actions[0].items[0].clone()
}
fn planes() -> Value {
json!([
{"src":[[4,4],[16,4],[16,20],[4,20]],"dst":[[5.5,5],[18,4],[17,20],[4,19]]},
{"src":[[16,4],[28,4],[28,20],[16,20]],"dst":[[18,4],[27,6],[26,17],[17,20]]}
])
}
fn value_mut<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut D {
&mut d.items.iter_mut().find(|(id, _)| id.is(key)).unwrap().1
}
/// Exact export preserves the original indexed mesh, target, rectangle and step flags.
#[test]
fn descriptor_round_trip_preserves_shared_indices_and_flags() {
for name in [None, Some("Façade")] {
let d = fixture(name);
let loaded = imported(d.clone(), true, false);
assert!(loaded.unsupported.is_empty());
let action = &loaded.actions[0];
assert_eq!(action.steps[0].0, COMMAND);
let params = &action.steps[0].1;
assert_eq!(params["planes"], planes());
assert_eq!(params["layer"].as_str(), name);
assert_eq!(params["target"], "pixels");
assert_eq!(params["__atnPerspectiveWarpRect"], json!([0, 0, 32, 24]));
let item = saved(action);
assert_eq!(item.event.id(), EVENT);
assert_eq!(item.descriptor.unwrap().to_bytes(), d.to_bytes());
assert!(item.with_dialog);
assert!(!item.enabled);
}
}
/// Unsupported targets, units, modes and geometry retain the original bytes as kept steps.
#[test]
fn unsupported_recordings_are_kept_without_partial_mapping() {
let d = fixture(None);
let mut layout = d.clone();
*value_mut(&mut layout, "Md ") = D::Enumerated { type_id: Id::new("perspectiveWarpMode"), value: Id::new("layout") };
let mut wrong_target = d.clone();
*value_mut(&mut wrong_target, "null") = D::Reference(vec![ReferenceItem::Class(Class { class_id: Id::new("Chnl"), name: UnicodeString::new_nul("") })]);
let mut bad_index = d.clone();
*value_mut(&mut bad_index, "quads") = D::List(vec![quad([1, 4, 99, 3])]);
let mut wrong_units = d.clone();
let D::Descriptor(rect) = value_mut(&mut wrong_units, "referenceRect") else { panic!("reference rectangle") };
*value_mut(rect, "Rght") = D::UnitFloat { unit: *b"#Prc", value: 32.0 };
let mut incompatible_topology = d.clone();
for key in ["vertices", "warpedVertices"] {
let D::List(points) = value_mut(&mut incompatible_topology, key) else { panic!("vertex list") };
points.push(points[4].clone());
}
*value_mut(&mut incompatible_topology, "quads") = D::List(vec![quad([1, 4, 0, 3]), quad([6, 2, 5, 0])]);
let mut duplicate = d.clone();
duplicate.items.push((Id::new("quads"), d.get("quads").unwrap().clone()));
for descriptor in [layout, wrong_target, bad_index, wrong_units, incompatible_topology, duplicate] {
let loaded = imported(descriptor.clone(), false, true);
assert_eq!(loaded.unsupported.len(), 1, "{descriptor:?}");
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep");
assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), descriptor.to_bytes());
}
}
/// Native export needs a reference rectangle. A stale recorded topology must not be substituted.
#[test]
fn native_export_requires_known_rectangle_and_rejects_stale_source() {
let mut action = Action::new("Warp", vec![(COMMAND.into(), json!({"planes": planes()}))]);
assert!(atn_cmds::export("Set", &[&action], &[], false).is_err());
action.steps[0].1["__atnPerspectiveWarpRect"] = json!([0, 0, 32, 24]);
let d = saved(&action).descriptor.unwrap();
let keys: Vec<_> = d.items.iter().map(|(id, _)| id.as_bytes()).collect();
assert_eq!(keys, [b"null".as_slice(), b"Md ", b"referenceRect", b"vertices", b"warpedVertices", b"quads"]);
let mut recorded = imported(d, false, true).actions.remove(0);
recorded.steps[0].1["planes"][0]["dst"][0][0] = json!(6);
assert!(atn_cmds::export("Set", &[&recorded], &[], false).is_err());
action.steps[0].1["interpolation"] = json!("nearest");
assert!(atn_cmds::export("Set", &[&action], &[], false).is_err());
}
fn session(depth: u8) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.edit("Fixture", |doc, _| {
let layer = doc.layer_mut(id).unwrap();
layer.name = "Facade".into();
let surf = layer.surface_mut().unwrap();
let fmt = surf.format();
surf.fill_rect(Rect::new(4, 4, 16, 20), &photocraft_raster::from_rgba(&fmt, [0.9, 0.2, 0.1, 1.0]));
surf.fill_rect(Rect::new(16, 4, 28, 20), &photocraft_raster::from_rgba(&fmt, [0.1, 0.2, 0.9, 1.0]));
Ok(())
})
.unwrap();
(s, id)
}
/// A named raster is warped, while the active decoy stays unchanged.
#[test]
fn action_playback_matches_the_native_raster_warp() {
for depth in [8, 16, 32] {
let (mut actual, id) = session(depth);
let (mut direct, did) = session(depth);
let before = actual.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone();
for s in [&mut actual, &mut direct] {
s.execute("layer.new.layer", json!({})).unwrap();
}
let decoy = actual.active().unwrap().active_layer.unwrap();
let decoy_before = actual.active().unwrap().doc.layer(decoy).unwrap().clone();
actual.actions.list.extend(imported(fixture(Some("Facade")), false, true).actions);
actual.execute("actions.play", json!({"action":0})).unwrap();
direct.execute(COMMAND, json!({"planes":planes(),"layer":"Facade","target":"pixels"})).unwrap();
let result = actual.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
assert_ne!(*result, before);
assert_eq!(result, direct.active().unwrap().doc.layer(did).unwrap().surface().unwrap());
assert_eq!(actual.active().unwrap().doc.layer(decoy), Some(&decoy_before));
actual.execute("edit.undo", json!({})).unwrap();
assert_eq!(actual.active().unwrap().doc.layer(id).unwrap().surface(), Some(&before));
}
}
/// The same active-layer recording adds a native Smart Filter and keeps PSD export available.
#[test]
fn active_smart_object_playback_matches_the_native_filter() {
let (mut actual, _) = session(8);
let (mut direct, _) = session(8);
for s in [&mut actual, &mut direct] {
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
}
actual.actions.list.extend(imported(fixture(None), false, true).actions);
actual.execute("actions.play", json!({"action":0})).unwrap();
direct.execute("layer.smartObjects.perspectiveWarp", json!({"planes":planes()})).unwrap();
let [actual, direct] = [&actual, &direct].map(|s| {
let st = s.active().unwrap();
let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart object") };
sm
});
assert_eq!(actual.cache, direct.cache);
assert_eq!(actual.smart_filters.len(), 1);
assert_eq!(actual.smart_filters[0].command, COMMAND);
assert_eq!(actual.smart_filters[0].params["planes"], planes());
assert!(photocraft_io::smart_map::item_for_filter(&actual.smart_filters[0]).is_ok());
}
+243
View File
@@ -0,0 +1,243 @@
//! The measured Smart Object Stack Mode events retain their descriptor and native behavior.
use photocraft_doc::{Group, Layer, LayerContent, StackMode};
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
use photocraft_geom::Rect;
use photocraft_psd::{
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
descriptor::{Class, Descriptor, Id, UnicodeString, Value as D},
};
use serde_json::json;
const APPLY: &str = "applyImageStackPluginRenderer";
const CLEAR: &str = "imageStackConvertSmartObject";
const ORIGIN: &str = "_atnStackDescriptor";
/// Independent pairs from the measured table in docs/atn.md.
const MODES: &[(&str, &str, StackMode)] = &[
("rang", "Range", StackMode::Range),
("entr", "Entropy", StackMode::Entropy),
("kurt", "Kurtosis", StackMode::Kurtosis),
("maxx", "Maximum", StackMode::Maximum),
("medn", "Median", StackMode::Median),
("minn", "Minimum", StackMode::Minimum),
("avrg", "Mean", StackMode::Mean),
("skew", "Skewness", StackMode::Skewness),
("stdv", "Standard Deviation", StackMode::StandardDeviation),
("summ", "Summation", StackMode::Summation),
("vari", "Variance", StackMode::Variance),
];
fn descriptor(code: &str, name: &str) -> Descriptor {
Descriptor::new("null")
.with("imageStackPlugin", D::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new(code) }))
.with("Nm ", D::Text(UnicodeString::new_nul(name)))
}
fn item(event: &str, descriptor: Option<Descriptor>) -> AtnItem {
AtnItem { event: AtnEvent::name(event), descriptor, ..Default::default() }
}
fn load(item: AtnItem) -> atn_cmds::Imported {
let file = AtnFile {
name: UnicodeString::new_nul("Stacks"),
actions: vec![AtnAction { name: UnicodeString::new_nul("Mode"), items: vec![item], ..Default::default() }],
..Default::default()
};
atn_cmds::import(&atn::write(&file).unwrap()).unwrap()
}
fn saved(action: &Action) -> AtnItem {
let (bytes, skipped) = atn_cmds::export("Stacks", &[action], &[], false).unwrap();
assert!(skipped.is_empty());
atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0)
}
/// Exercise actual load/save bytes for all plugin classes and the separate None event.
#[test]
fn measured_stack_modes_and_none_import_export_and_reload() {
for &(code, name, mode) in MODES {
let original = item(APPLY, Some(descriptor(code, name)));
let imported = load(original.clone());
assert!(imported.unsupported.is_empty());
assert_eq!(imported.actions[0].steps, vec![(format!("layer.smartObjects.stackMode.{}", mode.id()), json!({}))]);
let out = saved(&imported.actions[0]);
assert_eq!(out.event, original.event);
assert_eq!(out.descriptor, original.descriptor);
assert_eq!(load(out).actions[0].steps, imported.actions[0].steps);
}
for original in [item(CLEAR, None), item(CLEAR, Some(Descriptor::new("null")))] {
let imported = load(original.clone());
assert!(imported.unsupported.is_empty());
assert_eq!(imported.actions[0].steps[0].0, "layer.smartObjects.stackMode.none");
let out = saved(&imported.actions[0]);
assert_eq!(out.event, original.event);
assert_eq!(out.descriptor, original.descriptor);
assert_eq!(load(out).actions[0].steps, imported.actions[0].steps);
}
let native = Action::new("Mode", vec![("layer.smartObjects.stackMode.none".into(), json!({}))]);
let out = saved(&native);
assert_eq!(out.event, AtnEvent::name(CLEAR));
assert!(out.descriptor.is_none());
}
/// Localized names and noncanonical wire metadata still replay the class-selected mode.
#[test]
fn localized_stack_descriptors_keep_original_metadata_and_flags() {
for name in [UnicodeString::new("Moyenne"), UnicodeString::new_nul("平均值"), UnicodeString(vec![0xd800, 0, 0])] {
let mut d = descriptor("avrg", "Mean");
d.name = UnicodeString::new_nul("Recorded stack");
d.class_id = Id::Str(b"null".to_vec());
d.items[1].1 = D::Text(name);
d.items[1].0 = Id::Str(b"Nm ".to_vec());
d.items.reverse();
let mut original = item(APPLY, Some(d));
original.enabled = false;
original.with_dialog = true;
original.dialog_options = 2;
let imported = load(original.clone());
assert!(imported.unsupported.is_empty());
assert_eq!(imported.actions[0].steps[0].0, "layer.smartObjects.stackMode.mean");
let out = saved(&imported.actions[0]);
assert_eq!(out.descriptor, original.descriptor);
assert!(!out.enabled);
assert!(out.with_dialog);
assert_eq!(out.dialog_options, 2);
}
let mut d = Descriptor::new("null");
d.name = UnicodeString(vec![0xd800, 0]);
d.class_id = Id::Str(b"null".to_vec());
let imported = load(item(CLEAR, Some(d.clone())));
assert!(imported.unsupported.is_empty());
assert_eq!(saved(&imported.actions[0]).descriptor, Some(d));
}
fn stack(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":3,"height":2,"depth":depth})).unwrap();
s.edit("Fixture", |doc, active| {
let fmt = doc.pixel_format();
let children = [0.2, 0.2, 0.8]
.into_iter()
.enumerate()
.map(|(index, value)| {
let mut layer = Layer::raster(format!("Frame {index}"), fmt);
let pixel = photocraft_raster::from_rgba(&fmt, [value, value, value, 1.0]);
layer.surface_mut().unwrap().write_region(Rect::new(0, 0, 3, 2), &pixel.repeat(6));
layer
})
.collect();
let group = Layer::new("Stack", LayerContent::Group(Group { children, expanded: true, artboard: None, frame_stroke: None }));
let smart = photocraft_engine::smart_cmds::layer_to_smart(doc, &group)?;
*active = Some(smart.id);
doc.layers = vec![smart];
Ok(())
})
.unwrap();
s
}
/// Compare the adapter with existing native commands, including pixel output and one Undo/Redo.
#[test]
fn stack_recordings_play_native_modes_and_undo_at_each_depth() {
for depth in [8, 16, 32] {
let mut s = stack(depth);
let modes = MODES.iter().map(|&(code, _, mode)| (item(APPLY, Some(descriptor(code, "Recorded name"))), Some(mode)));
let clear_forms = [None, Some(Descriptor::new("null"))].into_iter().map(|d| (item(CLEAR, d), None));
for (source, mode) in modes.chain(clear_forms) {
let starting = if mode == Some(StackMode::Maximum) { "none" } else { "maximum" };
s.execute(&format!("layer.smartObjects.stackMode.{starting}"), json!({})).unwrap();
let before = s.active().unwrap().doc.clone();
let before_steps = s.active().unwrap().history.past_len();
let command = format!("layer.smartObjects.stackMode.{}", mode.map_or("none", StackMode::id));
s.execute(&command, json!({})).unwrap();
let expected = s.active().unwrap().doc.clone();
s.execute("edit.undo", json!({})).unwrap();
let imported = load(source);
s.actions.list = imported.actions;
let result = s.execute("actions.play", json!({"action":"Mode","set":"Stacks"})).unwrap();
assert_eq!(result["ran"], 1, "{result}");
let actual = s.active().unwrap();
assert_eq!(actual.doc.layers, expected.layers, "{command} at {depth}-bit");
assert_eq!(actual.history.past_len(), before_steps + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.layers, before.layers);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.layers, expected.layers);
}
}
}
/// Unrepresented types, duplicate keys and extra settings stay complete kept steps.
#[test]
fn unsupported_stack_recordings_save_whole_and_refuse_playback() {
let canonical = descriptor("medn", "Median");
let mut duplicate = canonical.clone();
duplicate.items.push(duplicate.items[0].clone());
let mut wrong_name = canonical.clone();
wrong_name.items[1].1 = D::Integer(5);
let mut global_class = canonical.clone();
let D::Class(class) = global_class.items[0].1.clone() else { unreachable!() };
global_class.items[0].1 = D::GlobalClass(class);
let extra = canonical.clone().with("unknown", D::Boolean(true));
for original in [
item(APPLY, Some(duplicate)),
item(APPLY, Some(wrong_name)),
item(APPLY, Some(global_class)),
item(APPLY, Some(extra)),
item(APPLY, Some(descriptor("nope", "Median"))),
item(APPLY, None),
item(CLEAR, Some(canonical)),
item(CLEAR, Some(Descriptor::new("nope"))),
] {
let imported = load(original.clone());
assert_eq!(imported.unsupported.len(), 1);
assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP);
let out = saved(&imported.actions[0]);
assert_eq!(out.event, original.event);
assert_eq!(out.descriptor, original.descriptor);
let mut s = stack(8);
s.execute("layer.smartObjects.stackMode.maximum", json!({})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.actions.list = imported.actions;
let result = s.execute("actions.play", json!({"action":"Mode","set":"Stacks"})).unwrap();
assert!(result.get("failed").is_some(), "{result}");
assert_eq!(s.active().unwrap().doc.layers, before.layers);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
/// Native mode edits replace the old class; explicit targets and damaged origin metadata refuse export.
#[test]
fn stack_mode_edits_and_unrepresented_native_parameters_are_safe() {
let imported = load(item(APPLY, Some(descriptor("medn", "Médiane"))));
let mut action = imported.actions[0].clone();
action.steps[0].0 = "layer.smartObjects.stackMode.maximum".into();
assert_eq!(saved(&action).descriptor, Some(descriptor("maxx", "Maximum")));
action.steps[0].0 = "layer.smartObjects.stackMode.none".into();
let cleared = saved(&action);
assert_eq!(cleared.event, AtnEvent::name(CLEAR));
assert_eq!(cleared.descriptor, Some(Descriptor::new("null")));
let mut action = load(item(CLEAR, Some(Descriptor::new("null")))).actions.remove(0);
action.steps[0].0 = "layer.smartObjects.stackMode.mean".into();
let applied = saved(&action);
assert_eq!(applied.event, AtnEvent::name(APPLY));
assert_eq!(applied.descriptor, Some(descriptor("avrg", "Mean")));
let unknown_empty = photocraft_engine::paint::tile::b64_encode(&Descriptor::new("nope").to_bytes());
let unknown_class = photocraft_engine::paint::tile::b64_encode(&descriptor("nope", "Mean").to_bytes());
for command in ["layer.smartObjects.stackMode.mean", "layer.smartObjects.stackMode.none"] {
for params in [json!(null), json!([]), json!({"layer":12}), json!({"layer":"Named"}), json!({"target":"mask"}), json!({"unknown":true})] {
let action = Action::new("Mode", vec![(command.into(), params)]);
assert!(atn_cmds::export("Stacks", &[&action], &[], false).is_err());
}
for origin in [json!(false), json!("!invalid-base64"), json!(""), json!(unknown_class), json!(unknown_empty)] {
let mut action = Action::new("Mode", vec![(command.into(), json!({}))]);
action.steps[0].1[ORIGIN] = origin;
assert!(atn_cmds::export("Stacks", &[&action], &[], false).is_err());
}
}
}
+224
View File
@@ -0,0 +1,224 @@
//! #10723: recorded frame edits use the active video layer and the current playhead.
use photocraft_doc::{LayerContent, LayerId};
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
use photocraft_psd::{
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
descriptor::{Descriptor, UnicodeString, Value as D},
};
use serde_json::json;
use std::sync::Arc;
const EVENTS: [(&str, &str); 3] = [
("insertBlankFrame", "layer.videoLayers.insertBlankFrame"),
("duplicateFrame", "layer.videoLayers.duplicateFrame"),
("deleteFrame", "layer.videoLayers.deleteFrame"),
];
/// Build typed source fixtures without asking the mapper to generate its own oracle.
fn file(item: AtnItem) -> Vec<u8> {
atn::write(&AtnFile {
name: UnicodeString::new_nul("Video"),
actions: vec![AtnAction { name: UnicodeString::new_nul("Frame edit"), items: vec![item], ..Default::default() }],
..Default::default()
})
.unwrap()
}
fn item(event: &str, descriptor: Option<Descriptor>) -> AtnItem {
AtnItem { event: AtnEvent::name(event), descriptor, ..Default::default() }
}
/// Exercise strict public export: skipping an unrepresentable step cannot pass this check.
fn saved(action: &Action) -> AtnItem {
let (bytes, skipped) = atn_cmds::export("Video", &[action], &[], false).unwrap();
assert!(skipped.is_empty());
atn::parse(&bytes).unwrap().actions[0].items[0].clone()
}
/// Playback tests require a native mapping, rather than a successfully loaded kept step.
fn load(s: &mut Session, source: AtnItem) {
let bytes = file(source);
let report = s.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap();
assert_eq!(report["unsupported"], json!([]));
}
/// Compare actual pixel markers; VideoData equality checks only stack shape.
fn pixels(s: &Session, id: LayerId, original: bool) -> Vec<Vec<f32>> {
let video = s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap();
let frames = if original { video.original_frames.as_ref().unwrap() } else { &video.frames };
frames.iter().map(|frame| frame.pixel(0, 0)).collect()
}
/// Distinguish both frame order and original footage from the altered display cache.
fn fixture(depth: u32) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width":2,"height":2,"depth":depth,"background":"transparent"})).unwrap();
s.execute("timeline.create", json!({"duration":3,"fps":24})).unwrap();
let result = s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap();
let id = LayerId(result["layer"].as_u64().unwrap());
let state = s.active_mut().unwrap();
let doc = Arc::make_mut(&mut state.doc);
let mut frames = Vec::new();
for red in [0.25, 0.5, 0.75] {
let mut frame = photocraft_raster::Surface::new(doc.pixel_format());
frame.write_pixel(0, 0, &[red, 0.0, 0.0, 1.0]);
frames.push(frame);
}
let layer = doc.layer_mut(id).unwrap();
layer.content = LayerContent::Raster(frames[1].clone());
let video = layer.video.as_mut().unwrap();
video.original_frames = Some(
frames
.iter()
.rev()
.map(|frame| {
let mut original = frame.clone();
let red = original.pixel(0, 0)[0];
original.write_pixel(0, 0, &[red, 1.0, 0.0, 1.0]);
original
})
.collect(),
);
video.frames = frames;
let timeline = doc.timeline.as_mut().unwrap();
timeline.current = 1;
timeline.work_start = 1;
timeline.work_end = 2;
(s, id)
}
#[test]
/// Import's empty placeholder must not erase the wire's descriptor-presence distinction.
fn descriptors_and_step_flags_round_trip_including_an_absent_descriptor() {
for (event, command) in EVENTS {
for descriptor in [None, Some(Descriptor::default())] {
for enabled in [false, true] {
let mut source = item(event, descriptor.clone());
source.enabled = enabled;
source.with_dialog = true;
let imported = atn_cmds::import(&file(source)).unwrap();
assert!(imported.unsupported.is_empty());
let action = &imported.actions[0];
assert_eq!(action.steps[0].0, command);
assert_eq!(action.disabled.contains(&0), !enabled);
assert!(action.dialogs.contains(&0));
let back = saved(action);
assert_eq!(back.event, AtnEvent::name(event));
assert_eq!(back.descriptor, descriptor);
assert_eq!(back.enabled, enabled);
assert!(back.with_dialog);
}
}
}
}
#[test]
/// One recorded command must change the correct stack position and remain one undoable edit.
fn playback_edits_the_current_frame_and_original_stack_with_undo_redo_at_each_depth() {
for (depth, has_descriptor) in [(8, true), (16, true), (32, true), (8, false)] {
for (event, _) in EVENTS {
let (mut s, id) = fixture(depth);
let before = pixels(&s, id, false);
let originals = pixels(&s, id, true);
let timeline = s.active().unwrap().doc.timeline.clone();
let expected = |frames: &[Vec<f32>]| match event {
"insertBlankFrame" => vec![frames[0].clone(), frames[1].clone(), vec![0.0; 4], frames[2].clone()],
"duplicateFrame" => vec![frames[0].clone(), frames[1].clone(), frames[1].clone(), frames[2].clone()],
"deleteFrame" => vec![frames[0].clone(), frames[2].clone()],
_ => panic!("fixture event"),
};
load(&mut s, item(event, has_descriptor.then(Descriptor::default)));
let report = s.execute("actions.play", json!({"action":0})).unwrap();
assert!(report.get("failed").is_none(), "{depth}/{event}: {report}");
assert_eq!(report["ran"], 1);
assert_eq!(pixels(&s, id, false), expected(&before), "{depth}/{event}");
assert_eq!(pixels(&s, id, true), expected(&originals), "original {depth}/{event}");
let t = s.active().unwrap().doc.timeline.as_ref().unwrap();
assert_eq!((t.current, t.work_start, t.work_end), (1, 1, 2));
assert_eq!(t.duration, if event == "deleteFrame" { 3 } else { 4 });
let LayerContent::Raster(display) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("video display") };
assert_eq!(display.pixel(0, 0), expected(&before)[1]);
s.execute("history.undo", json!({})).unwrap();
assert_eq!(pixels(&s, id, false), before);
assert_eq!(pixels(&s, id, true), originals);
assert_eq!(s.active().unwrap().doc.timeline, timeline);
s.execute("history.redo", json!({})).unwrap();
assert_eq!(pixels(&s, id, false), expected(&before));
assert_eq!(pixels(&s, id, true), expected(&originals));
}
}
}
#[test]
/// The native last-frame protection must also hold through recorded playback.
fn deleting_the_last_frame_keeps_its_pixels() {
let (mut s, id) = fixture(8);
let state = s.active_mut().unwrap();
let doc = Arc::make_mut(&mut state.doc);
let layer = doc.layer_mut(id).unwrap();
let video = layer.video.as_mut().unwrap();
video.frames.truncate(1);
video.original_frames.as_mut().unwrap().truncate(1);
layer.content = LayerContent::Raster(video.frames[0].clone());
let t = doc.timeline.as_mut().unwrap();
*t = photocraft_doc::video::Timeline::new(1, 24.0);
let before = pixels(&s, id, false);
let original = pixels(&s, id, true);
load(&mut s, item("deleteFrame", Some(Descriptor::default())));
let report = s.execute("actions.play", json!({"action":0})).unwrap();
assert!(report.get("failed").is_none(), "{report}");
assert_eq!(pixels(&s, id, false), before);
assert_eq!(pixels(&s, id, true), original);
}
#[test]
/// Recognizing an event name must never discard settings or reinterpret its unknown header.
fn unrepresented_settings_and_descriptor_metadata_stay_complete_kept_steps() {
let named = Descriptor { name: UnicodeString::new_nul("Unmeasured metadata"), ..Default::default() };
for (event, _) in EVENTS {
for descriptor in [Descriptor::default().with("frame", D::Integer(2)), Descriptor::new("other"), named.clone()] {
let imported = atn_cmds::import(&file(item(event, Some(descriptor.clone())))).unwrap();
assert_eq!(imported.unsupported.len(), 1);
assert_eq!(imported.actions[0].steps[0].0, "actions.photoshopStep");
assert_eq!(saved(&imported.actions[0]).descriptor, Some(descriptor));
}
}
}
#[test]
/// Current-frame recordings cannot serialize an explicit frame, layer or mask selector.
fn native_options_and_damaged_absence_metadata_refuse_export() {
for (_, command) in EVENTS {
for params in [
json!({"frame":2}),
json!({"layer":3}),
json!({"target":"mask"}),
json!({"_atnVideoFrameNoDescriptor":false}),
json!({"_atnVideoFrameNoDescriptor":"true"}),
] {
let action = Action::new("Invalid", vec![(command.into(), params)]);
assert!(atn_cmds::export("Video", &[&action], &[], false).is_err(), "{command}");
}
}
}
#[test]
/// Mapping a known event cannot bypass the native video-target precondition.
fn playback_without_a_video_target_fails_without_editing_a_document() {
for (event, _) in EVENTS {
for document in [false, true] {
let mut s = Session::new();
if document {
s.execute("file.new", json!({"width":2,"height":2})).unwrap();
}
let before = s.active().map(|state| state.doc.clone());
load(&mut s, item(event, Some(Descriptor::default())));
let report = s.execute("actions.play", json!({"action":0})).unwrap();
assert!(report.get("failed").is_some(), "{event}: {report}");
match before {
Some(before) => assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)),
None => assert!(s.active().is_none()),
}
}
}
}
@@ -0,0 +1,392 @@
//! #10868: crop fill samples the Background and editable active raster independently.
use photocraft_doc::{Document, Layer, LayerContent, LayerId, TextLayer};
use photocraft_engine::Session;
use photocraft_geom::Rect;
use serde_json::{Value, json};
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
const YELLOW: [f32; 4] = [1.0, 1.0, 0.0, 1.0];
struct Layers {
background: LayerId,
quads: LayerId,
small: LayerId,
edge: LayerId,
hidden: LayerId,
text: LayerId,
}
fn doc(s: &Session) -> &Document {
&s.active().unwrap().doc
}
fn raster(d: &Document, name: &str, bounds: Rect, color: [f32; 4]) -> Layer {
let fmt = d.pixel_format();
let mut layer = Layer::raster(name, fmt);
layer.surface_mut().unwrap().fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, color));
layer
}
/// Keeps the measured straight geometry and fits turned controls under the 1 MiB transform limit
/// that the preference test can set process-wide.
fn scene_divisor(turned: bool) -> i32 {
if turned { 2 } else { 1 }
}
/// The measured scene, with a deliberately different crop extension colour.
fn scene(turned: bool) -> (Session, Layers) {
let divisor = scene_divisor(turned);
let rect = |x0, y0, x1, y1| Rect::new(x0 / divisor, y0 / divisor, x1 / divisor, y1 / divisor);
let mut s = Session::new();
s.execute("file.new", json!({"width":800/divisor,"height":500/divisor,"depth":8,"background":"white"})).unwrap();
s.execute("tools.setColors", json!({"background":"#ff00ff"})).unwrap();
let ids = s
.edit("Fixture", |d, _| {
let background = d.background().unwrap().id;
let mut quads = raster(d, "Quads", rect(0, 0, 800, 500), RED);
let fmt = d.pixel_format();
let surf = quads.surface_mut().unwrap();
for (bounds, color) in [(rect(400, 0, 800, 250), GREEN), (rect(0, 250, 400, 500), BLUE), (rect(400, 250, 800, 500), YELLOW)] {
surf.fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, color));
}
let small = raster(d, "Small", rect(350, 200, 450, 300), RED);
let edge = raster(d, "EdgeHalf", rect(600, 0, 800, 250), BLUE);
let mut hidden = raster(d, "Hidden", rect(0, 0, 800, 500), RED);
hidden.visible = false;
let text = Layer::new("T", LayerContent::Text(TextLayer { text: "T".into(), ..Default::default() }));
let ids = Layers { background, quads: quads.id, small: small.id, edge: edge.id, hidden: hidden.id, text: text.id };
d.layers.extend([quads, small, edge, hidden, text]);
Ok(ids)
})
.unwrap();
(s, ids)
}
fn select(s: &mut Session, id: LayerId) {
s.execute("layer.select", json!({"layer":id.0})).unwrap();
}
/// Extends the frame to the right by one eighth of the original width.
fn frame(turned: bool) -> Value {
let divisor = scene_divisor(turned);
json!({"x":0,"y":0,"width":900/divisor,"height":500/divisor,"angle":if turned { 10 } else { 0 },"deleteCroppedPixels":true})
}
/// Samples the same relative point inside the new strip in both fixture sizes.
fn extension_sample(turned: bool) -> (i32, i32) {
let divisor = scene_divisor(turned);
(850 / divisor, 400 / divisor)
}
fn crop(s: &mut Session, mut p: Value, aware: bool) {
p["contentAware"] = json!(aware);
s.execute("image.crop", p).unwrap();
}
fn plain_control(s: &mut Session, p: &Value) -> Document {
crop(s, p.clone(), false);
let plain = doc(s).clone();
s.execute("edit.undo", json!({})).unwrap();
plain
}
/// Checks donor colour with room for the resampling and synthesis tolerance.
fn pixel(d: &Document, id: LayerId, x: i32, y: i32, want: [f32; 4]) {
let actual = d.layer(id).unwrap().surface().unwrap().rgba(x, y);
assert!(actual.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.06), "layer {id:?} at ({x},{y}): {actual:?}, expected {want:?}");
}
/// Compares complete layers with plain crop, including any legitimate turn or clipping.
fn unchanged(d: &Document, plain: &Document, ids: &[LayerId]) {
for id in ids {
// Includes bounds, pixels and metadata; turned crops legitimately transform these layers.
assert_eq!(d.layer(*id), plain.layer(*id), "non-target {id:?} differs from plain crop");
}
}
/// Every pixel of `id` inside `area` (the old canvas) is the plain crop's.
fn covered_as_plain(d: &Document, plain: &Document, id: LayerId, area: Rect) {
let (got, want) = (d.layer(id).unwrap().surface().unwrap(), plain.layer(id).unwrap().surface().unwrap());
for y in (area.y0..area.y1).step_by(7) {
for x in (area.x0..area.x1).step_by(7) {
assert_eq!(got.rgba(x, y), want.rgba(x, y), "layer {id:?} at ({x},{y}) differs from the plain crop");
}
}
}
fn named_one_step(s: &Session, past: usize, name: &str) {
let st = s.active().unwrap();
assert_eq!(st.history.past_len(), past + 1);
assert_eq!(st.history.undo_label(), Some(name));
}
fn undo_redo(s: &mut Session, before: &Document) {
let after = doc(s).clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(s), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(doc(s), &after);
assert_eq!(s.active().unwrap().history.undo_label(), Some("Content-aware Crop"));
}
/// Matches the three measured rows at their original size, bounds and sample points.
#[test]
fn measured_straight_rows_fill_background_and_only_the_active_raster() {
for row in 0..3 {
let (mut s, ids) = scene(false);
let active = if row == 1 { ids.edge } else { ids.quads };
if row == 2 {
s.edit("Layer 0", |d, _| {
let bg = d.layer_mut(ids.background).unwrap();
bg.name = "Layer 0".into();
bg.locks = Default::default();
Ok(())
})
.unwrap();
}
select(&mut s, active);
let before = doc(&s).clone();
let p = frame(false);
let plain = plain_control(&mut s, &p);
let past = s.active().unwrap().history.past_len();
crop(&mut s, p, true);
let d = doc(&s);
assert_eq!((d.size.width, d.size.height), (900, 500));
let mut untouched = vec![ids.small, ids.hidden, ids.text];
untouched.push(if row == 1 { ids.quads } else { ids.edge });
if row == 2 {
assert!(d.background().is_none());
untouched.push(ids.background);
} else {
assert_eq!(d.layer(ids.background).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 900, 500));
pixel(d, ids.background, 850, 400, WHITE);
}
let bounds = if row == 1 { Rect::new(600, 0, 900, 500) } else { Rect::new(0, 0, 900, 500) };
assert_eq!(d.layer(active).unwrap().surface().unwrap().content_bounds(), bounds);
pixel(d, active, 850, if row == 1 { 400 } else { 125 }, if row == 1 { BLUE } else { GREEN });
// The fill adds pixels only where the crop uncovered the canvas: the active layer's own
// pixels inside the old canvas are the plain crop's.
covered_as_plain(d, &plain, active, Rect::new(0, 0, 800, 500));
unchanged(d, &plain, &untouched);
named_one_step(&s, past, "Content-aware Crop");
undo_redo(&mut s, &before);
}
}
/// Checks own-layer donors and exact non-target parity after rotation.
#[test]
fn turned_rows_sample_each_target_and_leave_other_layers_at_plain_crop() {
let (x, y) = extension_sample(true);
for row in 0..3 {
let (mut s, ids) = scene(true);
// Uniform donors isolate target selection from the existing turned-fill sampling policy.
s.edit("Uniform Quads", |d, _| {
let fmt = d.pixel_format();
let bounds = d.bounds();
d.layer_mut(ids.quads).unwrap().surface_mut().unwrap().fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, GREEN));
if row == 2 {
d.layer_mut(ids.background).unwrap().locks = Default::default();
}
Ok(())
})
.unwrap();
let active = if row == 1 { ids.edge } else { ids.quads };
select(&mut s, active);
let before = doc(&s).clone();
let p = frame(true);
let plain = plain_control(&mut s, &p);
let past = s.active().unwrap().history.past_len();
let mut p = p;
p["contentAware"] = json!(true);
s.execute("image.crop", p).unwrap_or_else(|error| panic!("turned row {row}, active {active:?}: {error}"));
let d = doc(&s);
pixel(d, active, x, y, if row == 1 { BLUE } else { GREEN });
assert!(d.layer(active).unwrap().surface().unwrap().content_bounds().contains(x, y));
let mut untouched = vec![ids.small, ids.hidden, ids.text, if row == 1 { ids.quads } else { ids.edge }];
if row == 2 {
untouched.push(ids.background);
} else {
pixel(d, ids.background, x, y, WHITE);
}
unchanged(d, &plain, &untouched);
named_one_step(&s, past, "Content-aware Crop");
undo_redo(&mut s, &before);
}
}
/// Selecting the Background leaves each other layer identical to its plain crop.
#[test]
fn background_active_is_not_a_second_target() {
for turned in [false, true] {
let (x, y) = extension_sample(turned);
let (mut s, ids) = scene(turned);
select(&mut s, ids.background);
let p = frame(turned);
let plain = plain_control(&mut s, &p);
let past = s.active().unwrap().history.past_len();
crop(&mut s, p, true);
pixel(doc(&s), ids.background, x, y, WHITE);
unchanged(doc(&s), &plain, &[ids.quads, ids.small, ids.edge, ids.hidden, ids.text]);
named_one_step(&s, past, "Content-aware Crop");
}
}
/// Verifies Background-only fill for ineligible active layers and Delete Cropped Pixels off.
#[test]
fn unmeasured_active_targets_and_delete_off_keep_the_legacy_target() {
for turned in [false, true] {
let (x, y) = extension_sample(turned);
for case in 0..10 {
let (mut s, ids) = scene(turned);
let active = match case {
0 => ids.text,
1 => ids.hidden,
_ => ids.quads,
};
if (2..7).contains(&case) {
s.edit("Lock active", |d, _| {
assert!(d.layer_mut(active).unwrap().locks.set(["all", "pixels", "transparency", "position", "artboard"][case - 2], true));
Ok(())
})
.unwrap();
}
if case >= 8 {
s.edit("Group active", |d, _| {
let i = d.layers.iter().position(|layer| layer.id == active).unwrap();
let child = d.layers.remove(i);
let mut group = Layer::group("Ancestor", vec![child]);
if case == 8 {
group.visible = false;
} else {
assert!(group.locks.set("pixels", true));
}
d.layers.insert(i, group);
Ok(())
})
.unwrap();
}
select(&mut s, active);
let mut p = frame(turned);
if case == 7 {
p["deleteCroppedPixels"] = json!(false);
}
let plain = plain_control(&mut s, &p);
crop(&mut s, p, true);
pixel(doc(&s), ids.background, x, y, WHITE);
unchanged(doc(&s), &plain, &[ids.quads, ids.small, ids.edge, ids.hidden, ids.text]);
}
}
}
/// Checks the plain history name for interior crops and crops with synthesis disabled.
#[test]
fn no_fill_or_content_aware_off_keeps_the_crop_history_name() {
for (p, aware, turned) in [
(json!({"x":20,"y":20,"width":40,"height":30}), true, false),
(json!({"x":20,"y":20,"width":40,"height":30,"angle":10}), true, true),
(frame(false), false, false),
(frame(true), false, true),
] {
let (mut s, ids) = scene(turned);
select(&mut s, ids.quads);
let past = s.active().unwrap().history.past_len();
crop(&mut s, p, aware);
named_one_step(&s, past, "Crop");
}
}
/// Gives exactly one target a strip of held pixels outside the original canvas.
fn overhang_scene(held_active: bool) -> (Session, LayerId, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"background":"white"})).unwrap();
s.execute("tools.setColors", json!({"background":"#ff00ff"})).unwrap();
let (bg, active) = s
.edit("Distinct hidden pixels", |d, active| {
let bg = d.background().unwrap().id;
let mut layer = raster(d, "Active", Rect::new(0, 0, 64, 48), GREEN);
let fmt = d.pixel_format();
let surf = if held_active { layer.surface_mut().unwrap() } else { d.layer_mut(bg).unwrap().surface_mut().unwrap() };
surf.fill_rect(Rect::new(-16, 0, 0, 48), &photocraft_raster::from_rgba(&fmt, if held_active { BLUE } else { RED }));
let id = layer.id;
d.layers.push(layer);
*active = Some(id);
Ok((bg, id))
})
.unwrap();
(s, bg, active)
}
/// Confirms that held pixels protect only the layer that owned them before the turn.
#[test]
fn turned_hidden_overhang_coverage_belongs_to_each_layer() {
for held_active in [false, true] {
let (mut s, bg, active) = overhang_scene(held_active);
crop(&mut s, json!({"x":-10,"y":14,"width":20,"height":20,"angle":45}), true);
let mut held_pixels = 0;
for y in 0..20 {
for x in 0..20 {
let (held, other, want) = if held_active { (active, bg, WHITE) } else { (bg, active, GREEN) };
pixel(doc(&s), other, x, y, want);
let px = doc(&s).layer(held).unwrap().surface().unwrap().rgba(x, y);
let color = if held_active { BLUE } else { RED };
held_pixels += usize::from(px.iter().zip(color).all(|(a, b)| (a - b).abs() < 0.06));
}
}
assert!(held_pixels >= 100, "the target's own hidden pixels survive: {held_pixels}");
}
}
/// An empty active raster leaves the successful Background fill and undo step intact.
#[test]
fn donorless_added_target_keeps_the_background_fill() {
for turned in [false, true] {
let (x, y) = extension_sample(turned);
let (mut s, ids) = scene(turned);
let empty = s
.edit("Empty target", |d, active| {
let layer = Layer::raster("Empty", d.pixel_format());
let id = layer.id;
d.layers.push(layer);
*active = Some(id);
Ok(id)
})
.unwrap();
assert_ne!(empty, ids.background);
let before = doc(&s).clone();
let p = frame(turned);
let plain = plain_control(&mut s, &p);
let past = s.active().unwrap().history.past_len();
crop(&mut s, p, true);
pixel(doc(&s), ids.background, x, y, WHITE);
unchanged(doc(&s), &plain, &[empty, ids.quads, ids.small, ids.edge, ids.hidden, ids.text]);
assert_eq!(s.active().unwrap().active_layer, Some(empty));
named_one_step(&s, past, "Content-aware Crop");
undo_redo(&mut s, &before);
}
}
/// Confirms both layers synthesize their own colours at every RGB depth.
#[test]
fn both_target_colours_remain_native_at_each_rgb_depth() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24,"depth":depth,"background":"white"})).unwrap();
let (bg, active) = s
.edit("Own colours", |d, active| {
let bg = d.background().unwrap().id;
let layer = raster(d, "Green", Rect::new(0, 0, 32, 24), GREEN);
let id = layer.id;
d.layers.push(layer);
*active = Some(id);
Ok((bg, id))
})
.unwrap();
crop(&mut s, json!({"x":0,"y":0,"width":40,"height":24}), true);
pixel(doc(&s), bg, 36, 12, WHITE);
pixel(doc(&s), active, 36, 12, GREEN);
}
}
+4
View File
@@ -13,15 +13,18 @@ mod atn_dialog_filters;
mod atn_direct_adjustments;
mod atn_layer_style;
mod atn_newer_events;
mod atn_perspective_warp;
mod atn_reference_forms;
mod atn_review_regressions;
mod atn_ripple_amount;
mod atn_selective_color;
mod atn_shape_blur;
mod atn_shear;
mod atn_stack_modes;
mod atn_stroke_width;
mod atn_type_path;
mod atn_v12;
mod atn_video_frames;
mod background_and_mask_depth;
mod background_layer_consistency;
mod brush_smoothing;
@@ -35,6 +38,7 @@ mod convert_keeps_references;
mod copy_shape_placement;
mod corpus_atn;
mod crop_content_aware;
mod crop_content_aware_layers;
mod crop_delete_off_background;
mod current_affinity_artboards;
mod duplicate_layer_comps;
@@ -63,6 +63,35 @@ fn re_placing_at_another_scale_re_renders_the_vector_source() {
assert!(red(&surf, 150, 81) < 0.02 && red(&surf, 150, 75) > 0.98 && red(&surf, 150, 87) > 0.98);
}
/// A `.pcraft` opens from its cached pixels and parses an embedded SVG source only on a later
/// edit. A source whose DTD entity hides 20 000 nested groups from the depth check used to open
/// clean and then overflow the stack on Image Size or a PSD save; both are an error or a fallback
/// now. Run on a 2 MB stack, as the app's import and command threads are.
#[test]
fn a_pcraft_smart_object_with_a_hostile_svg_does_not_abort_a_later_edit() {
let deep = format!("{}{}", "<g>".repeat(20_000), "</g>".repeat(20_000));
let hostile = format!("<!DOCTYPE svg [<!ENTITY a \"{deep}\">]><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\">&a;</svg>");
std::thread::Builder::new()
.stack_size(2 * 1024 * 1024)
.spawn(move || {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100, "background": "white"})).unwrap();
photocraft_engine::file_cmds::place_bytes(&mut s, "logo.svg", SVG.as_bytes().to_vec(), None, &json!({"scale": 100})).unwrap();
let mut doc = (*s.active().unwrap().doc).clone();
let LayerContent::Smart(sm) = &mut doc.layers.last_mut().unwrap().content else { panic!("smart object") };
sm.source = photocraft_doc::SmartSource::Embedded { file_name: "logo.svg".into(), bytes: std::sync::Arc::new(hostile.into_bytes()) };
let carrier = photocraft_format::save_to_bytes(&doc, &photocraft_format::SaveOptions::default()).unwrap();
photocraft_engine::file_cmds::open_bytes_as(&mut s, "carrier.pcraft", &carrier, None, None).unwrap();
let _ = s.execute("image.imageSize", json!({"width": 50, "height": 50}));
let dir = tempfile::tempdir().unwrap();
let _ = s.execute("file.saveACopy", json!({"path": dir.path().join("copy.psd").to_string_lossy()}));
})
.unwrap()
.join()
.unwrap();
}
/// Placed through a background job, an SVG is rendered at its placement scale on the worker and
/// lands exactly as the inline place does: still sharp, not a resampled raster.
#[test]
+2
View File
@@ -49,6 +49,8 @@ imagesize = "0.13"
# already in the tree through the UI's icon loader; text shaping needs the fonts feature set.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] }
# The SVG fallback font search keeps each font file's character map (already in the tree through usvg).
ttf-parser = { version = "0.25", default-features = false }
[target.'cfg(target_arch = "wasm32")'.dependencies]
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }
+16 -15
View File
@@ -38,7 +38,7 @@ const FILTER_ID: i32 = 1023;
const NAME: &str = "Perspective Warp...";
pub(super) const TEMPLATE_KEY: &str = "__perspectiveWarpPsd";
/// Far more planes than anyone lays out by hand; bounds the corner linking (quadratic).
const MAX_QUADS: usize = 256;
pub const MAX_QUADS: usize = 256;
const MAX_VERTICES: usize = 4 * MAX_QUADS;
const MAX_TEMPLATE_BYTES: usize = 1 << 20;
@@ -74,29 +74,29 @@ fn quads(f: &Descriptor) -> Option<Vec<[usize; 4]>> {
.map(|q| {
let Value::Descriptor(q) = q else { return None };
let Some(Value::List(ix)) = q.get("indices") else { return None };
let ix: Vec<usize> = ix
.iter()
.map(|i| match i {
Value::Integer(i) => usize::try_from(*i).ok(),
_ => None,
})
.collect::<Option<_>>()?;
ix.try_into().ok()
let [Value::Integer(a), Value::Integer(b), Value::Integer(c), Value::Integer(d)] = ix.as_slice() else { return None };
Some([usize::try_from(*a).ok()?, usize::try_from(*b).ok()?, usize::try_from(*c).ok()?, usize::try_from(*d).ok()?])
})
.collect()
}
/// The planes a `perspectiveWarpTransform` descriptor describes; `None` when it is inconsistent.
fn planes_of(f: &Descriptor) -> Option<Vec<Plane>> {
/// Reads the document-space planes and original corner indices from Perspective Warp geometry.
/// Callers validate the enclosing descriptor and target according to the file format.
pub fn indexed_geometry(f: &Descriptor) -> Option<(Vec<Plane>, Vec<[usize; 4]>)> {
let (src, dst) = (points(f, "vertices")?, points(f, "warpedVertices")?);
if src.len() != dst.len() {
return None;
}
let corners =
|list: &[[f64; 2]], q: &[usize; 4]| -> Option<[[f64; 2]; 4]> { Some([*list.get(q[0])?, *list.get(q[1])?, *list.get(q[2])?, *list.get(q[3])?]) };
let planes: Vec<Plane> = quads(f)?.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::<Option<_>>()?;
let indices = quads(f)?;
let planes: Vec<Plane> = indices.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::<Option<_>>()?;
PerspectiveMap::new(&planes)?;
Some(planes)
Some((planes, indices))
}
fn planes_of(f: &Descriptor) -> Option<Vec<Plane>> {
indexed_geometry(f).map(|(planes, _)| planes)
}
/// `layer.smartObjects.perspectiveWarp` params for a Perspective Warp item.
@@ -137,7 +137,8 @@ fn template(params: &J) -> Result<Option<Descriptor>, String> {
/// The planes to write: as rendered, so a `straighten` is baked in.
fn export_planes(params: &J) -> Result<Vec<Plane>, String> {
let object = params.as_object().ok_or("Perspective Warp parameters must be an object")?;
if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !["planes", "straighten", "interpolation"].contains(&k.as_str())) {
let supported = |k: &str| super::is_meta(k) || ["planes", "straighten", "interpolation"].contains(&k) || (k == "target" && params["target"] == "pixels");
if let Some(k) = object.keys().find(|k| !supported(k)) {
return Err(format!("Perspective Warp setting `{k}` has no PSD equivalent"));
}
let mut planes: Vec<Plane> =
@@ -161,7 +162,7 @@ fn pnt(p: [f64; 2]) -> Value {
/// `vertices`, `warpedVertices` and `quads` for `planes`; a corner shared by several planes (same
/// layout and warped position) is written once.
fn geometry(planes: &[Plane]) -> (Value, Value, Value) {
pub fn geometry(planes: &[Plane]) -> (Value, Value, Value) {
let mut corners: Vec<([f64; 2], [f64; 2])> = Vec::new();
let mut quads = Vec::with_capacity(planes.len());
for p in planes {
+3
View File
@@ -51,6 +51,9 @@ mod hue_sat;
mod perspective_warp;
pub use displace::{embed_map as displace_embed_map, embedded_map as displace_embedded_map, without_map as displace_without_map};
pub use perspective_warp::{
MAX_QUADS as MAX_PERSPECTIVE_WARP_PLANES, geometry as perspective_warp_geometry, indexed_geometry as perspective_warp_indexed_geometry,
};
mod export;
mod import;
+114 -9
View File
@@ -281,15 +281,78 @@ fn in_memory(id: usvg::fontdb::ID, db: &mut Arc<usvg::fontdb::Database>) -> usvg
loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id)
}
/// Most `cmap` table bytes [`has_char`] keeps; past it a face is asked on disk (slow, still right).
#[cfg(not(target_arch = "wasm32"))]
const MAX_CACHED_CMAP_BYTES: usize = 64 << 20;
/// Whether face `face` maps `c` to a glyph, as usvg's own check decides it. usvg reads a font
/// file's whole font for every face it asks, so one character no font has re-read every
/// installed font once per text element (430 ms each with 273 faces). A file face's `cmap` table
/// is kept instead, read once per process; a file that doesn't load isn't remembered.
#[cfg(not(target_arch = "wasm32"))]
fn has_char(db: &usvg::fontdb::Database, face: &usvg::fontdb::FaceInfo, c: char) -> bool {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Mutex, PoisonError};
use ttf_parser::{Face, Tag, cmap};
type Cmaps = (HashMap<(PathBuf, u32), Arc<Vec<u8>>>, usize);
static CMAPS: OnceLock<Mutex<Cmaps>> = OnceLock::new();
let maps = || CMAPS.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner);
let in_cmap =
|table: &[u8]| cmap::Table::parse(table).is_some_and(|t| t.subtables.into_iter().any(|s| s.is_unicode() && s.glyph_index(u32::from(c)).is_some()));
let usvg_check = |data: &[u8], index| Face::parse(data, index).is_ok_and(|f| f.glyph_index(c).is_some());
let usvg::fontdb::Source::File(path) = &face.source else {
return db.with_face_data(face.id, usvg_check).unwrap_or(false);
};
let key = (path.clone(), face.index);
if let Some(table) = maps().0.get(&key).cloned() {
return in_cmap(&table);
}
// usvg's check parses the whole face first: one that doesn't parse has no characters. Its
// `cmap` is the last such table record (a linear walk, unsorted directories included).
let cmap_of = |data: &[u8], index| {
let face = Face::parse(data, index).ok()?;
let raw = face.raw_face();
let record = raw.table_records.into_iter().filter(|r| r.tag == Tag::from_bytes(b"cmap")).last();
let table = record.and_then(|r| {
let start = usize::try_from(r.offset).ok()?;
raw.data.get(start..start.checked_add(usize::try_from(r.length).ok()?)?)
});
Some(table.unwrap_or_default().to_vec())
};
let Some(table) = db.with_face_data(face.id, cmap_of).flatten() else {
return false;
};
let found = in_cmap(&table);
let mut maps = maps();
// An import that missed at the same time may have kept it already.
if !maps.0.contains_key(&key) && maps.1.saturating_add(table.len()) <= MAX_CACHED_CMAP_BYTES {
maps.1 = maps.1.saturating_add(table.len());
maps.0.insert(key, Arc::new(table));
}
found
}
/// usvg's fallback choice (the first face, in database order, not used yet, sharing the base
/// face's style, weight or stretch and having `c`), asked through [`has_char`].
#[cfg(not(target_arch = "wasm32"))]
fn fallback_face(c: char, used: &[usvg::fontdb::ID], db: &usvg::fontdb::Database) -> Option<usvg::fontdb::ID> {
let base = db.face(*used.first()?)?;
db.faces()
.filter(|f| !used.contains(&f.id))
.filter(|f| base.style == f.style || base.weight == f.weight || base.stretch == f.stretch)
.find(|f| has_char(db, f, c))
.map(|f| f.id)
}
/// usvg's font choice, with the chosen faces read from memory ([`in_memory`]).
fn font_resolver() -> usvg::FontResolver<'static> {
#[cfg(not(target_arch = "wasm32"))]
{
let select = usvg::FontResolver::default_font_selector();
let fallback = usvg::FontResolver::default_fallback_selector();
usvg::FontResolver {
select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))),
select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))),
select_fallback: Box::new(|c, used, db| fallback_face(c, used, db).map(|id| in_memory(id, db))),
}
}
#[cfg(target_arch = "wasm32")]
@@ -329,7 +392,7 @@ fn options(fontdb: Arc<usvg::fontdb::Database>, depth_left: usize) -> usvg::Opti
Ok(std::borrow::Cow::Owned(xml)) if !xml.starts_with(&[0x1f, 0x8b]) => ("image/svg+xml", Arc::new(xml)),
_ => return None,
};
if nesting_depth(&xml, depth_left) > depth_left {
if !nesting_depth(&xml, depth_left).is_ok_and(|depth| depth <= depth_left) {
return None;
}
return default_resolver(mime, xml, opts);
@@ -366,7 +429,7 @@ fn parse(bytes: &[u8]) -> Result<Parsed, IoError> {
/// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one).
fn parse_with(bytes: &[u8], fonts: fn() -> Arc<usvg::fontdb::Database>) -> Result<Parsed, IoError> {
let xml = inflate(bytes)?;
let depth = nesting_depth(&xml, MAX_XML_DEPTH);
let depth = nesting_depth(&xml, MAX_XML_DEPTH)?;
if depth > MAX_XML_DEPTH {
return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep")));
}
@@ -627,7 +690,7 @@ fn parse_image_bounded_with(
if left.is_none() {
return Err(overrun(IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into())));
}
let nesting = nesting_depth(&xml, xml_left);
let nesting = nesting_depth(&xml, xml_left)?;
if nesting > xml_left {
return Err(IoError::Svg(format!("elements are nested more than {xml_left} levels deep")));
}
@@ -806,8 +869,10 @@ fn inflate_with_limit(bytes: &[u8], limit: u64) -> Result<std::borrow::Cow<'_, [
/// The deepest element nesting in `xml`, stopping as soon as it passes `limit` (the result is
/// then `limit + 1`). Comments, CDATA sections, processing instructions and the DOCTYPE add
/// nothing; quoted attribute values may hold `>` or `/>`. Malformed markup is only counted
/// loosely: the parser rejects it afterwards.
pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
/// loosely: the parser rejects it afterwards. The parser expands the DTD's entities after this
/// count, so an entity holding markup (a `<`) is an error: it could nest deeper than anything
/// counted here. A `&#60;` there stays text.
pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> Result<usize, IoError> {
let after = |from: usize, pat: &[u8]| -> usize {
xml.get(from..).and_then(|s| s.windows(pat.len()).position(|w| w == pat)).map_or(xml.len(), |p| from.saturating_add(p).saturating_add(pat.len()))
};
@@ -829,6 +894,44 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
}
(xml.len(), false)
};
// The byte after the DOCTYPE starting at `from`: its internal subset `[...]` is walked
// declaration by declaration, so a quote in a comment there can't hide the markup after it.
let doctype_end = |from: usize| -> Result<usize, IoError> {
let (mut quote, mut j) = (None, from);
loop {
let Some(&b) = xml.get(j) else { return Ok(xml.len()) };
j = j.saturating_add(1);
match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None if b == b'"' || b == b'\'' => quote = Some(b),
None if b == b'>' => return Ok(j),
None if b == b'[' => break,
None => {}
}
}
while let Some(rest) = xml.get(j..).filter(|r| !r.is_empty()) {
j = if rest.starts_with(b"<!--") {
after(j.saturating_add(4), b"-->")
} else if rest.starts_with(b"<?") {
after(j.saturating_add(2), b"?>")
} else if rest.starts_with(b"<!ENTITY") {
let end = tag_end(j.saturating_add(2)).0;
if xml.get(j.saturating_add(2)..end).is_some_and(|decl| decl.contains(&b'<')) {
return Err(IoError::Svg("the SVG's DTD declares an entity holding markup, which isn't supported".into()));
}
end
} else if rest.starts_with(b"<!") {
// The parser ends ELEMENT, ATTLIST and NOTATION at the first `>`, quotes or not.
after(j.saturating_add(2), b">")
} else if rest.starts_with(b"]") {
return Ok(tag_end(j.saturating_add(1)).0);
} else {
j.saturating_add(1)
};
}
Ok(xml.len())
};
let (mut depth, mut deepest, mut i) = (0usize, 0usize, 0usize);
while let Some(p) = xml.get(i..).and_then(|s| s.iter().position(|&b| b == b'<')) {
let at = i.saturating_add(p);
@@ -839,6 +942,8 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
after(at.saturating_add(9), b"]]>")
} else if rest.starts_with(b"<?") {
after(at.saturating_add(2), b"?>")
} else if rest.starts_with(b"<!DOCTYPE") {
doctype_end(at.saturating_add(9))?
} else if rest.starts_with(b"<!") {
tag_end(at.saturating_add(2)).0
} else if rest.starts_with(b"</") {
@@ -850,13 +955,13 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
depth = depth.saturating_add(1);
deepest = deepest.max(depth);
if deepest > limit {
return deepest;
return Ok(deepest);
}
}
end
};
}
deepest
Ok(deepest)
}
/// The document canvas for a drawing: its size in px, scaled down to [`MAX_SIDE`] when needed.
+73
View File
@@ -487,6 +487,79 @@ fn imports_missing_the_same_font_at_once_share_one_read() {
}
}
/// #9671: the fallback search for a character the text's font lacks reads each font file's
/// character map once per process, not the whole file on every text element (430 ms an
/// element with 273 installed faces). A second import still finds the fallback face after
/// its file stopped being readable, so it didn't go back to the disk.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_fallback_search_reads_each_font_file_once() {
use std::sync::atomic::{AtomicBool, Ordering};
static GONE: AtomicBool = AtomicBool::new(false);
fn path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("photocraft-svg-test-fallback-{}.ttf", std::process::id()))
}
fn inter_and_mono_on_disk() -> Arc<usvg::fontdb::Database> {
std::fs::write(path(), include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf")).unwrap();
let mut db = usvg::fontdb::Database::new();
db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec());
db.load_font_file(path()).unwrap();
if GONE.load(Ordering::SeqCst) {
std::fs::write(path(), b"not a font any more").unwrap();
}
Arc::new(db)
}
// Inter has no box-drawing characters; JetBrains Mono has.
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"><text x=\"5\" y=\"45\" font-size=\"48\" font-family=\"Inter\">I\u{2500}I</text></svg>";
let outlines = |parsed: Parsed| {
let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap().document.layers;
let groups = layers.into_iter().filter_map(|l| if let LayerContent::Group(g) = l.content { Some(g) } else { None });
groups.flat_map(|g| g.children).filter_map(|l| if let LayerContent::Shape(s) = l.content { Some(s.path) } else { None }).collect::<Vec<_>>()
};
let first = parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap();
let mono =
first.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_)));
assert!(mono, "the box-drawing character falls back to JetBrains Mono");
let first = outlines(first);
GONE.store(true, Ordering::SeqCst);
let second = outlines(parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap());
let _ = std::fs::remove_file(path());
assert!(first.iter().any(|p| !p.subpaths.is_empty()), "the text has outlines");
assert!(second == first, "the second import set the character in the same fallback face");
}
/// #9671: the kept `cmap` is the one usvg reads (the last such record of a linear walk), also
/// in a font whose table directory isn't sorted by tag, where a binary search misses it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_fallback_search_reads_the_cmap_of_an_unsorted_font() {
fn inter_and_unsorted_mono_on_disk() -> Arc<usvg::fontdb::Database> {
let mut font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec();
let count = usize::from(u16::from_be_bytes([font[4], font[5]]));
// The `cmap` record moves to the end of the directory.
let mut records: Vec<Vec<u8>> = font[12..12 + count * 16].chunks(16).map(<[u8]>::to_vec).collect();
let at = records.iter().position(|r| r.starts_with(b"cmap")).unwrap();
let cmap = records.remove(at);
records.push(cmap);
font.splice(12..12 + count * 16, records.concat());
let raw = ttf_parser::RawFace::parse(&font, 0).unwrap();
assert!(raw.table(ttf_parser::Tag::from_bytes(b"cmap")).is_none(), "a binary search misses the cmap");
assert!(raw.table(ttf_parser::Tag::from_bytes(b"name")).is_some(), "fontdb still finds the name");
let path = std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id()));
std::fs::write(&path, font).unwrap();
let mut db = usvg::fontdb::Database::new();
db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec());
db.load_font_file(&path).unwrap();
Arc::new(db)
}
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">I\xE2\x94\x80I</text></svg>";
let parsed = parse_with(svg, inter_and_unsorted_mono_on_disk).unwrap();
let _ = std::fs::remove_file(std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id())));
let mono =
parsed.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_)));
assert!(mono, "the box-drawing character falls back to JetBrains Mono");
}
/// #9406: moving a font collection into memory keeps its faces in their order, so the
/// fallback search for a later text still finds the face it found on disk first.
#[cfg(not(target_arch = "wasm32"))]
+1 -1
View File
@@ -15,7 +15,7 @@ pub fn parse<'i>(text: &'i str, opts: ParsingOptions<'i>) -> Result<Document<'i>
if opts.allow_dtd && internal_subset(text.as_bytes()) {
return Err("internal DTD subsets aren't supported".into());
}
if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH) > MAX_DEPTH {
if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH).map_err(|e| e.to_string())? > MAX_DEPTH {
return Err(format!("elements are nested more than {MAX_DEPTH} levels deep"));
}
Document::parse_with_options(text, opts).map_err(|e| e.to_string())
+47
View File
@@ -411,6 +411,53 @@ fn deeply_nested_markup_is_an_error_not_a_stack_overflow() {
assert_eq!(r.document.layers.len(), 1);
}
/// The parser expands a DTD's internal entities after the depth check: an entity holding
/// markup, or a quote in a DTD comment or declaration that made the check skip ahead, hid 20 000 nested groups
/// from it and overflowed the stack again. Both are errors now; same small stack as above.
#[test]
fn a_dtd_cannot_hide_nesting_from_the_depth_check() {
let deep = |open: &str, close: &str| format!("{}{}", open.repeat(20_000), close.repeat(20_000));
let with_dtd = |subset: &str, body: &str| format!("<!DOCTYPE svg [{subset}]><svg {NS} width=\"8\" height=\"8\">{body}</svg>");
let cases = [
("entity", with_dtd(&format!("<!ENTITY a \"{}\">", deep("<g>", "</g>")), "&a;")),
("single-quoted entity", with_dtd(&format!("<!ENTITY a '{}'>", deep("<g>", "</g>")), "&a;")),
("quote in a comment", with_dtd("<!-- ' -->", &format!("{}<desc>'</desc>", deep("<g>", "</g>")))),
("quote in an element declaration", with_dtd("<!ELEMENT x '>", &format!("{}<desc>'</desc>", deep("<g>", "</g>")))),
(
"entity between quoted attribute lists",
with_dtd(&format!("<!ATTLIST svg x CDATA '><!ENTITY a \"{}\"><!ATTLIST svg y CDATA '>", deep("<g>", "</g>")), "&a;"),
),
("entity between quoted notations", with_dtd(&format!("<!NOTATION n SYSTEM '><!ENTITY a \"{}\"><!NOTATION m SYSTEM '>", deep("<g>", "</g>")), "&a;")),
];
std::thread::Builder::new()
.stack_size(512 * 1024)
.spawn(move || {
for (name, svg) in &cases {
let gz = gzip(svg.as_bytes());
for bytes in [svg.as_bytes(), gz.as_slice()] {
let e = import("deep.svg", bytes).err().unwrap_or_else(|| panic!("{name} opened"));
assert!(matches!(&e, IoError::Svg(_)), "{name}: {e}");
}
}
})
.unwrap()
.join()
.unwrap();
}
/// Text-only entities, as Illustrator declares its namespaces, still open, `&#60;` included:
/// the parser keeps a character reference in an entity as text.
#[test]
fn text_entities_in_a_dtd_still_open() {
let r = open(
"<?xml version=\"1.0\"?><!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\
<!ENTITY ns_svg \"http://www.w3.org/2000/svg\"> <!-- a ' and a <g> --> <?pi > ?> <!ATTLIST svg data-x CDATA '<'> <!ENTITY red '#ff0000'> <!ENTITY c \"&#169; &#60;g&#62;\"> ]>\
<svg xmlns=\"&ns_svg;\" width=\"8\" height=\"8\"><title>&c;</title><rect width=\"8\" height=\"8\" fill=\"&red;\"/></svg>",
);
assert_eq!(r.document.layers.len(), 1);
assert!(close(px(&r.document, 4, 4), [1.0, 0.0, 0.0, 1.0]));
}
/// The depth check reads the markup, not the parser's tree: it must not count `<` in comments,
/// CDATA, processing instructions or the DOCTYPE, nor end a tag at a `>` inside quotes.
#[test]
+2
View File
@@ -313,6 +313,8 @@ impl PhotocraftApp {
self.ui.selected_path = None;
}
self.path_document = document;
// A shape box handle drag belongs to its document, revision and tool.
crate::shape_bounds_box::cancel_stale(self);
vector_ui::cancel_stale_pen(self);
crate::direct_select::cancel_stale(self);
canvas::cancel_stale_vector_drag(self);
+2
View File
@@ -637,6 +637,8 @@ impl PhotocraftApp {
// The job locked the document, but record the revision that was written.
st.saved_revision = revision;
app.remember_stamp(doc, path);
// History Options › Automatically Create New Snapshot When Saving (#2966).
photocraft_engine::history_options_cmds::snapshot_on_save(&mut app.session);
}
// "Save Document" script events and File › Generate › Image Assets.
if hooks && let Some(r) = photocraft_engine::automate_cmds::document_saved(&mut app.session, doc) {
@@ -0,0 +1,219 @@
//! The Artboard tool's Add New Artboard placing mode (#11386): with the mode on, a click on empty
//! canvas puts a board of the bar's size with its top-left corner at the click.
use egui::Modifiers;
use photocraft_geom::Rect;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
/// The measured document: two boards, the 750 x 1334 one selected, the other one apart from it.
fn app_with_selected_board() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
app.run("layer.new.artboard", json!({"rect": [2000, 0, 300, 300]})).unwrap();
app.run("layer.new.artboard", json!({"rect": [900, 0, 750, 1334]})).unwrap();
app.sync_views();
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
app.ui.tool = Tool::Artboard;
app
}
fn click(app: &mut PhotocraftApp, x: f64, y: f64) {
let m = Modifiers::NONE;
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m);
tool_event(app, ToolEvent::Up { x, y }, m);
}
fn boards(app: &PhotocraftApp) -> Vec<Rect> {
app.session.active().unwrap().doc.artboards().iter().map(|b| b.2.rect).collect()
}
fn last_step(app: &PhotocraftApp) -> Option<String> {
app.session.active().unwrap().history.entries().last().cloned()
}
#[test]
fn a_click_with_the_mode_on_puts_a_bar_sized_board_at_the_click() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
let before = boards(&app);
click(&mut app, 222.0, 600.0);
assert!(!app.ui.status_error, "{}", app.ui.status);
let after = boards(&app);
assert_eq!(after.len(), before.len() + 1);
// 750 x 1334, top-left at the click, over the selected board (it did not move aside).
assert_eq!(after.last().copied(), Some(Rect::new(222, 600, 972, 1934)));
assert_eq!(after.get(..before.len()), Some(before.as_slice()));
let st = app.session.active().unwrap();
assert_eq!(st.doc.layers.last().map(|l| l.name.as_str()), Some("Artboard 3"));
assert_eq!(st.active_layer, Some(st.doc.artboards().last().unwrap().0));
assert_eq!(last_step(&app).as_deref(), Some("New Artboard"));
// One undo step.
app.run("edit.undo", json!({})).unwrap();
assert_eq!(boards(&app), before);
}
#[test]
fn the_click_is_rounded_to_whole_pixels() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
click(&mut app, 222.4, 600.6);
assert_eq!(boards(&app).last().copied(), Some(Rect::new(222, 601, 972, 1935)));
}
#[test]
fn the_mode_stays_on_after_a_board_is_placed() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
click(&mut app, 222.0, 600.0);
assert!(app.ui.artboard_tool.adding);
// The next click is another placement, at the new selected board's size.
click(&mut app, 3000.0, 100.0);
assert_eq!(boards(&app).last().copied(), Some(Rect::new(3000, 100, 3750, 1434)));
}
#[test]
fn a_click_with_the_mode_off_still_places_nothing() {
let mut app = app_with_selected_board();
let before = boards(&app);
click(&mut app, 222.0, 600.0);
assert_eq!(boards(&app), before);
}
#[test]
fn with_no_board_the_click_uses_the_canvas_size() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
app.sync_views();
app.ui.tool = Tool::Artboard;
app.ui.artboard_tool.adding = true;
click(&mut app, 40.0, 50.0);
assert_eq!(boards(&app), vec![Rect::new(40, 50, 840, 650)]);
}
#[test]
fn a_drag_with_the_mode_on_still_draws_the_dragged_board() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
let m = Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 80.0, y: 70.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 120.0, y: 110.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 120.0, y: 110.0 }, m);
assert_eq!(boards(&app).last().copied(), Some(Rect::new(20, 30, 120, 110)));
}
#[test]
fn a_click_on_an_existing_board_selects_it_and_places_nothing() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
let before = boards(&app);
click(&mut app, 2100.0, 100.0);
assert_eq!(boards(&app), before);
let st = app.session.active().unwrap();
assert_eq!(st.active_layer, Some(st.doc.artboards()[0].0));
}
#[test]
fn another_tool_turns_the_mode_off() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
assert_eq!(serde_json::to_value(&app.ui.artboard_tool).unwrap()["adding"], json!(true));
app.ui.tool = Tool::Move;
click(&mut app, 3000.0, 100.0);
assert!(!app.ui.artboard_tool.adding);
app.ui.tool = Tool::Artboard;
let before = boards(&app);
click(&mut app, 3000.0, 100.0);
assert_eq!(boards(&app), before);
}
#[test]
fn ui_inspect_reports_the_mode_on_and_off() {
let ctx = egui::Context::default();
let mut app = app_with_selected_board();
let adding = |app: &mut PhotocraftApp| {
let (req, _) = crate::control::ControlRequest::new("ui.inspect", json!({}));
let crate::control::Outcome::Done(v) = crate::control::handle(app, &ctx, &req) else { panic!("inspect") };
v["result"]["artboardTool"]["adding"].clone()
};
assert_eq!(adding(&mut app), json!(false));
app.ui.artboard_tool.adding = true;
assert_eq!(adding(&mut app), json!(true));
}
#[test]
fn selecting_another_tool_and_coming_back_leaves_the_mode_off() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
// Both changes in one pass, with no pointer or options-bar call between them.
crate::tool_memory::select(&mut app, Tool::Move);
crate::tool_memory::select(&mut app, Tool::Artboard);
assert!(!app.ui.artboard_tool.adding);
let before = boards(&app);
click(&mut app, 222.0, 600.0);
assert_eq!(boards(&app), before);
}
#[test]
fn the_add_new_artboard_button_toggles_the_mode_and_adds_no_board() {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
let mut app = app_with_selected_board();
app.ui.tool = Tool::Artboard;
let before = boards(&app);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 200.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
crate::panels::options_bar(app, ui);
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(4);
assert!(!h.state().ui.artboard_tool.adding);
h.get_by_label("Add New Artboard").click();
h.run_steps(4);
assert!(h.state().ui.artboard_tool.adding, "the first click turns the mode on");
assert_eq!(boards(h.state()), before);
h.get_by_label("Add New Artboard").click();
h.run_steps(4);
assert!(!h.state().ui.artboard_tool.adding, "the second click turns it off");
assert_eq!(boards(h.state()), before);
}
#[test]
fn the_tool_shortcuts_away_and_back_leave_the_mode_off() {
let mut app = app_with_selected_board();
app.ui.artboard_tool.adding = true;
let ctx = egui::Context::default();
let press = |app: &mut PhotocraftApp, modifiers| {
let key = egui::Key::V;
let raw = egui::RawInput {
events: vec![
egui::Event::ModifiersChanged(modifiers),
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
],
..Default::default()
};
ctx.begin_pass(raw);
crate::shortcuts::handle(app, &ctx);
ctx.end_pass().textures_delta.clear();
};
// Shift+V leaves the Artboard tool for Move, and Shift+V again comes back, with no pointer or options-bar call between.
press(&mut app, Modifiers::SHIFT);
assert_eq!(app.ui.tool, Tool::Move);
press(&mut app, Modifiers::SHIFT);
assert_eq!(app.ui.tool, Tool::Artboard);
assert!(!app.ui.artboard_tool.adding);
let before = boards(&app);
click(&mut app, 222.0, 600.0);
assert_eq!(boards(&app), before);
}
@@ -0,0 +1,35 @@
//! The Artboard tool's first drag takes the Background in as "Layer 0" (#11387): the drag over
//! the measured 800 x 600 document gives Artboard 1 (holding Layer 0) and Red at the top level.
use egui::Modifiers;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
#[test]
fn the_tools_first_drag_moves_the_background_into_the_board() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
app.run("layer.new.layer", json!({"name": "Red"})).unwrap();
app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 150})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app.ui.tool = Tool::Artboard;
let m = Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 400.0, y: 300.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 500.0, y: 400.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 600.0, y: 500.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 600.0, y: 500.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
let st = app.session.active().unwrap();
let top: Vec<&str> = st.doc.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(top, ["Red", "Artboard 1"]);
assert!(st.doc.background().is_none());
let board = st.doc.layers.last().unwrap();
let kids: Vec<&str> = board.children().unwrap().iter().map(|l| l.name.as_str()).collect();
assert_eq!(kids, ["Layer 0"]);
assert_eq!((st.doc.size.width, st.doc.size.height), (800, 600));
assert_eq!(st.history.entries().last().map(String::as_str), Some("New Artboard"));
}
@@ -0,0 +1,101 @@
//! #5532: the Layers panel's Blend Mode list draws a line between Photoshop's six groups of modes:
//! Normal, Dissolve | Darken … Darker Color | Lighten … Lighter Color | Overlay … Hard Mix |
//! Difference … Divide | Hue … Luminosity.
use egui::accesskit::Role;
use egui::{Pos2, Rect, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::BlendMode;
use serde_json::json;
use crate::PhotocraftApp;
/// The app at 1440×900 with a new document and a pixel layer above the Background, active.
fn harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app
});
h.run_steps(8);
h
}
/// The Layers panel's dropdown: the lowest one (the options bar and the title bar have some too).
fn dropdown(h: &Harness<'_, PhotocraftApp>) -> Rect {
h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).unwrap()
}
/// The open list's rect (the one foreground area: no other popup is open).
fn open_list(h: &Harness<'_, PhotocraftApp>) -> Option<Rect> {
h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id)))
}
fn blend(h: &Harness<'_, PhotocraftApp>) -> BlendMode {
let st = h.state().session.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().blend
}
fn steps(h: &Harness<'_, PhotocraftApp>) -> usize {
h.state().session.active().unwrap().history.past_len()
}
/// Presses at `at`, holds for `held` frames and releases there.
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, held: usize) {
h.hover_at(at);
h.run_steps(1);
for (pressed, frames) in [(true, held), (false, 4)] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.run_steps(frames);
}
}
#[test]
fn the_layers_blend_mode_list_has_a_line_between_each_group() {
let mut h = harness();
let at = dropdown(&h).center();
click(&mut h, at, 1);
assert!(open_list(&h).is_some(), "the list is open");
// Each mode's row in the open list: its label's last node (the box shows the chosen one's
// first). Rows scrolled out of the list keep their place in it, so the gaps hold for all.
let rows: Vec<Rect> = BlendMode::LAYER_MODES
.iter()
.map(|m| h.get_all_by_label(m.label()).map(|n| n.rect()).next_back().unwrap_or_else(|| panic!("{m:?} is in the list")))
.collect();
let gaps: Vec<f32> = rows.windows(2).map(|w| w[1].top() - w[0].bottom()).collect();
let row_gap = gaps.iter().copied().fold(f32::INFINITY, f32::min);
let lines: Vec<(BlendMode, BlendMode)> =
gaps.iter().enumerate().filter(|(_, g)| **g > row_gap + 2.0).map(|(i, _)| (BlendMode::LAYER_MODES[i], BlendMode::LAYER_MODES[i + 1])).collect();
assert_eq!(
lines,
[
(BlendMode::Dissolve, BlendMode::Darken),
(BlendMode::DarkerColor, BlendMode::Lighten),
(BlendMode::LighterColor, BlendMode::Overlay),
(BlendMode::HardMix, BlendMode::Difference),
(BlendMode::Divide, BlendMode::Hue),
],
"row gaps {gaps:?}"
);
}
/// A plain click on the Layers panel's Blend Mode box at 1440×900, released where it was pressed
/// (after one frame, or after a few as a person's click takes), opens the list beside the box and
/// chooses nothing: the list doesn't open over the box, where the release would pick a row
/// (#5495 review of a taller list).
#[test]
fn a_click_on_the_blend_mode_box_opens_the_list_and_changes_nothing() {
for held in [6, 1] {
let mut h = harness();
let undo = steps(&h);
let button = dropdown(&h);
click(&mut h, button.center(), held);
assert_eq!((blend(&h), steps(&h)), (BlendMode::Normal, undo), "held {held}: the click chose nothing");
let Some(list) = open_list(&h) else { panic!("held {held}: the list is open") };
assert!(!list.intersects(button.shrink(1.0)), "held {held}: the list {list:?} leaves the box {button:?} free");
assert!(list.top() >= 0.0 && list.bottom() <= 900.0, "held {held}: the list {list:?} is in the window");
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ pub(crate) fn choices() -> Vec<((Option<BlendMode>, bool), &'static str)> {
fn blend_items(behind: bool, clear: bool) -> Vec<Item> {
let mut out = Vec::new();
for m in BlendMode::LAYER_MODES {
if matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) {
if crate::panels::starts_blend_group(m) {
out.push(Item::Separator);
}
out.push(Item::Blend(m));
+15 -3
View File
@@ -45,7 +45,7 @@ mod selection;
pub mod snap_ui;
mod start_screen;
mod strokes;
mod tabs;
pub(crate) mod tabs;
mod textures;
pub(super) mod tile_overlay;
pub mod tool_cursor;
@@ -1422,6 +1422,9 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C
// corner badge, as Photoshop's corner radius cursor.
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
crate::icons::cursor_badge(ctx, "scan", p);
} else if let Some(c) = response.hover_pos().and_then(|p| crate::shape_bounds_box::cursor(app, xf, xf.to_doc(p))) {
// Over a handle of the box round several selected shapes: its scale cursor.
ui.ctx().set_cursor_icon(c);
} else if let Some((r, p, c)) =
response.hover_pos().and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c))))
{
@@ -1699,9 +1702,14 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie
painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside));
}
// A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the
// drag's next move then extends into what scrolled in.
// drag's next move then extends into what scrolled in. The crop tools keep their own drags:
// they scroll only while the press is the canvas's own (not a panel slider or a scrollbar held
// while a stale crop drag lingers) and the crop tool has it (not a temporary Zoom, which
// freezes the drag).
let crop_drag =
response.is_pointer_button_down_on() && tool == app.ui.tool && (crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app));
if primary
&& app.drag.is_some()
&& (app.drag.is_some() || crop_drag)
&& tool != Tool::Hand
&& !middle
&& let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten())
@@ -1962,6 +1970,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::crop_straighten::set_held(app, !ctx.text_edit_focused() && ui.input(|i| i.modifiers.command));
// A tab switched this frame drops the other document's pending crop (#1918).
crate::crop_ui::cancel_stale(app);
// A tool picked mid-drag ends the crop gesture before the frame sees the switch, which would
// otherwise drop the untouched default frame under the box being drawn (#9223). The Hand and
// Zoom never reach `tool_event`, so this is the one place that sees every tool.
crate::crop_ui::end_drag_on_leave(app);
crate::crop_ui::ensure_frame(app);
let mut drawing = crate::crop_ui::active(app);
if let Some(pen) = app.ui.pen.as_mut().filter(|pen| pen.dragging && !pen.curvature) {
@@ -184,9 +184,9 @@ const ENDS_TYPE_EDIT: [&str; 5] =
pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor";
/// The guide menu's Edit Selected Guides row: New Guide's dialog on the selected guide.
pub const GUIDE_EDIT: &str = "guides.editSelected";
pub const GUIDE_EDIT: &str = "view.editSelectedGuides";
/// The guide menu's Clear Selected Guides row.
pub const GUIDE_CLEAR: &str = "guides.clearSelected";
pub const GUIDE_CLEAR: &str = "view.clearSelectedGuides";
/// Photoshop's guide menu (right-click on a guide), in its order. Its two Selected Guides rows act
/// on the guide the right-click selected; the rest are the View menu's.
@@ -753,14 +753,9 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui
true
}
/// A guide row: the two Selected Guides rows act on the selected guide; the rest are the View
/// menu's commands.
/// A guide row: every row is a View › Guides command, the two Selected Guides ones included.
fn choose_guide(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> {
match command {
GUIDE_EDIT => crate::new_guide_ui::open_edit(app).map(|_| ()),
GUIDE_CLEAR => crate::guide_select::delete_selected(app).map(|_| ()),
_ => crate::menus::invoke(app, ctx, command, json!({})).map(|_| ()),
}
crate::menus::invoke(app, ctx, command, json!({})).map(|_| ())
}
/// A pasteboard row: set the colour, or open the Color Picker on the custom one.
+35 -5
View File
@@ -27,6 +27,9 @@ pub struct Drag {
/// `Some(false)` moves the outline, `Some(true)` moves the floating piece (`select.float`), as
/// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`).
pub sel_move: Option<bool>,
/// The pointer, before snapping, has been half a pixel or more from the press: the gesture is
/// not a click even if it comes back there (`tool_input::selection_release`, #11392).
pub left_press: bool,
/// The last ⇧-locked offset of a selection-tool drag of the selection (`move_mods`, #6231);
/// keeps the locked direction stable between events. The Move tool's own drags use `move_mods`.
pub sel_axis: Option<[f64; 2]>,
@@ -34,6 +37,10 @@ pub struct Drag {
/// Freeform Pen and Path Selection gestures belong to their source document and target.
pub(super) vector_owner: Option<VectorGestureOwner>,
pub(super) pen_owner: Option<crate::vector_ui::PenOwner>,
/// The document had a selection (one with content) when the drag was pressed. A modifier held
/// at the press picks the selection mode only then; without one it constrains the shape from
/// the first move (#11389). `true` until recorded, which keeps that rule for drags that don't.
pub selection_at_press: bool,
}
impl Drag {
@@ -50,13 +57,21 @@ impl Drag {
released: egui::Modifiers::NONE,
reposition: false,
sel_move: None,
left_press: false,
sel_axis: None,
lasso: None,
vector_owner: None,
pen_owner: None,
selection_at_press: true,
}
}
/// Record whether the document has a selection at the press; an empty one counts as none, as
/// `tool_feedback::document_selection_mode` counts it (#11389).
pub(crate) fn capture_selection_at_press(&mut self, app: &PhotocraftApp) {
self.selection_at_press = app.session.active().and_then(|st| st.doc.selection.as_ref()).is_some_and(|s| !s.content_bounds().is_empty());
}
/// Vector drags capture their owner at the press, the Freeform Pen also its destination, before
/// later ⌥-clicks or a temporary Direct Selection can finish them.
pub(crate) fn capture_vector_owners(&mut self, app: &PhotocraftApp) {
@@ -92,13 +107,22 @@ impl Drag {
self.released.alt |= !mods.alt;
}
/// ⇧ (square / circle) and ⌥ (from the centre) for a marquee drag. A modifier held at the press
/// picks the selection mode instead (add / subtract, #188) until it is released and pressed
/// again, as in Photoshop.
/// ⇧ (square / circle) and ⌥ (from the centre) for a marquee drag. With a selection at the
/// press, a modifier held at the press picks the selection mode instead (add / subtract, #188)
/// until it is released and pressed again, as in Photoshop; without one it constrains the
/// shape from the first move (#11389).
pub fn marquee_mods(&self) -> (bool, bool) {
let fresh = |now: bool, at_press: bool, released: bool| now && (!at_press || released);
let picks_mode = self.selection_at_press;
let fresh = |now: bool, at_press: bool, released: bool| now && (!at_press || released || !picks_mode);
(fresh(self.live.shift, self.modifiers.shift, self.released.shift), fresh(self.live.alt, self.modifiers.alt, self.released.alt))
}
/// ⇧ held since the press with no selection: the square takes the drag's shorter side, as the
/// reference application does (a 200 × 100 drag selects 100 × 100, #11389). ⇧ pressed during
/// the drag keeps the longer side (not measured, so as before).
fn square_from_press(&self) -> bool {
!self.selection_at_press && self.modifiers.shift && !self.released.shift && self.live.shift
}
}
/// The marquee dragged from `d.start` to `end` as two document-space corners: the options-bar
@@ -113,7 +137,13 @@ pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -
let a = if alt { [end[0] - w / 2.0, end[1] - h / 2.0] } else { end };
return (a, [a[0] + w, a[1] + h]);
}
let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end);
let e = if shift && d.square_from_press() && !matches!(o.marquee_style.as_str(), "fixedRatio" | "fixedSize") {
let (dx, dy) = (end[0] - d.start[0], end[1] - d.start[1]);
let m = dx.abs().min(dy.abs());
[d.start[0] + m.copysign(dx), d.start[1] + m.copysign(dy)]
} else {
crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end)
};
if !alt {
return (d.start, e);
}
+60 -5
View File
@@ -7,6 +7,19 @@ use super::textures::shown_as_document;
use super::{PhotocraftApp, Tool, json};
pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
finish(app, d, None);
}
/// [`finish_gesture`] for a pointer release. `click`: the release, before snapping, came back
/// within half a pixel of the press (`tool_input`). A selection drag that snapping pulled back onto
/// its press point is not one (#11392).
pub(crate) fn finish_release(app: &mut PhotocraftApp, d: Drag, click: bool) {
finish(app, d, Some(click));
}
/// `click`: whether the gesture was a click, when the caller saw the release before snapping;
/// otherwise the end point decides.
fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option<bool>) {
// Control can select a different tool and release before the next frame cancels the drag.
// Cancel before even recording a Shift-stroke anchor, including the preview fallback paths.
if d.tool == Tool::MixerBrush && app.ui.tool != Tool::MixerBrush {
@@ -20,9 +33,9 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]);
end[0] = sx;
end[1] = sy;
if let Some(floating) = d.sel_move {
finish_selection_drag(app, floating, d.start, [end[0], end[1]]);
return;
if d.sel_move.is_some() {
let Some(line) = finish_selection_release(app, &mut d, end, click) else { return };
end = line;
}
// Where the next ⇧-click line starts.
if crate::stroke_constraint::connects(d.tool)
@@ -153,6 +166,41 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
}
}
/// The release of selection drag `d` at `end` (`click`: see [`finish`]). A Single Row / Single
/// Column click inside the selection or on a floating piece selects a line, as it does elsewhere,
/// instead of deselecting or dropping as other marquees do (#11392): the point it selects at, for
/// the tool's own release to go on with. `None`: the release is done. A click ends here unsnapped
/// (`tool_input`), so it is never a move; the line snaps as a click outside the selection does (its
/// press began as a move, which keeps the press point).
fn finish_selection_release(app: &mut PhotocraftApp, d: &mut Drag, end: [f64; 3], click: Option<bool>) -> Option<[f64; 3]> {
let floating = d.sel_move?;
let unmoved = (end[0] - d.start[0]).round() == 0.0 && (end[1] - d.start[1]).round() == 0.0;
let click = click.unwrap_or(unmoved);
if unmoved && !click {
// A drag that snapping pulled back where it started leaves the selection as it was. Its
// preview of a floating piece goes too: nothing changed for it to go stale with, and a
// Move-tool drag's release (`move_ui::finish`) is not reached. It is rebuilt as needed.
app.move_preview = None;
return None;
}
if !click || !matches!(d.tool, Tool::SingleRowMarquee | Tool::SingleColumnMarquee) {
finish_selection_drag(app, floating, d.start, [end[0], end[1]]);
return None;
}
// The piece drops where it is first, as a press off it drops it (`tool_input`).
if floating
&& app.session.active().and_then(photocraft_engine::float_cmds::floating).is_some()
&& let Err(e) = app.run("select.drop", json!({}))
{
app.ui.status = e;
app.ui.status_error = true;
return None;
}
let [x, y] = crate::snap_ui::snap_click(app, d.start, d.live);
d.points.push([x, y, end[2]]);
Some([x, y, end[2]])
}
/// Selection mode from the options bar, overridden by modifier keys (⇧ add, ⌥ subtract, ⇧⌥ intersect).
/// The cursor badge announces the same mode (`tool_feedback`).
pub(crate) fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'static str {
@@ -161,6 +209,8 @@ pub(crate) fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'stati
/// Shift constrains a Polygonal Lasso segment to Photoshop's 45° directions.
/// Work in document coordinates so zoom, view rotation and mirroring cannot skew it.
/// A diagonal keeps the shorter of the two offsets on both axes, as Photoshop does (#11393);
/// a horizontal or vertical snap keeps the segment's length.
pub(crate) fn constrained_polygon_point(from: [f64; 2], point: [f64; 2]) -> [f64; 2] {
let dx = point[0] - from[0];
let dy = point[1] - from[1];
@@ -171,7 +221,12 @@ pub(crate) fn constrained_polygon_point(from: [f64; 2], point: [f64; 2]) -> [f64
if length == 0.0 {
return point;
}
let angle = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
let octant = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round();
let angle = octant * std::f64::consts::FRAC_PI_4;
if octant.rem_euclid(2.0) == 1.0 {
let m = dx.abs().min(dy.abs());
return [from[0] + m * angle.cos().signum(), from[1] + m * angle.sin().signum()];
}
[from[0] + length * angle.cos(), from[1] + length * angle.sin()]
}
@@ -246,7 +301,7 @@ pub fn commit_polygon(app: &mut PhotocraftApp) {
if pts.len() >= 3 {
let mode = if mode.is_empty() { selection_mode(app, egui::Modifiers::NONE).to_owned() } else { mode };
let o = &app.ui.tool_options;
let _ = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": o.feather}));
let _ = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": o.feather, "polygonal": true}));
}
}
+11 -1
View File
@@ -36,9 +36,19 @@ pub(crate) fn moves_selected_pixels_with(app: &PhotocraftApp, tool: Tool) -> boo
/// time, or with `copy` (⌥) floats a duplicate and leaves them; `copy` on a piece already floating
/// drops it where it is and floats a copy of it. False when it failed (reported in the status bar).
pub(crate) fn float_selected(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f64) -> bool {
float_selected_as(app, copy, dx, dy, None)
}
/// The start of a Move-tool drag of the selected pixels. An ⌥-drag copy is one "Duplicate" step,
/// as in Photoshop (#11383); a plain drag is "Move".
pub(crate) fn float_selected_for_drag(app: &mut PhotocraftApp, copy: bool) -> bool {
float_selected_as(app, copy, 0.0, 0.0, copy.then(|| tl!("Duplicate")))
}
fn float_selected_as(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f64, step_name: Option<&str>) -> bool {
let floating = app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some());
let dropped = if copy && floating { app.run("select.drop", json!({})) } else { Ok(json!(null)) };
match dropped.and_then(|_| app.run("select.float", json!({"dx": dx, "dy": dy, "copy": copy}))) {
match dropped.and_then(|_| app.run("select.float", json!({"dx": dx, "dy": dy, "copy": copy, "stepName": step_name}))) {
Ok(_) => {
crate::snap_ui::follow_cut(app);
true
+1 -1
View File
@@ -96,7 +96,7 @@ pub(super) fn finish_selection_drag(app: &mut PhotocraftApp, floating: bool, sta
(false, true) if app.session.is_enabled("select.deselect") => app.run("select.deselect", json!({})),
(false, true) => return,
(true, false) => app.run("select.float", json!({"dx": dx, "dy": dy})),
(false, false) => app.run("select.transformSelection", json!({"dx": dx, "dy": dy})),
(false, false) => app.run("select.transformSelection", json!({"dx": dx, "dy": dy, "stepName": "Move Selection"})),
};
if let Err(e) = r {
app.ui.status = e;
+40 -1
View File
@@ -202,6 +202,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
let tool = crate::canvas::event_tool(app, mods);
let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods);
let crop_tol = crate::crop_ui::tolerance(app);
let straightens = tool == Tool::Crop && app.crop.drag.is_none() && (app.crop.straighten.armed || mods.command);
let gesture = if let Some(t) = &app.ui.transform {
use crate::transform_tool::Hit;
let h = crate::transform_tool::box_hit(app, t, p, crate::transform_tool::handle_tolerance(app));
@@ -231,6 +232,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
} else if tool == Tool::Move || moves_layer {
let exclude = app.session.active().map(|s| s.selected_layers()).unwrap_or_default();
moving_rect(app, tool).map(|rect| (Gesture::Move { rect }, exclude))
} else if straightens {
// A straighten line (`crop_straighten::pointer` takes the press before the frame's drags)
// snaps its ends as points wherever it starts, never by the offset of a frame or image it
// doesn't move.
Some((Gesture::Point, Vec::new()))
} else if tool == Tool::Crop
&& (crate::crop_mode::classic(app) || app.crop.default_frame)
&& let Some(rect) =
@@ -297,7 +303,10 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
} else {
SnapTargets::default()
};
app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false });
// Windows Ctrl both starts a straighten line and is held while it is drawn: the override then
// covers the press too, or the line's start would snap and its end not.
let disabled = straightens && override_held(mods);
app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled });
}
/// Snap a guide drag that ⌘ with a selection tool starts at `p` (`canvas::command_guide_at`,
@@ -308,6 +317,19 @@ pub fn begin_guide(app: &mut PhotocraftApp, p: [f64; 2]) {
app.prefs_rt.snap = Some(ActiveSnap { gesture: Gesture::Guide, start: p, targets, smart: SnapTargets::default(), disabled: false });
}
/// Snap `p` as a point press does (`Gesture::Point`), for a press that began as a selection move
/// and ended as a click: a Single Row / Column Marquee click inside the selection selects the line
/// at the snapped point, as one outside does (#11392). Not to the selection's own bounds, which the
/// click replaces (on a 1 px line they would pick the line beside it). Ctrl held (`mods`) turns
/// snapping off, as during a drag.
pub(crate) fn snap_click(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
if !snap_on(app) || override_held(mods) {
return p;
}
let targets = build(app, &[], false).filtered(|k| k != SnapKind::Selection);
targets.snap_point(p, tolerance(app)).0
}
/// Round to whole pixels when Preferences › Tools asks vector tools and transforms to snap to
/// the pixel grid.
fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] {
@@ -524,6 +546,18 @@ fn follow_reposition(app: &mut PhotocraftApp) {
}
}
/// A click on a crop frame or one of its handles (released where it was pressed, the frame not
/// moved) changes nothing, as a Move-tool click doesn't move the layer (`canvas::tool_event`):
/// snapping the release would pull an edge within reach onto a nearby one.
fn crop_click(app: &PhotocraftApp, p: [f64; 2]) -> bool {
use crate::crop_ui::CropDrag;
let Some(snap) = app.prefs_rt.snap.as_ref() else { return false };
let Some(CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } | CropDrag::MoveImage { rect, .. }) = app.crop.drag else {
return false;
};
app.ui.tool == Tool::Crop && p == snap.start && app.ui.crop_rect == Some(rect)
}
/// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it).
pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent {
match ev {
@@ -564,6 +598,11 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier
if app.prefs_rt.snap.is_none() {
return ev;
}
if crop_click(app, [x, y]) {
app.prefs_rt.snap = None;
app.prefs_rt.snap_lines.clear();
return ev;
}
follow_reposition(app);
let disabled = override_for_gesture(app, mods);
if let Some(s) = app.prefs_rt.snap.as_mut() {
@@ -93,6 +93,77 @@ fn drawing_a_new_crop_snaps_its_corner_not_the_default_frame() {
}
}
/// A straighten line drawn inside an upright, edited frame levels the frame on the line as
/// drawn: it isn't shifted by the offset that would snap the frame if the press moved it. The
/// default mode moves the image there, which never shifted the line; it stays as it was.
#[test]
fn a_straighten_line_inside_the_frame_is_not_snapped_as_a_frame_move() {
let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() };
for (classic, armed, m) in [(true, true, egui::Modifiers::NONE), (true, false, mac_cmd), (false, true, egui::Modifiers::NONE)] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.sync_views();
app.ui.views[0].zoom = 1.0;
app.ui.views[0].fit_pending = false;
app.ui.tool = Tool::Crop;
app.ui.tool_options.crop_shield.classic_mode = classic;
app.ui.extras.snap = true;
app.ui.view.show.smart_guides = true;
crate::crop_ui::ensure_frame(&mut app);
app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]);
app.crop.default_frame = false;
if classic {
// Without Straighten the press moves the frame and snaps its edges, as on main.
crate::snap_ui::begin(&mut app, [30.0, 30.0], egui::Modifiers::NONE);
let gesture = app.prefs_rt.snap.take().map(|s| s.gesture);
assert_eq!(gesture, Some(Gesture::Move { rect: [20.0, 20.0, 120.0, 70.0] }));
}
app.crop.straighten.armed = armed;
// Moving the frame by the line's (100, 8) would bring its centre 3 px from the
// document's: that offset must not reach the line. Both ends are clear of every target.
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 38.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 130.0, y: 38.0 }, m);
let want = crate::crop_straighten::frame_angle([30.0, 30.0], [130.0, 38.0], 0.0).unwrap();
let got = crate::crop_ui::angle(&app);
assert!((got - want).abs() < 1e-9, "Classic Mode {classic}, armed {armed}: {got} vs {want}");
}
}
/// Windows Ctrl both starts a straighten line and is held while drawing it, which turns
/// snapping off: the press stays where it was too, so the frame levels on the line as drawn,
/// inside an edited frame, outside it and in the untouched default frame. ⌘ on the Mac is not
/// the override: both ends snap.
#[test]
fn a_windows_ctrl_straighten_line_does_not_snap_its_start_either() {
let win_ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() };
for frame in [Some([20.0, 20.0, 120.0, 70.0]), Some([130.0, 20.0, 190.0, 70.0]), None] {
for (m, start) in [(win_ctrl, [97.0, 30.0]), (mac_cmd, [100.0, 30.0])] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.sync_views();
app.ui.views[0].zoom = 1.0;
app.ui.views[0].fit_pending = false;
app.ui.tool = Tool::Crop;
app.ui.tool_options.crop_shield.classic_mode = true;
app.ui.extras.snap = true;
crate::crop_ui::ensure_frame(&mut app);
if let Some(r) = frame {
app.ui.crop_rect = Some(r);
app.crop.default_frame = false;
}
// The press is 3 px from the document's centre line; the end is clear of every target.
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 97.0, y: 30.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 180.0, y: 38.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 180.0, y: 38.0 }, m);
let want = crate::crop_straighten::frame_angle(start, [180.0, 38.0], 0.0).unwrap();
let got = crate::crop_ui::angle(&app);
assert!((got - want).abs() < 1e-9, "frame {frame:?}, {m:?}: {got} vs {want}");
}
}
}
#[test]
fn move_tool_snaps_to_other_layer_edges_with_smart_guides() {
let mut app = app_with_box();
+19 -5
View File
@@ -327,25 +327,39 @@ fn tab_close_clicked(resp: &egui::Response, xresp: &egui::Response) -> bool {
xresp.clicked() || xresp.middle_clicked() || resp.middle_clicked()
}
/// All tab actions go through the same guarded File commands as the menu bar, including the
/// Rows of the document tab menu after which a separator is drawn: after Move to New Window and
/// after Open Document…, as in the reference (#11403).
pub(crate) const TAB_MENU_SEPARATORS_AFTER: [usize; 2] = [4, 6];
pub(crate) type TabMenuItem = (&'static str, &'static str, serde_json::Value, bool);
/// All tab actions go through the same guarded commands as the menu bar, including the
/// unsaved-changes prompt. The clicked tab is explicit even when another document is active.
pub(super) fn tab_context_items(index: usize, count: usize, has_path: bool) -> [(&'static str, &'static str, serde_json::Value, bool); 4] {
/// Order and separators follow the reference application (#11403); see [`TAB_MENU_SEPARATORS_AFTER`].
pub(crate) fn tab_context_items(index: usize, count: usize, has_path: bool) -> [TabMenuItem; 8] {
[
("Consolidate All to Here", "window.arrange.consolidateAllToTabs", json!({}), count > 1),
("Close", "file.close", json!({"document": index}), true),
("Close Others", "file.closeOthers", json!({"document": index}), count > 1),
("Close All", "file.closeAll", json!({}), true),
("Close Others", "file.closeOthers", json!({"document": index}), count > 1),
("Move to New Window", "window.arrange.floatInWindow", json!({"document": index}), true),
("New Document…", "file.new", json!({}), true),
("Open Document…", "file.open", json!({}), true),
(crate::menus::reveal_label(), "file.revealInFinder", json!({"document": index}), has_path),
]
}
fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: bool) -> Option<(&'static str, serde_json::Value)> {
pub(crate) fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: bool) -> Option<(&'static str, serde_json::Value)> {
crate::widgets::menu_scroll(ui, |ui| {
ui.set_min_width(170.0);
for (label, id, params, enabled) in tab_context_items(index, count, has_path) {
for (row, (label, id, params, enabled)) in tab_context_items(index, count, has_path).into_iter().enumerate() {
if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() {
ui.close();
return Some((id, params));
}
if TAB_MENU_SEPARATORS_AFTER.contains(&row) {
ui.separator();
}
}
None
})
+27 -9
View File
@@ -935,20 +935,38 @@ fn navigator_damage_updates_match_a_full_thumbnail() {
#[test]
fn tab_context_uses_clicked_document_and_existing_close_commands() {
let items = tab_context_items(2, 3, true);
assert_eq!(items.iter().map(|(_, id, _, _)| *id).collect::<Vec<_>>(), ["file.close", "file.closeOthers", "file.closeAll", "file.revealInFinder"]);
assert_eq!(items[0].2, json!({"document": 2}));
assert_eq!(
items.iter().map(|(_, id, _, _)| *id).collect::<Vec<_>>(),
[
"window.arrange.consolidateAllToTabs",
"file.close",
"file.closeAll",
"file.closeOthers",
"window.arrange.floatInWindow",
"file.new",
"file.open",
"file.revealInFinder"
]
);
assert_eq!(items[1].2, json!({"document": 2}));
assert_eq!(items[2].2, json!({}));
assert_eq!(items[3].2, json!({"document": 2}));
assert!(items[3].3, "a saved document can be revealed in the file manager");
assert!(!tab_context_items(2, 3, false)[3].3, "an unsaved document cannot");
assert!(!tab_context_items(0, 1, false)[1].3);
assert!(items.iter().all(|(_, id, _, _)| photocraft_engine::commands::find(id).is_some()));
assert_eq!(items[2].2, json!({}));
assert_eq!(items[7].2, json!({"document": 2}));
assert!(items[7].3, "a saved document can be revealed in the file manager");
assert!(!tab_context_items(2, 3, false)[7].3, "an unsaved document cannot");
assert!(!tab_context_items(0, 1, false)[3].3);
// The window.* rows and file.new / file.open are UI commands; the doc_tab_menu tests check them.
assert!(
items
.iter()
.filter(|r| r.1.starts_with("file.close") || r.1 == "file.revealInFinder")
.all(|(_, id, _, _)| photocraft_engine::commands::find(id).is_some())
);
}
#[test]
fn tab_context_reveal_names_the_platform_file_manager() {
let (label, id, _, _) = &tab_context_items(0, 1, true)[3];
let (label, id, _, _) = &tab_context_items(0, 1, true)[7];
assert_eq!((*label, *id), (crate::menus::reveal_label(), "file.revealInFinder"));
}
@@ -1099,7 +1117,7 @@ fn tab_close_others_prompts_for_unsaved_nonactive_document() {
app.run("file.new", json!({"width": 8, "height": 8, "name": "Edited"})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
assert_eq!(app.session.active_index(), Some(1));
let (_, id, params, _) = tab_context_items(0, 2, false)[1].clone();
let (_, id, params, _) = tab_context_items(0, 2, false)[3].clone();
crate::menus::invoke(&mut app, &egui::Context::default(), id, params).unwrap();
assert!(app.discard.is_some(), "close others must ask before discarding the edited tab");
assert_eq!(app.session.documents().len(), 2);
+65 -9
View File
@@ -1,7 +1,7 @@
use super::cancel_stale_vector_drag;
use super::cursors::{alt_eyedropper, sample_eyedropper};
use super::drag::Drag;
use super::gesture::{finish_gesture, polygon_click, selection_mode};
use super::gesture::{finish_release, polygon_click, selection_mode};
use super::selection::finish_selection_drag;
use super::strokes::{HOLD_REPEAT_MS, begin_live_stroke, feed_live_stroke, paint_target, snap_aliased_tip, stroke_params, strokes_live};
use super::{PhotocraftApp, Tool, json, multiple_color_target};
@@ -61,6 +61,12 @@ fn preflight_tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modi
{
crate::guide_select::clear(app);
}
// A tool key pressed mid-drag ends the crop gesture here: a later handler can take every event
// of the new tool, and a drag left set keeps the frame from committing on leave. It ends as a
// release would, so a box drawn over the untouched default frame is committed, not dropped (#9223).
if app.ui.tool != Tool::Crop {
crate::crop_ui::end_drag(app);
}
if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) {
return None;
}
@@ -160,6 +166,11 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::
if crate::corner_radius::pointer(app, raw, mods) {
return None;
}
// The box round several selected shape layers: its handles scale them (Path Selection and the
// shape tools).
if crate::shape_bounds_box::pointer(app, raw, mods) {
return None;
}
// Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790).
if crate::direct_select::pointer(app, ev, mods) {
return None;
@@ -276,7 +287,7 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods:
// With a selection: cut the selected pixels (⌥ copies them) and drag them as a floating
// piece, from anywhere, as a marquee ⌘-drag does.
if crate::move_ui::moves_selected_pixels_with(app, tool) {
if crate::move_ui::float_selected(app, mods.alt, 0.0, 0.0) {
if crate::move_ui::float_selected_for_drag(app, mods.alt) {
let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false);
d.sel_move = Some(true);
app.drag = Some(d);
@@ -392,8 +403,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
return;
}
// Marquee / lasso inside the selection: drag the outline, or ⌘-drag to cut the selected
// pixels into a floating piece (`select.float`) and drag that.
if let Some(cut) = selection_drag_kind(app, tool, [x, y], mods) {
// pixels into a floating piece (`select.float`) and drag that. Inside is judged where the
// press was before snapping, as `snap_ui::begin` judges it: snapped, a press just outside
// can land on the selection's edge (#11392).
if let Some(cut) = selection_drag_kind(app, tool, unsnapped(raw), mods) {
// ⌘⌥ on a floating piece leaves it where it is and drags a copy of it.
if cut
&& mods.alt
@@ -513,6 +526,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
let tool = crate::retouch_ui::alt_flipped(tool, mods.alt);
let mut drag = Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) };
drag.capture_vector_owners(app);
drag.capture_selection_at_press(app);
app.drag = Some(drag);
app.trail = None;
app.stylus.begin_stroke();
@@ -547,6 +561,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
return;
}
note_left_press(app, raw);
tool_move(app, x, y, pressure, mods);
feed_live_stroke(app);
}
@@ -572,9 +587,12 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
let [x, y] = crate::move_mods::selection_drag_to(&mut d, [x, y], mods.shift && app.active_tool() != Tool::Move);
let (click, [x, y]) = selection_release(&d, raw, [x, y]);
// A Move-tool click (released where it was pressed) selects, it never moves: snapping
// the release point would otherwise nudge the layer onto a nearby edge.
if d.tool == Tool::Move && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
// the release point would otherwise nudge the layer onto a nearby edge. A drag that
// snapping held at the press point (no points recorded) and that came back there is
// not a click: it keeps a floating piece floating (#11392).
if d.tool == Tool::Move && !d.left_press && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
app.move_preview = None;
crate::move_mods::finish(app);
if d.sel_move.is_some() {
@@ -586,12 +604,47 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
app.stylus.record_point();
}
finish_gesture(app, d);
finish_release(app, d, click);
crate::move_mods::finish(app);
}
}
}
/// Where pointer event `raw` (before snapping) was.
fn unsnapped(raw: ToolEvent) -> [f64; 2] {
match raw {
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => [x, y],
}
}
/// Is `p` within half a pixel of the press of `d` (a click's reach, a zoomed-in hand's jitter
/// included)?
fn at_press(d: &Drag, p: [f64; 2]) -> bool {
(p[0] - d.start[0]).round() == 0.0 && (p[1] - d.start[1]).round() == 0.0
}
/// Records on the drag that pointer move `raw` (before snapping) left the press (`Drag::left_press`).
fn note_left_press(app: &mut PhotocraftApp, raw: ToolEvent) {
if let ToolEvent::Move { x, y, .. } = raw
&& let Some(d) = app.drag.as_mut()
{
d.left_press |= !at_press(d, [x, y]);
}
}
/// Whether the release `raw` of selection drag `d` is a click, and where it ends (`p`: snapped). A
/// click never leaves the press (`at_press`) before snapping, and ends where it was pressed, as
/// with snapping off: snapping the release would nudge the selection's edges onto a nearby guide or
/// the canvas edge, moving it instead of deselecting (a Single Row / Column Marquee: selecting the
/// line). A drag that snapping pulls back onto the press point, or that comes back to it, is not a
/// click (#11392).
fn selection_release(d: &Drag, raw: ToolEvent, p: [f64; 2]) -> (bool, [f64; 2]) {
match raw {
ToolEvent::Up { x, y } if d.sel_move.is_some() && !d.left_press && at_press(d, [x, y]) => (true, d.start),
_ => (false, p),
}
}
/// Why a press with `tool` must not start: it would paint on or move a hidden target layer (the
/// engine refuses the command; refusing the press keeps the live stroke from drawing first).
fn hidden_target(app: &PhotocraftApp, tool: Tool) -> Option<&'static str> {
@@ -623,12 +676,15 @@ fn covers(sel: &photocraft_raster::Surface, p: [f64; 2]) -> bool {
/// moves the floating piece (⌘ cuts one first, ⌘⌥ copies; a floating piece moves with a plain
/// drag; the Polygonal Lasso and Magic Wand need ⌘, as a plain press there places or samples),
/// `Some(false)` just the outline (no ⇧ / ⌥ and the options bar on New Selection, so a combining
/// drag still draws).
/// drag still draws). The Single Row / Single Column Marquee drag the outline too (#11392); a click
/// without moving, inside the selection or on a floating piece (dropped first), still selects the
/// line under the pointer (`finish_gesture`).
pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option<bool> {
// Click-driven selection tools (a press places a point or samples): only ⌘ drags the
// selection, and not while a polygon is being drawn.
let clicky = matches!(tool, Tool::PolygonLasso | Tool::MagicWand);
if !(clicky || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso)) || (clicky && !app.ui.polygon.is_empty()) {
let marquee = matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee);
if !(clicky || marquee || tool == Tool::Lasso) || (clicky && !app.ui.polygon.is_empty()) {
return None;
}
// ⌘ cuts, ⌘⌥ copies (`select.float`'s `copy`).
+1
View File
@@ -35,6 +35,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
"memory": photocraft_raster::memory::stats(),
"tool": app.ui.tool,
"toolOptions": app.ui.tool_options,
"artboardTool": app.ui.artboard_tool,
"perspectiveCrop": app.ui.perspective_crop,
"strokeEditor": app.ui.stroke_editor,
"magnetic": app.ui.magnetic,
+7 -1
View File
@@ -317,6 +317,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields:
fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id) -> Option<bool> {
let mut outcome: Option<bool> = None;
match d.kind {
_ if merge_channels_ui::owns(fields) => merge_channels_ui::body(ui, fields),
_ if crate::file_ui::owns(fields) => crate::file_ui::body(app, ui, fields, d.id),
_ if crate::color_picker_ui::owns(fields) => {
outcome = crate::color_picker_ui::body(ui, fields);
@@ -454,7 +455,7 @@ fn button_row(
} else {
let ok_label = ok_label(d);
// Dialogs that need an input first (a preset name, a selection) grey OK out.
let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields);
let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields) && merge_channels_ui::can_confirm(fields);
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok);
// Photoshop: holding Alt turns Cancel into Reset (the dialog stays open).
// Dialogs opt in via `dialog_alt_reset::supported` (per marker).
@@ -860,6 +861,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[
crate::stroke_ui::owns,
crate::channel_options_ui::owns,
crate::channel_options_ui::owns_new_spot,
merge_channels_ui::owns,
crate::shape_dialog::owns,
crate::delete_layer_prompt::owns,
crate::disk_change_prompt::owns,
@@ -1011,6 +1013,8 @@ pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(),
if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::guide_layout_ui::owns(&d.fields)) {
crate::guide_layout_ui::validate(app, &d.fields)?;
}
// Merge Channels with too few, repeated, closed or changed documents stays open (#11451).
merge_channels_ui::preflight(app, id)?;
Ok(())
}
@@ -1054,6 +1058,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result<Value, String>
/// The rest of the command dialogs: files, colour, preferences and export, then any other command.
fn confirm_command_more(app: &mut PhotocraftApp, d: Dialog) -> Result<Value, String> {
match d.kind {
_ if merge_channels_ui::owns(&d.fields) => merge_channels_ui::confirm(app, &d.fields),
_ if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields),
_ if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields),
_ if crate::color_picker_ui::owns(&d.fields) => {
@@ -1212,6 +1217,7 @@ pub mod layer_picker;
pub mod layer_style;
pub mod liquify_ui;
pub mod mask_trash_prompt;
pub mod merge_channels_ui;
pub mod new_doc_ui;
pub mod new_group_ui;
pub mod new_guide_ui;
@@ -5,6 +5,9 @@
//!
//! On the layer mask (double-click its Channels row, or Mask Options… on its Layers thumbnail)
//! it is Photoshop's Layer Mask Display Options: just the rubylith's colour and opacity.
//!
//! The Channels panel's New Channel… is the same dialog for a channel still to be made (name,
//! Masked / Selected Areas, colour, opacity); OK runs `channel.new` (#11451).
use egui::{Align2, Sense, pos2, vec2};
use photocraft_doc::ColorIndicates;
@@ -15,6 +18,8 @@ use crate::theme::Tokens;
pub const COMMAND: &str = "channel.options";
const MARK: &str = "__channelOptions";
pub const NEW_CHANNEL: &str = "channel.new";
const NEW_CHANNEL_MARK: &str = "__newChannel";
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
@@ -75,6 +80,28 @@ pub fn open(app: &mut PhotocraftApp, channel: &Value) -> Option<u64> {
Some(app.ui.open_dialog(crate::state::DialogKind::Command, f))
}
/// The New Channel fields: Channel Options' for a channel still to be made (the next "Alpha N"
/// name, Masked Areas, red at 50%), or `None` without a document. OK runs `channel.new`.
pub fn new_channel_fields(app: &PhotocraftApp) -> Option<Map<String, Value>> {
let doc = &app.session.active()?.doc;
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert(NEW_CHANNEL_MARK.into(), json!(true));
f.insert("__command".into(), json!(NEW_CHANNEL));
f.insert("__label".into(), json!("New Channel…"));
f.insert("name".into(), json!(photocraft_engine::channel_cmds::next_alpha_name(doc, "Alpha")));
f.insert("indicates".into(), json!("masked"));
f.insert("color".into(), json!("#ff0000"));
f.insert("opacity".into(), json!(50.0));
Some(f)
}
/// Opens New Channel, or `None` without a document.
pub fn open_new_channel(app: &mut PhotocraftApp) -> Option<u64> {
let f = new_channel_fields(app)?;
Some(app.ui.open_dialog(crate::state::DialogKind::Command, f))
}
/// The `channel.options` params for the dialog's fields: spot channels take `solidity`, the
/// others `opacity`.
pub fn params(f: &Map<String, Value>) -> Value {
@@ -92,7 +119,7 @@ pub fn params(f: &Map<String, Value>) -> Value {
}
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
app.run(COMMAND, params(f))
app.run(if f.contains_key(NEW_CHANNEL_MARK) { NEW_CHANNEL } else { COMMAND }, params(f))
}
fn label(ui: &mut egui::Ui, text: &str) {
@@ -122,8 +149,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.indent("channel-options-indicates", |ui| {
ui.radio_value(&mut indicates, "masked".to_string(), tl!("Masked Areas"));
ui.radio_value(&mut indicates, "selected".to_string(), tl!("Selected Areas"));
// Quick Mask can't be a spot channel.
if !quick {
// Quick Mask can't be a spot channel; New Spot Channel… makes spot channels.
if !quick && !f.contains_key(NEW_CHANNEL_MARK) {
ui.radio_value(&mut indicates, "spot".to_string(), tl!("Spot Color"));
}
});
@@ -1,8 +1,8 @@
//! The History Options dialog (History panel menu › History Options…, #2966): a form over the
//! engine's `history.options` with its two checkboxes, filled with the current values. OK runs
//! the command with the dialog's values (the generic command-dialog confirm), Cancel changes
//! nothing. Agents can also run `history.options` with params directly, or `prefs.set
//! historyOptions.*`; a call with params skips the dialog.
//! engine's `history.options` with Photoshop's five checkboxes in its order, filled with the
//! current values. OK runs the command with the dialog's values (the generic command-dialog
//! confirm), Cancel changes nothing. Agents can also run `history.options` with params directly,
//! or `prefs.set historyOptions.*`; a call with params skips the dialog.
use serde_json::{Map, Value, json};
@@ -27,11 +27,16 @@ pub fn open(app: &mut PhotocraftApp) -> u64 {
f.insert("__label".into(), json!("History Options…"));
f.insert("__form".into(), json!(true));
f.insert("__choices".into(), json!({}));
// Photoshop's capitalised labels, in the UI language (the form would sentence-case the keys).
// Photoshop's order and capitalised labels, in the UI language (the form would sort the keys
// and sentence-case them).
f.insert("__order".into(), json!(photocraft_engine::history_options_cmds::OPTIONS));
f.insert(
"__labels".into(),
json!({
"automaticallyCreateFirstSnapshot": tl!("Automatically Create First Snapshot"),
"automaticallyCreateNewSnapshotWhenSaving": tl!("Automatically Create New Snapshot When Saving"),
"allowNonLinearHistory": tl!("Allow Non-Linear History"),
"showNewSnapshotDialogByDefault": tl!("Show New Snapshot Dialog by Default"),
"makeLayerVisibilityChangesUndoable": tl!("Make Layer Visibility Changes Undoable"),
}),
);
@@ -58,19 +63,25 @@ mod tests {
let d = app.ui.dialogs.iter().find(|d| d.id == id).unwrap();
assert_eq!(d.fields["allowNonLinearHistory"], json!(false));
assert_eq!(d.fields["makeLayerVisibilityChangesUndoable"], json!(true));
assert_eq!(d.fields["automaticallyCreateFirstSnapshot"], json!(true));
assert_eq!(d.fields["automaticallyCreateNewSnapshotWhenSaving"], json!(false));
assert_eq!(d.fields["showNewSnapshotDialogByDefault"], json!(false));
assert_eq!(crate::dialogs::title(d), "History Options");
// Cancel changes nothing.
app.ui.dialog_mut(id).unwrap().fields.insert("allowNonLinearHistory".into(), json!(true));
crate::dialogs::cancel(&mut app, id).unwrap();
assert!(!app.session.prefs().history_options.allow_non_linear_history);
// OK applies both checkboxes.
// OK applies every checkbox.
let id = open(&mut app);
let fields = &mut app.ui.dialog_mut(id).unwrap().fields;
fields.insert("allowNonLinearHistory".into(), json!(true));
fields.insert("makeLayerVisibilityChangesUndoable".into(), json!(false));
for key in photocraft_engine::history_options_cmds::OPTIONS {
let on = fields[key].as_bool().unwrap();
fields.insert(key.into(), json!(!on));
}
crate::dialogs::confirm(&mut app, id).unwrap();
let o = &app.session.prefs().history_options;
assert!(o.allow_non_linear_history && !o.make_layer_visibility_changes_undoable);
assert!(!o.automatically_create_first_snapshot && o.automatically_create_new_snapshot_when_saving && o.show_new_snapshot_dialog_by_default);
assert!(app.ui.dialogs.is_empty());
// Reopened, it shows what was chosen; with params the command runs without a dialog.
let id = open(&mut app);
@@ -82,16 +93,24 @@ mod tests {
assert!(crate::menus::invoke(&mut app, &ctx, COMMAND, json!({"allowNonLinearHistory": "no"})).is_err());
}
/// The five checkboxes with Photoshop's labels, top to bottom in its dialog's order.
#[test]
fn the_dialog_draws_both_checkboxes_with_photoshops_labels() {
fn the_dialog_draws_the_checkboxes_in_photoshops_order() {
use egui_kittest::{Harness, kittest::Queryable};
let mut h = Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app());
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
open(h.state_mut());
h.run_steps(3);
h.get_by_label("History Options");
h.get_by_label("Allow Non-Linear History");
h.get_by_label("Make Layer Visibility Changes Undoable");
let labels = [
"Automatically Create First Snapshot",
"Automatically Create New Snapshot When Saving",
"Allow Non-Linear History",
"Show New Snapshot Dialog by Default",
"Make Layer Visibility Changes Undoable",
];
let tops: Vec<f32> = labels.iter().map(|l| h.get_by_label(l).rect().min.y).collect();
assert!(tops.windows(2).all(|w| w[0] < w[1]), "top to bottom: {tops:?}");
h.get_by_label("OK");
h.get_by_label("Cancel");
}
@@ -0,0 +1,365 @@
//! Channels panel › Merge Channels…: the mode (RGB, CMYK or Lab Color) and, for each of its
//! channels, the open grayscale document that becomes it. Photoshop asks in two steps (the mode,
//! then the documents); here both are one dialog. Only the modes the open documents can fill are
//! offered (RGB and Lab need three, CMYK four), OK waits for a different document per channel,
//! and a merge that still fails opens the dialog again with the reason. OK runs the engine's
//! `channel.merge`, which closes the sources; agents call it directly with params (#11451).
//!
//! The dialog names documents by their [`photocraft_doc::DocId`], not their tab index: work that
//! runs while it is open (a script moving or closing a document) can shift the indices, and OK
//! must merge, and close, the documents that were picked. They are looked up again at OK.
use photocraft_doc::ColorMode;
use photocraft_engine::channel_cmds;
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::theme::Tokens;
pub const COMMAND: &str = "channel.merge";
const MARK: &str = "__mergeChannels";
/// The most channels a mode has (CMYK).
const SLOTS: usize = 4;
const MODES: [(&str, &str); 3] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color"), ("lab", "Lab Color")];
const TOO_FEW: &str = "Not enough grayscale documents for this mode.";
const REPEATED: &str = "Pick a different document for each channel.";
const CLOSED: &str = "A picked document is no longer open.";
const CHANGED: &str = "A picked document is no longer a grayscale document of the same size.";
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
}
fn mode_of(key: &str) -> ColorMode {
match key {
"cmyk" => ColorMode::Cmyk,
"lab" => ColorMode::Lab,
_ => ColorMode::Rgb,
}
}
/// The modes `count` documents can fill, in the menu's order.
fn modes(count: usize) -> Vec<(String, &'static str)> {
MODES.iter().filter(|(key, _)| mode_of(key).color_channels() <= count).map(|(key, label)| (key.to_string(), *label)).collect()
}
/// The documents that can be merged, by document id and name: the open grayscale ones of the
/// active document's size, the active one first, as the engine picks them without params.
fn sources(app: &PhotocraftApp) -> Vec<(u64, String)> {
let Some(active) = app.session.active() else { return Vec::new() };
let docs = app.session.documents();
let fits = |i: usize| docs.get(i).filter(|d| d.doc.mode == ColorMode::Grayscale && d.doc.size == active.doc.size);
let current = app.session.active_index();
let others = (0..docs.len()).filter(|i| Some(*i) != current);
current.into_iter().chain(others).filter_map(|i| fits(i).map(|d| (d.doc.id.0, d.doc.name.clone()))).collect()
}
fn mode_field(f: &Map<String, Value>) -> &str {
f.get("mode").and_then(Value::as_str).unwrap_or("rgb")
}
fn sources_field(f: &Map<String, Value>) -> Vec<(u64, String)> {
f.get("__sources").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default()
}
/// The picked document ids, one per channel of the mode.
fn picks(f: &Map<String, Value>) -> Vec<u64> {
let all: Vec<u64> = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
all.into_iter().take(mode_of(mode_field(f)).color_channels()).collect()
}
/// The dialog's fields: the first mode the documents can fill (RGB Color from three) and the
/// merge-able documents in order, one per channel, or `None` without a document.
pub fn fields(app: &PhotocraftApp) -> Option<Map<String, Value>> {
app.session.active()?;
let sources = sources(app);
let picks: Vec<u64> = sources.iter().take(SLOTS).map(|(id, _)| *id).collect();
let mode = modes(sources.len()).first().map_or_else(|| "rgb".to_string(), |(key, _)| key.clone());
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert("__command".into(), json!(COMMAND));
f.insert("__label".into(), json!("Merge Channels…"));
f.insert("__sources".into(), json!(sources));
f.insert("mode".into(), json!(mode));
f.insert("documents".into(), json!(picks));
Some(f)
}
/// Opens Merge Channels, or `None` without a document.
pub fn open(app: &mut PhotocraftApp) -> Option<u64> {
let f = fields(app)?;
Some(app.ui.open_dialog(crate::state::DialogKind::Command, f))
}
/// Why OK is greyed (English, for `tl!`): the mode needs more documents than are offered, or the
/// picks repeat a document or name one the dialog did not offer. `None` when OK can merge, and
/// for another dialog's fields.
pub fn problem(f: &Map<String, Value>) -> Option<&'static str> {
if !owns(f) {
return None;
}
let n = mode_of(mode_field(f)).color_channels();
let sources = sources_field(f);
let picks = picks(f);
if sources.len() < n || picks.len() < n {
return Some(TOO_FEW);
}
let mut seen = std::collections::HashSet::new();
let offered = |p: &u64| sources.iter().any(|(id, _)| id == p);
if picks.iter().any(|p| !seen.insert(*p) || !offered(p)) {
return Some(REPEATED);
}
None
}
/// Whether OK is live: always for other dialogs, here while [`problem`] finds none.
pub fn can_confirm(f: &Map<String, Value>) -> bool {
problem(f).is_none()
}
/// The picked documents' current tab indices, looked up by id; refused (English, for `tl!`)
/// when one was closed, or is no longer a grayscale document of the others' size.
fn resolve(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<Vec<usize>, &'static str> {
if let Some(why) = problem(f) {
return Err(why);
}
let docs = app.session.documents();
let indices = picks(f).iter().map(|id| docs.iter().position(|d| d.doc.id.0 == *id).ok_or(CLOSED)).collect::<Result<Vec<_>, _>>()?;
let first = indices.first().and_then(|&i| docs.get(i)).map(|d| d.doc.size);
let fits = |i: &usize| docs.get(*i).is_some_and(|d| d.doc.mode == ColorMode::Grayscale && Some(d.doc.size) == first);
if indices.iter().all(fits) { Ok(indices) } else { Err(CHANGED) }
}
/// Checked before the dialog closes (`dialogs::confirm_preflight`), so a refused OK keeps it
/// open with the reason under the pickers: too few or repeated documents, or a picked one that
/// was closed or changed while the dialog was open.
pub fn preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> {
let Some(f) = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| &d.fields).filter(|f| owns(f)) else { return Ok(()) };
let Err(why) = resolve(app, f) else { return Ok(()) };
if let Some(d) = app.ui.dialog_mut(id) {
d.fields.insert("__error".into(), json!(why));
}
Err(why.to_string())
}
/// The `channel.merge` params for the dialog's fields: the mode and the picked documents' current
/// indices, one per channel.
pub fn params(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let documents = resolve(app, f).map_err(str::to_string)?;
Ok(json!({ "mode": mode_field(f), "documents": documents }))
}
/// Runs the merge. Should it still be refused, the dialog opens again with its picks and the
/// reason, rather than going away.
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let result = params(app, f).and_then(|p| app.run(COMMAND, p));
if let Err(e) = &result {
let mut again = f.clone();
again.insert("__error".into(), json!(e));
app.ui.open_dialog(crate::state::DialogKind::Command, again);
}
result
}
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.spacing_mut().item_spacing.y = 6.0;
let sources = sources_field(f);
let mut mode = mode_field(f).to_string();
let modes = modes(sources.len());
ui.horizontal(|ui| {
ui.label(tl!("Mode:"));
if crate::widgets::dropdown(ui, "merge-channels-mode", tl!("Mode:"), &mut mode, &modes, 160.0) {
f.insert("mode".into(), json!(mode));
f.remove("__error");
}
});
crate::widgets::section_label(ui, tl!("Specify Channels:"));
crate::widgets::hairline(ui);
let options: Vec<(u64, &str)> = sources.iter().map(|(id, name)| (*id, name.as_str())).collect();
let mut picks: Vec<u64> = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let mut changed = false;
for (k, channel) in channel_cmds::color_names(mode_of(&mode)).iter().enumerate() {
let Some(pick) = picks.get_mut(k) else { break };
ui.horizontal(|ui| {
let caption = format!("{}:", tl!(channel));
ui.add_sized([84.0, 22.0], egui::Label::new(&caption));
changed |= crate::widgets::dropdown_literal(ui, &format!("merge-channels-{k}"), &caption, pick, &options, 200.0);
});
}
if changed {
f.insert("documents".into(), json!(picks));
f.remove("__error");
}
// Why OK is greyed, or why the last OK was refused (the dialog's reasons are catalogued; the
// engine's own are shown as they come).
let why: Option<&str> = problem(f);
let why = why.or_else(|| f.get("__error").and_then(Value::as_str)).map(|w| tl!(w).to_string());
if let Some(why) = why {
ui.label(egui::RichText::new(why).color(Tokens::get(ui.ctx()).danger).size(11.0));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app(docs: usize) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for _ in 0..docs {
app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
}
app
}
fn id(app: &PhotocraftApp, index: usize) -> u64 {
app.session.documents()[index].doc.id.0
}
fn offered(f: &Map<String, Value>) -> Vec<String> {
modes(sources_field(f).len()).into_iter().map(|(key, _)| key).collect()
}
/// The merged document's first pixel as 8-bit RGB.
fn merged_pixel(app: &PhotocraftApp) -> [u8; 3] {
let doc = &app.session.active().unwrap().doc;
let px = photocraft_compose::render(doc, doc.bounds()).px[0];
[px[0], px[1], px[2]].map(|v| (v * 255.0).round() as u8)
}
/// Three grayscale documents filled black, mid gray and white, then the active one is `active`.
fn shaded(active: usize) -> PhotocraftApp {
let mut app = app(3);
for (i, gray) in ["#000000", "#808080", "#ffffff"].into_iter().enumerate() {
app.session.set_active(i);
app.run("edit.fill", json!({"color": gray})).unwrap();
}
app.session.set_active(active);
app
}
#[test]
fn fields_list_the_sources_by_id_and_params_take_their_indices() {
let mut app = app(3);
app.run("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap();
app.run("file.new", json!({"width": 8, "height": 8, "mode": "rgb"})).unwrap();
app.session.set_active(2);
let f = fields(&app).unwrap();
let ids = [id(&app, 2), id(&app, 0), id(&app, 1)];
assert_eq!(f["__sources"].as_array().unwrap().iter().map(|s| s[0].as_u64().unwrap()).collect::<Vec<_>>(), ids, "the active one first");
assert_eq!((f["mode"].clone(), f["documents"].clone()), (json!("rgb"), json!(ids)), "one each, none repeated");
assert_eq!(params(&app, &f).unwrap(), json!({"mode": "rgb", "documents": [2, 0, 1]}));
assert!(fields(&PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())).is_none());
}
/// Three documents fill RGB and Lab, not CMYK; four fill CMYK too.
#[test]
fn only_the_modes_the_documents_fill_are_offered() {
let f = fields(&app(3)).unwrap();
assert_eq!(offered(&f), ["rgb", "lab"]);
assert!(can_confirm(&f));
let mut cmyk = f.clone();
cmyk.insert("mode".into(), json!("cmyk"));
assert_eq!(problem(&cmyk), Some(TOO_FEW), "CMYK from three documents (as automation could ask) greys OK");
let app = app(4);
let f = fields(&app).unwrap();
assert_eq!(offered(&f), ["rgb", "cmyk", "lab"]);
let mut cmyk = f.clone();
cmyk.insert("mode".into(), json!("cmyk"));
assert!(can_confirm(&cmyk));
assert_eq!(params(&app, &cmyk).unwrap()["documents"], json!([3, 0, 1, 2]), "the active (last opened) one first");
}
/// A document picked for two channels greys OK with the reason; a refused OK keeps the dialog
/// open and merges nothing.
#[test]
fn repeated_picks_grey_ok_and_keep_the_dialog_open() {
let mut app = app(3);
let (a, b) = (id(&app, 0), id(&app, 1));
let id = open(&mut app).unwrap();
let d = app.ui.dialog_mut(id).unwrap();
d.fields.insert("documents".into(), json!([a, a, b]));
assert_eq!(problem(&d.fields), Some(REPEATED));
assert!(!can_confirm(&d.fields));
d.fields.insert("documents".into(), json!([a, b, 999_999]));
assert_eq!(problem(&d.fields), Some(REPEATED), "a document the dialog did not offer");
assert!(crate::dialogs::confirm(&mut app, id).is_err());
assert!(app.ui.dialogs.iter().any(|d| d.id == id), "the dialog stays open");
assert_eq!(app.session.documents().len(), 3, "nothing merged or closed");
assert!(can_confirm(&Map::new()), "other dialogs are not affected");
}
/// Documents moved while the dialog is open: OK still merges the ones picked, by id, into the
/// channels picked for them.
#[test]
fn ok_merges_the_picked_documents_after_a_reorder() {
let mut app = shaded(0);
let (black, gray, white) = (id(&app, 0), id(&app, 1), id(&app, 2));
let dialog = open(&mut app).unwrap();
app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!([black, white, gray]));
// A script reorders the tabs while the dialog is open: white first, black last.
app.session.execute("document.move", json!({"document": 2, "to": 0})).unwrap();
app.session.execute("document.move", json!({"document": 1, "to": 2})).unwrap();
assert_eq!([id(&app, 0), id(&app, 1), id(&app, 2)], [white, gray, black]);
crate::dialogs::confirm(&mut app, dialog).unwrap();
assert_eq!((app.session.documents().len(), app.session.active().unwrap().doc.mode), (1, ColorMode::Rgb));
assert_eq!(merged_pixel(&app), [0, 255, 128], "red, green and blue from the documents picked, not their old tabs");
}
/// A picked document closed while the dialog is open: OK refuses with the reason, keeps the
/// dialog and closes nothing, even when another same-size grayscale document took its tab.
#[test]
fn a_closed_pick_is_refused_and_no_other_document_merges() {
let mut app = shaded(0);
app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
app.session.set_active(0);
let spare = id(&app, 3);
let dialog = open(&mut app).unwrap();
let picked: Vec<u64> = (0..3).map(|i| id(&app, i)).collect();
app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!(picked));
app.session.execute("file.close", json!({"document": 1})).unwrap();
assert_eq!(id(&app, 2), spare, "the spare document is where a picked one was");
let why = crate::dialogs::confirm(&mut app, dialog).unwrap_err();
assert_eq!(why, CLOSED);
let d = app.ui.dialogs.iter().find(|d| d.id == dialog).expect("the dialog stays open");
assert_eq!(d.fields["__error"], json!(CLOSED), "with the reason under the pickers");
assert_eq!(app.session.documents().len(), 3, "nothing merged or closed");
assert!(app.session.documents().iter().any(|d| d.doc.id.0 == spare));
}
/// A picked document turned into another mode while the dialog is open is refused too; should
/// the engine refuse a merge after OK, the dialog comes back with the reason.
#[test]
fn a_changed_pick_or_a_refused_merge_keeps_the_dialog() {
let mut app = app(3);
let dialog = open(&mut app).unwrap();
let f = app.ui.dialog_mut(dialog).unwrap().fields.clone();
app.run("image.mode.rgb", json!({})).unwrap();
assert_eq!(crate::dialogs::confirm(&mut app, dialog).unwrap_err(), CHANGED);
assert!(app.ui.dialogs.iter().any(|d| d.id == dialog));
app.ui.dialogs.clear();
assert!(confirm(&mut app, &f).is_err());
let again = &app.ui.dialogs.last().expect("the dialog again").fields;
assert!(owns(again) && again["__error"].is_string());
assert_eq!(app.session.documents().len(), 3);
}
#[test]
fn body_renders_every_mode_and_the_reason() {
let app = app(4);
let ids: Vec<u64> = (0..4).map(|i| id(&app, i)).collect();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
for (mode, docs, error) in
[("rgb", json!(ids[..3]), None), ("cmyk", json!([ids[0], ids[1], ids[2], ids[2]]), None), ("lab", json!(ids[1..]), Some(CLOSED))]
{
let mut f = fields(&app).unwrap();
f.insert("mode".into(), json!(mode));
f.insert("documents".into(), docs);
if let Some(e) = error {
f.insert("__error".into(), json!(e));
}
ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear();
assert!(owns(&f));
}
}
}
@@ -1408,6 +1408,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
w.pick(t);
}
}
// E cycles the tool mode (add, subtract); Hand and Zoom edit nothing, so it leaves them alone.
let bare_e = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::E, pressed: true, modifiers, .. } if modifiers.is_none())));
if bare_e && w.tool.edits() && pressed(Key::E, Modifiers::NONE) {
w.add = !w.add;
}
if pressed(Key::OpenBracket, Modifiers::NONE) {
let s = w.size();
w.set_size((s * 0.9).min(s - 1.0));
@@ -1973,5 +1978,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
}
#[cfg(test)]
mod cycle_tool_mode_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,158 @@
use super::*;
use egui::{Key, Modifiers};
fn opened() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.execute("file.new", json!({"width": 160, "height": 100, "depth": 8})).unwrap();
app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
app.session.prefs.edit(|p| {
p.dialogs.remove(REMEMBERED);
});
app.sync_views();
open(&mut app).unwrap();
app
}
fn ws(app: &PhotocraftApp) -> &SelectMaskWorkspace {
app.distort.select_mask.as_ref().unwrap()
}
fn ui_selected(app: &mut PhotocraftApp) -> u64 {
control(app, &json!({"waitPreview": true})).unwrap()["selectedProxyPixels"].as_u64().unwrap()
}
fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) {
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
let input = egui::RawInput { events: vec![ev], ..Default::default() };
ctx.run_ui(input, |ui| keys(app, ui.ctx())).textures_delta.clear();
}
#[test]
fn e_switches_between_add_and_subtract_and_back() {
let mut app = opened();
let ctx = egui::Context::default();
assert!(ws(&app).add);
press(&mut app, &ctx, Key::E, Modifiers::NONE);
assert!(!ws(&app).add);
assert_eq!(ws(&app).describe()["mode"].as_str(), Some("subtract"));
press(&mut app, &ctx, Key::E, Modifiers::NONE);
assert!(ws(&app).add);
assert_eq!(ws(&app).describe()["mode"].as_str(), Some("add"));
}
#[test]
fn e_works_with_every_editing_tool_and_keeps_the_tool() {
let ctx = egui::Context::default();
for tool in [SmTool::Quick, SmTool::RefineEdge, SmTool::Brush, SmTool::Object, SmTool::Lasso, SmTool::PolygonalLasso] {
let mut app = opened();
app.distort.select_mask.as_mut().unwrap().pick(tool);
press(&mut app, &ctx, Key::E, Modifiers::NONE);
assert!(!ws(&app).add, "{tool:?}");
assert_eq!(ws(&app).tool, tool);
}
}
#[test]
fn e_does_nothing_with_hand_or_zoom() {
let ctx = egui::Context::default();
for tool in [SmTool::Hand, SmTool::Zoom] {
let mut app = opened();
app.distort.select_mask.as_mut().unwrap().pick(tool);
press(&mut app, &ctx, Key::E, Modifiers::NONE);
assert!(ws(&app).add, "{tool:?}");
}
}
#[test]
fn e_with_command_alt_or_shift_does_nothing_new() {
let ctx = egui::Context::default();
for m in [Modifiers::COMMAND, Modifiers::ALT, Modifiers::SHIFT, Modifiers::CTRL] {
let mut app = opened();
press(&mut app, &ctx, Key::E, m);
assert!(ws(&app).add, "{m:?}");
}
}
#[test]
fn e_is_ignored_while_a_popup_has_the_keys() {
let mut app = opened();
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::Popup::open_id(ui.ctx(), egui::Id::unique("sm-test-popup"));
})
.textures_delta
.clear();
assert!(egui::Popup::is_any_open(&ctx));
press(&mut app, &ctx, Key::E, Modifiers::NONE);
assert!(ws(&app).add);
}
#[test]
fn e_chooses_the_mode_alt_swaps_while_held() {
let mut app = opened();
let ctx = egui::Context::default();
press(&mut app, &ctx, Key::E, Modifiers::NONE);
assert!(!ws(&app).add);
// Alt held on top of subtract: the stroke adds, so the pixel under it becomes selected.
let before = ui_selected(&mut app);
let stroke = json!({"tool": "brush", "size": 10, "hardness": 50, "stroke": {"points": [[100.0, 10.0], [100.0, 90.0]], "alt": true}});
control(&mut app, &stroke).unwrap();
assert!(!ws(&app).add, "Alt does not change the chosen mode");
assert!(ui_selected(&mut app) > before, "Alt over subtract adds");
}
#[test]
fn a_polygon_in_progress_keeps_its_starting_mode() {
let mut app = opened();
let ctx = egui::Context::default();
app.distort.select_mask.as_mut().unwrap().pick(SmTool::PolygonalLasso);
app.distort.select_mask.as_mut().unwrap().poly = Some((true, vec![[10.0, 10.0], [50.0, 10.0], [50.0, 50.0]]));
press(&mut app, &ctx, Key::E, Modifiers::NONE);
assert!(!ws(&app).add);
assert_eq!(ws(&app).poly.as_ref().map(|(a, _)| *a), Some(true));
assert!(enter(&mut app), "Enter closes the polygon");
let edit = ws(&app).edits.last().expect("the closed polygon is recorded");
assert_eq!(edit["tool"].as_str(), Some("lasso"));
assert_eq!(edit["mode"].as_str(), Some("add"), "the mode the polygon started with");
assert!(!ws(&app).add, "the workspace mode is still the one E chose");
}
fn e_event() -> egui::RawInput {
let ev = egui::Event::Key { key: Key::E, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
egui::RawInput { events: vec![ev], ..Default::default() }
}
/// Through the shortcut handler, as the app runs it: a focused text field keeps the E.
#[test]
fn e_through_handle_is_ignored_while_a_text_field_has_focus() {
let mut app = opened();
let ctx = egui::Context::default();
let mut text = String::new();
ctx.run_ui(Default::default(), |ui| {
ui.text_edit_singleline(&mut text).request_focus();
})
.textures_delta
.clear();
ctx.run_ui(e_event(), |ui| {
assert!(ui.ctx().text_edit_focused());
crate::shortcuts::handle(&mut app, ui.ctx());
ui.text_edit_singleline(&mut text);
})
.textures_delta
.clear();
assert!(ws(&app).add);
}
/// The same path with no text field focused: E flips the mode.
#[test]
fn e_through_handle_flips_the_mode_without_a_focused_text_field() {
let mut app = opened();
let ctx = egui::Context::default();
ctx.run_ui(e_event(), |ui| {
assert!(!ui.ctx().text_edit_focused());
crate::shortcuts::handle(&mut app, ui.ctx());
})
.textures_delta
.clear();
assert!(!ws(&app).add);
}
@@ -50,6 +50,30 @@ fn dialog_maps_to_engine_command() {
assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(120, 50));
}
/// #9260: OK with a new Width resizes a document whose own resolution is outside the range
/// the command accepts (an imported PNG's pHYs of 1 px/m is 0.0254 ppi); the dialog sends that
/// resolution back unchanged.
#[test]
fn ok_resizes_a_document_with_an_imported_resolution_out_of_range() {
for own in [0.0254_f32, 40_000.0] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap();
app.session
.edit("ppi", |doc, _| {
doc.resolution_dpi = own;
Ok(())
})
.unwrap();
let id = open(&mut app, "image.imageSize").unwrap();
let d = app.ui.dialog_mut(id).unwrap();
d.fields.insert("width".into(), json!(100));
d.fields.insert("height".into(), json!(50));
crate::dialogs::confirm(&mut app, id).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(100, 50), own), "{own}");
}
}
#[test]
fn alt_reset_restores_open_dimensions() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+219
View File
@@ -0,0 +1,219 @@
//! #11403: the document tab's right-click menu lists the reference application's eight entries in
//! order, with its two separators, and each entry runs the command it names on the clicked tab.
use std::cell::RefCell;
use std::rc::Rc;
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::{Value, json};
use crate::canvas::tabs::{TAB_MENU_SEPARATORS_AFTER, tab_context_items, tab_context_menu};
use crate::state::DialogKind;
use crate::{FileDialogRequest, PhotocraftApp, Services};
/// The app, what the menu is drawn for, and the command the last click returned.
struct Probe {
app: PhotocraftApp,
index: usize,
has_path: bool,
picked: Option<(&'static str, Value)>,
}
type Requests = Rc<RefCell<Vec<FileDialogRequest>>>;
fn probe(documents: usize, index: usize, has_path: bool) -> (Harness<'static, Probe>, Requests) {
let requests: Requests = Default::default();
let held = requests.clone();
let services = Services { file_dialog: Some(Box::new(move |request, _parent, _reply| held.borrow_mut().push(request))), ..Default::default() };
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
for n in 0..documents {
app.run("file.new", json!({"width": 10 + n, "height": 8})).unwrap();
}
let state = Probe { app, index, has_path, picked: None };
let mut h = Harness::builder().with_size(vec2(400.0, 500.0)).build_ui_state(
|ui, p: &mut Probe| {
let count = p.app.session.documents().len();
if let Some(hit) = tab_context_menu(ui, p.index, count, p.has_path) {
p.picked = Some(hit);
}
},
state,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(3);
(h, requests)
}
/// The drawn rows top to bottom: label, top edge, enabled.
fn rows(h: &Harness<'static, Probe>) -> Vec<(String, f32, bool)> {
let mut rows: Vec<(String, f32, bool)> = h
.query_all_by_role(egui::accesskit::Role::Button)
.map(|n| {
let a = n.accesskit_node();
(a.label().unwrap_or_default().to_string(), n.rect().top(), !a.is_disabled())
})
.collect();
rows.sort_by(|a, b| a.1.total_cmp(&b.1));
rows
}
/// Each document's width, which tells the documents apart (`probe` makes them 10, 11, 12 wide).
fn widths(h: &Harness<'static, Probe>) -> Vec<u32> {
h.state().app.session.documents().iter().map(|d| d.doc.size.width).collect()
}
fn click(h: &mut Harness<'static, Probe>, label: &str) -> (&'static str, Value) {
h.get_by_label(label).click();
h.run_steps(3);
h.state_mut().picked.take().unwrap_or_else(|| panic!("clicking {label} returned no command"))
}
#[test]
fn the_menu_lists_the_eight_reference_entries_in_order() {
let (h, _) = probe(1, 0, false);
let labels: Vec<String> = rows(&h).into_iter().map(|r| r.0).collect();
assert_eq!(
labels,
[
"Consolidate All to Here",
"Close",
"Close All",
"Close Others",
"Move to New Window",
"New Document…",
"Open Document…",
crate::menus::reveal_label(),
]
);
}
#[test]
fn separators_follow_move_to_new_window_and_open_document() {
assert_eq!(TAB_MENU_SEPARATORS_AFTER, [4, 6]);
let (h, _) = probe(1, 0, false);
let tops: Vec<f32> = rows(&h).into_iter().map(|r| r.1).collect();
let gaps: Vec<f32> = tops.windows(2).map(|w| w[1] - w[0]).collect();
let plain = gaps[0];
for (i, gap) in gaps.iter().enumerate() {
let separated = TAB_MENU_SEPARATORS_AFTER.contains(&i);
assert_eq!(*gap > plain + 1.0, separated, "gap after row {i} is {gap} against a plain {plain}");
}
}
#[test]
fn each_row_names_its_command_and_the_clicked_tabs_index() {
let items = tab_context_items(2, 3, true);
let menu = crate::menus::menu_items(&PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()));
let want = [
("Consolidate All to Here", "window.arrange.consolidateAllToTabs", json!({})),
("Close", "file.close", json!({"document": 2})),
("Close All", "file.closeAll", json!({})),
("Close Others", "file.closeOthers", json!({"document": 2})),
("Move to New Window", "window.arrange.floatInWindow", json!({"document": 2})),
("New Document…", "file.new", json!({})),
("Open Document…", "file.open", json!({})),
(crate::menus::reveal_label(), "file.revealInFinder", json!({"document": 2})),
];
for ((label, id, params, _), (wl, wi, wp)) in items.iter().zip(want) {
assert_eq!((*label, *id, params), (wl, wi, &wp));
assert!(menu.iter().any(|i| i.id == *id) || photocraft_engine::commands::find(id).is_some(), "{id} is a command");
}
}
#[test]
fn enabled_states_for_one_saved_one_unsaved_and_two_documents() {
let enabled = |documents: usize, has_path: bool| -> Vec<bool> {
let (h, _) = probe(documents, 0, has_path);
rows(&h).into_iter().map(|r| r.2).collect()
};
// Consolidate, Close, Close All, Close Others, Move, New, Open, Reveal.
assert_eq!(enabled(1, true), [false, true, true, false, true, true, true, true], "one saved document");
assert_eq!(enabled(1, false), [false, true, true, false, true, true, true, false], "one unsaved document");
assert_eq!(enabled(2, true), [true, true, true, true, true, true, true, true], "two documents");
assert_eq!(enabled(2, false), [true, true, true, true, true, true, true, false], "two documents, the clicked one unsaved");
}
#[test]
fn close_close_all_and_close_others_act_on_the_clicked_tab() {
let (mut h, _) = probe(3, 1, true);
let (id, params) = click(&mut h, "Close");
assert_eq!((id, &params), ("file.close", &json!({"document": 1})));
let ctx = h.ctx.clone();
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
let names = widths(&h);
assert_eq!(names, [10, 12]);
let (mut h, _) = probe(3, 1, true);
let (id, params) = click(&mut h, "Close Others");
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
let names = widths(&h);
assert_eq!(names, [11]);
let (mut h, _) = probe(3, 1, true);
let (id, params) = click(&mut h, "Close All");
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
assert!(h.state().app.session.documents().is_empty());
}
#[test]
fn move_to_new_window_floats_the_clicked_tab_even_with_one_document() {
let ctx = egui::Context::default();
let (mut h, _) = probe(1, 0, false);
let (id, params) = click(&mut h, "Move to New Window");
assert_eq!((id, &params), ("window.arrange.floatInWindow", &json!({"document": 0})));
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
assert!(h.state().app.ui.windows.iter().any(|w| w.floating && w.document == 0));
// The clicked tab (2) is not the active document (0).
let (mut h, _) = probe(3, 2, true);
h.state_mut().app.session.set_active(0);
assert_eq!(h.state().app.session.active_index(), Some(0));
let (id, params) = click(&mut h, "Move to New Window");
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
let windows = &h.state().app.ui.windows;
assert!(windows.iter().any(|w| w.floating && w.document == 2), "the clicked tab floats");
assert!(!windows.iter().any(|w| w.document == 0), "the active document does not float");
}
#[test]
fn consolidate_all_to_here_brings_floating_windows_back() {
let ctx = egui::Context::default();
let (mut h, _) = probe(2, 0, true);
crate::menus::invoke(&mut h.state_mut().app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap();
assert_eq!(h.state().app.ui.windows.len(), 2);
let (id, params) = click(&mut h, "Consolidate All to Here");
assert_eq!((id, &params), ("window.arrange.consolidateAllToTabs", &json!({})));
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
assert!(h.state().app.ui.windows.is_empty());
}
#[test]
fn new_document_opens_the_new_document_dialog() {
let ctx = egui::Context::default();
let (mut h, _) = probe(1, 0, false);
let (id, params) = click(&mut h, "New Document…");
assert_eq!((id, &params), ("file.new", &json!({})));
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
assert!(h.state().app.ui.dialogs.iter().any(|d| d.kind == DialogKind::NewDocument));
}
#[test]
fn open_document_asks_for_files_like_file_open() {
let ctx = egui::Context::default();
let (mut h, requests) = probe(1, 0, false);
let (id, params) = click(&mut h, "Open Document…");
assert_eq!((id, &params), ("file.open", &json!({})));
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
h.state_mut().app.poll_file_dialog(&ctx, None);
let asked = requests.borrow();
assert!(matches!(asked.as_slice(), [FileDialogRequest::Open { multiple: true, .. }]), "{asked:?}");
}
#[test]
fn reveal_keeps_its_platform_label_and_command() {
let (mut h, _) = probe(1, 0, true);
let (id, params) = click(&mut h, crate::menus::reveal_label());
assert_eq!((id, &params), ("file.revealInFinder", &json!({"document": 0})));
}
+230
View File
@@ -0,0 +1,230 @@
//! #11481: File › Export › Artboards to Files (and Layers / Layer Comps to Files) has a JPEG
//! Quality field behind a Custom checkbox, as Batch has (#3956). The quality reaches the command
//! only for a JPEG with Custom on: left alone, an export writes what the command writes with no
//! quality (the source JPEG's own quality, else the encoder default), a WebP stays lossless and
//! PNG has none.
use crate::PhotocraftApp;
use egui::accesskit::Role;
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::{Value, json};
const ARTBOARDS: &str = "file.export.artboardsToFiles";
const EXPORTS: [&str; 3] = [ARTBOARDS, "file.export.layersToFiles", "file.export.layerCompsToFiles"];
fn harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default())
});
let app = h.state_mut();
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
// A noisy layer inside the board, so the JPEG quality shows in the file.
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color": "#8A42B3"})).unwrap();
app.run("filter.noise.addNoise", json!({"amount": 60})).unwrap();
app.run("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap();
app.run("layerComp.new", json!({"name": "Comp"})).unwrap();
app.sync_views();
h
}
fn open(h: &mut Harness<'_, PhotocraftApp>, id: &str, dir: &str, format: &str) -> u64 {
let ctx = h.ctx.clone();
let dialog = crate::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap();
let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields;
fields.insert("dir".into(), json!(dir));
fields.insert("prefix".into(), json!("out"));
fields.insert("format".into(), json!(format));
h.run_steps(4);
dialog
}
fn params(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Value {
crate::filter_dialog::params_of(&h.state_mut().ui.dialog_mut(dialog).unwrap().fields)
}
fn exported(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Vec<u8> {
let r = crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
std::fs::read(r["files"][0].as_str().unwrap()).unwrap()
}
/// The same export run straight through the command, into `dir`.
fn direct(h: &mut Harness<'_, PhotocraftApp>, dir: &str, format: &str, quality: Option<i64>) -> Vec<u8> {
let mut p = json!({"dir": dir, "prefix": "out", "format": format});
if let Some(q) = quality {
p["quality"] = json!(q);
}
let r = h.state_mut().run(ARTBOARDS, p).unwrap();
std::fs::read(r["files"][0].as_str().unwrap()).unwrap()
}
/// Whether the Quality number field (not its row label) takes input.
fn quality_enabled(h: &Harness<'_, PhotocraftApp>) -> bool {
let fields: Vec<bool> =
h.query_all_by_label("Quality").filter(|n| n.accesskit_node().role() != Role::Label).map(|n| !n.accesskit_node().is_disabled()).collect();
assert!(!fields.is_empty(), "the dialog shows a Quality field");
assert!(fields.iter().all(|e| *e == fields[0]), "{fields:?}");
fields[0]
}
/// Whether the Custom checkbox is ticked.
fn custom_ticked(h: &Harness<'_, PhotocraftApp>) -> bool {
h.get_by_role_and_label(Role::CheckBox, "Custom").accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)
}
/// The value the Quality field shows.
fn shown_quality(h: &Harness<'_, PhotocraftApp>) -> Option<f64> {
h.get_by_role_and_label(Role::SpinButton, "Quality").accesskit_node().numeric_value()
}
fn tick_custom(h: &mut Harness<'_, PhotocraftApp>) {
h.get_by_role_and_label(Role::CheckBox, "Custom").click();
h.run_steps(2);
}
/// Types `text` into the Quality field and commits it with Tab, as a user does.
fn type_quality(h: &mut Harness<'_, PhotocraftApp>, text: &str) {
h.get_by_role_and_label(Role::SpinButton, "Quality").click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(1);
for ch in text.chars() {
h.event(egui::Event::Text(ch.to_string()));
h.run_steps(1);
}
h.key_press(egui::Key::Tab);
h.run_steps(2);
}
/// Holds Alt, which turns Cancel into Reset, and clicks it.
fn alt_reset(h: &mut Harness<'_, PhotocraftApp>) {
let alt = egui::Modifiers::ALT;
h.event(egui::Event::ModifiersChanged(alt));
h.run_steps(2);
let at = h.get_by_label("Reset").rect().center();
h.event(egui::Event::PointerMoved(at));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: alt });
h.run_steps(1);
}
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(3);
}
fn tmp() -> (tempfile::TempDir, String) {
let t = tempfile::tempdir().unwrap();
let s = t.path().to_string_lossy().into_owned();
(t, s)
}
#[test]
fn artboards_to_files_sends_a_custom_jpeg_quality() {
let mut h = harness();
let (_a, via_dialog) = tmp();
let (_b, via_command) = tmp();
let mut sizes = Vec::new();
for quality in [0, 3, 12] {
let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg");
assert!(!custom_ticked(&h) && !quality_enabled(&h), "JPEG with Custom off: the field is greyed out");
tick_custom(&mut h);
assert!(custom_ticked(&h) && quality_enabled(&h), "JPEG with Custom on: the field takes input");
assert_eq!(params(&mut h, dialog)["quality"], json!(10), "Custom starts at quality 10");
type_quality(&mut h, &quality.to_string());
assert_eq!(shown_quality(&h), Some(quality as f64), "the field shows the typed quality");
assert_eq!(params(&mut h, dialog)["quality"], json!(quality), "the typed quality reaches the command");
let bytes = exported(&mut h, dialog);
assert!(bytes == direct(&mut h, &via_command, "jpg", Some(quality)), "quality {quality}: the file the command writes at that quality");
sizes.push(bytes.len());
}
assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2], "a higher quality writes a bigger JPEG: {sizes:?}");
}
#[test]
fn an_untouched_jpeg_export_writes_what_the_command_writes_with_no_quality() {
let mut h = harness();
let (_a, via_dialog) = tmp();
let (_b, via_command) = tmp();
let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg");
assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "no quality is sent");
let bytes = exported(&mut h, dialog);
assert!(bytes == direct(&mut h, &via_command, "jpg", None), "the untouched dialog keeps the command's own JPEG quality");
}
#[test]
fn export_to_files_quality_leaves_png_and_webp_as_they_were() {
let mut h = harness();
let (_a, dir) = tmp();
for id in EXPORTS {
for format in ["png", "webp"] {
let dialog = open(&mut h, id, &dir, format);
// Custom on from an earlier JPEG choice still sends nothing for these formats.
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true));
h.run_steps(2);
assert!(!quality_enabled(&h), "{id} {format}: the field is greyed out");
assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "{id} {format}: no quality is sent");
let bytes = exported(&mut h, dialog);
if format == "webp" {
// The default WebP stays lossless (a VP8L bitstream, not lossy VP8).
assert!(bytes.windows(4).any(|w| w == b"VP8L"), "{id}: the WebP stays lossless");
}
}
// Back to JPEG with Custom on, the field sends its quality again.
let dialog = open(&mut h, id, &dir, "jpg");
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true));
h.run_steps(2);
assert!(quality_enabled(&h), "{id}");
assert_eq!(params(&mut h, dialog)["quality"], json!(10), "{id}");
crate::dialogs::cancel(h.state_mut(), dialog).unwrap();
}
}
/// A Custom quality survives a switch to another format and back in the same dialog.
#[test]
fn the_custom_quality_survives_a_format_round_trip() {
let mut h = harness();
let (_a, dir) = tmp();
let dialog = open(&mut h, ARTBOARDS, &dir, "jpg");
tick_custom(&mut h);
type_quality(&mut h, "4");
assert_eq!(params(&mut h, dialog)["quality"], json!(4));
for format in ["png", "webp"] {
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!(format));
h.run_steps(2);
assert!(!quality_enabled(&h), "{format}: the field is greyed out");
assert!(params(&mut h, dialog)["quality"].is_null(), "{format}: no quality is sent");
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
h.run_steps(2);
assert!(custom_ticked(&h) && quality_enabled(&h), "back from {format}: Custom is still on");
assert_eq!(shown_quality(&h), Some(4.0), "back from {format}: the field still shows 4");
assert_eq!(params(&mut h, dialog)["quality"], json!(4), "back from {format}: quality 4 is sent");
}
}
/// Alt+Reset (Alt held, Cancel clicked as Reset) puts the Custom checkbox and its quality back to
/// how the dialog opened, and keeps the dialog open.
#[test]
fn alt_reset_restores_the_custom_quality() {
let mut h = harness();
let ctx = h.ctx.clone();
let dialog = crate::menus::invoke(h.state_mut(), &ctx, ARTBOARDS, json!({})).unwrap()["dialog"].as_u64().unwrap();
// JPEG chosen in the dialog, after it opened: Reset puts the format back too.
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
h.run_steps(4);
tick_custom(&mut h);
type_quality(&mut h, "4");
assert_eq!(params(&mut h, dialog)["quality"], json!(4));
alt_reset(&mut h);
assert!(h.state().ui.dialogs.iter().any(|d| d.id == dialog), "Reset keeps the dialog open");
let p = params(&mut h, dialog);
assert_eq!((&p["format"], &p["quality"]), (&json!("png"), &Value::Null), "the opening format, no quality");
assert!(!custom_ticked(&h) && !quality_enabled(&h), "Custom is off again");
assert_eq!(shown_quality(&h), Some(10.0), "the field shows the opening quality");
// Choosing JPEG again still sends no quality until Custom is ticked.
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
h.run_steps(2);
assert!(!custom_ticked(&h), "JPEG after Reset: Custom stays off");
assert!(params(&mut h, dialog)["quality"].is_null(), "JPEG after Reset: no quality is sent");
}
+36 -4
View File
@@ -81,6 +81,7 @@
Right يمين
Open Recent فتح الملفات الأخيرة
Clear Recent Files مسح الملفات الأخيرة
Clear Recent File List مسح الملفات الأخيرة
Remove from Recent إزالة من الملفات الأخيرة
Remove {name} from Recent إزالة {name} من الملفات الأخيرة
Close إغلاق
@@ -89,6 +90,10 @@
Restore استعادة
Close All إغلاق الكل
Close Others إغلاق المستندات الأخرى
New Document… مستند جديد…
Open Document… فتح…
Consolidate All to Here توحيد الكل هنا
Move to New Window نقل إلى نافذة جديدة
Save حفظ
Save As… حفظ باسم…
Save a Copy… حفظ نسخة…
@@ -141,6 +146,7 @@
Create Smart Object after Loading Layers إنشاء كائن ذكي بعد تحميل الطبقات
Reveal in Finder إظهار في Finder
Show in Explorer إظهار في Explorer
Reveal in Explorer إظهار في Explorer
Show in Folder إظهار في المجلد
File Info… معلومات الملف…
Print… طباعة…
@@ -229,6 +235,7 @@
Units & Rulers… الوحدات والمساطر…
Guides, Grid & Slices… خطوط التوجيه والشبكة والشرائح…
Plug-ins… المكونات الإضافية…
Plugins… المكونات الإضافية…
Type… النص…
Enhanced Controls… عناصر التحكم المحسنة…
Camera Raw… Camera Raw…
@@ -386,6 +393,8 @@
Smart Object كائن ذكي
Convert to Smart Object تحويل إلى كائن ذكي
Convert to Embedded تحويل إلى مضمّن
Embed Linked تضمين المرتبط
Embed All Linked تضمين كل المرتبط
Convert to Layers تحويل إلى طبقات
Convert to Linked… تحويل إلى مرتبط…
Edit Contents تحرير المحتويات
@@ -459,7 +468,9 @@
Unite Shapes توحيد الأشكال
Subtract Front Shape طرح الشكل الأمامي
Intersect Shape Areas تقاطع مناطق الأشكال
Unite Shapes at Overlap تقاطع مناطق الأشكال
Exclude Overlapping Shapes استبعاد الأشكال المتداخلة
Subtract Shapes at Overlap استبعاد الأشكال المتداخلة
Merge Shape Components دمج مكونات الشكل
Align محاذاة
Top Edges الحواف العلوية
@@ -660,6 +671,7 @@
Minimum… الحد الأدنى…
Offset… إزاحة…
Plug-ins المكونات الإضافية
Plugins المكونات الإضافية
Proof Setup إعداد المعاينة الطباعية
Proof Colors ألوان المعاينة الطباعية
Gamut Warning تحذير النطاق اللوني
@@ -668,9 +680,9 @@
Working Magenta Plate لوح الأرجواني العامل
Working Yellow Plate لوح الأصفر العامل
Working Black Plate لوح الأسود العامل
Working CMY Plate لوح CMY العامل
Legacy Macintosh RGB RGB القديم لنظام Macintosh
Internet Standard RGB RGB القياسي للإنترنت
Working CMY Plates لوح CMY العامل
Legacy Macintosh RGB (Gamma 1.8) RGB القديم لنظام Macintosh (Gamma 1.8)
Internet Standard RGB (sRGB) RGB القياسي للإنترنت (sRGB)
Monitor RGB RGB الشاشة
Color Blindness — Protanopia-type عمى الألوان — نوع بروتانوبيا
Color Blindness — Deuteranopia-type عمى الألوان — نوع ديوتيرانوبيا
@@ -752,7 +764,7 @@
Consolidate All to Tabs توحيد الكل في علامات تبويب
Float in Window تعويم في نافذة
Float All in Windows تعويم الكل في نوافذ
New Window for Document نافذة جديدة للمستند
New Window نافذة جديدة للمستند
Workspace مساحة العمل
Essentials الأساسيات
Graphic and Web الرسومات والويب
@@ -1924,6 +1936,11 @@
Show canvas dots إظهار نقاط مساحة الرسم
Clip thumbnails to layer bounds قصر الصور المصغرة على حدود الطبقة
Clip Thumbnails to Layer Bounds قصر الصور المصغرة على حدود الطبقة
No Thumbnails بلا صور مصغرة
Small Thumbnails صور مصغرة صغيرة
Medium Thumbnails صور مصغرة متوسطة
Large Thumbnails صور مصغرة كبيرة
Clip Thumbnails to Document Bounds قصر الصور المصغرة على حدود المستند
Canvas color لون مساحة الرسم
Canvas custom color لون مخصص لمساحة الرسم
Color picker منتقي الألوان
@@ -3564,3 +3581,18 @@ curves Smooth تنعيم
Pause For إيقاف مؤقت لمدة
seconds ثانية
Create a single history state إنشاء حالة سجل واحدة
# Channels panel menu (#11451)
New Channel… قناة جديدة…
Duplicate Channel تكرار القناة
Delete Channel حذف القناة
New Spot Channel… قناة لون موضعي جديدة…
Merge Spot Channel دمج قناة اللون الموضعي
Channel Options… خيارات القناة…
Split Channels تقسيم القنوات
Merge Channels… دمج القنوات…
Specify Channels: تحديد القنوات:
Not enough grayscale documents for this mode. لا توجد مستندات كافية بتدرج الرمادي لهذا الوضع.
Pick a different document for each channel. اختر مستندًا مختلفًا لكل قناة.
A picked document is no longer open. أحد المستندات المختارة لم يعد مفتوحًا.
A picked document is no longer a grayscale document of the same size. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+26 -4
View File
@@ -64,6 +64,7 @@
Right Desno
Open Recent Otvori nedavno
Clear Recent Files Očisti nedavne datoteke
Clear Recent File List Očisti nedavne datoteke
Remove from Recent Ukloni iz nedavnih
Remove {name} from Recent Ukloni {name} iz nedavnih
Close Zatvori
@@ -72,6 +73,10 @@
Restore Vrati
Close All Zatvori sve
Close Others Zatvori ostale
New Document… Novi dokument…
Open Document… Otvori…
Consolidate All to Here Objedini sve ovdje
Move to New Window Premjesti u novi prozor
Save Sačuvaj
Save As… Sačuvaj kao…
Save a Copy… Sačuvaj kopiju…
@@ -123,6 +128,7 @@
Create Smart Object after Loading Layers Kreiraj pametni objekat nakon učitavanja slojeva
Reveal in Finder Prikaži u Finderu
Show in Explorer Prikaži u Exploreru
Reveal in Explorer Prikaži u Exploreru
Show in Folder Prikaži u folderu
File Info… Informacije o datoteci…
Print… Štampaj…
@@ -207,6 +213,7 @@
Units & Rulers… Jedinice i ravnala…
Guides, Grid & Slices… Vodilice, mreža i isječci…
Plug-ins… Dodaci…
Plugins… Dodaci…
Type… Vrsta…
Enhanced Controls… Poboljšane kontrole…
Camera Raw… Camera Raw…
@@ -355,6 +362,8 @@
Smart Object Pametni objekat
Convert to Smart Object Pretvori u pametni objekat
Convert to Embedded Pretvori u ugrađeno
Embed Linked Ugradi povezano
Embed All Linked Ugradi sve povezano
Convert to Layers Pretvori u slojeve
Convert to Linked… Pretvori u povezano…
Edit Contents Uredi sadržaj
@@ -605,6 +614,7 @@
Minimum… Minimum…
Offset… Pomak…
Plug-ins Dodaci
Plugins Dodaci
Proof Setup Postavke probnog prikaza
Proof Colors Probne boje
Gamut Warning Upozorenje o gamutu
@@ -613,9 +623,9 @@
Working Magenta Plate Radna magenta ploča
Working Yellow Plate Radna žuta ploča
Working Black Plate Radna crna ploča
Working CMY Plate Radna CMY ploča
Legacy Macintosh RGB Naslijeđeni Macintosh RGB
Internet Standard RGB Internet standardni RGB
Working CMY Plates Radna CMY ploča
Legacy Macintosh RGB (Gamma 1.8) Naslijeđeni Macintosh RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Internet standardni RGB (sRGB)
Monitor RGB RGB monitora
Color Blindness — Protanopia-type Daltonizam — tip protanopije
Color Blindness — Deuteranopia-type Daltonizam — tip deuteranopije
@@ -707,7 +717,6 @@
Consolidate All to Tabs Objedini sve u kartice
Float in Window Odvoji u prozor
Float All in Windows Odvoji sve u prozore
New Window for Document Novi prozor za dokument
Workspace Radni prostor
Essentials Osnove
Graphic and Web Grafika i web
@@ -1851,6 +1860,11 @@
Show canvas dots Prikaži tačke platna
Clip thumbnails to layer bounds Ograniči sličice na granice sloja
Clip Thumbnails to Layer Bounds Ograniči sličice na granice sloja
No Thumbnails Bez sličica
Small Thumbnails Male sličice
Medium Thumbnails Srednje sličice
Large Thumbnails Velike sličice
Clip Thumbnails to Document Bounds Ograniči sličice na granice dokumenta
Canvas color Boja platna
Canvas custom color Prilagođena boja platna
Color picker Birač boja
@@ -4605,3 +4619,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
Stop Color Boja tačke
Shapes can't use patterns yet Oblici još ne mogu koristiti uzorke
Edit Brush… Uredi četkicu…
# Channels panel menu (#11451)
Channel Options… Opcije kanala…
Specify Channels: Odredi kanale:
Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj način.
Pick a different document for each channel. Odaberite drugi dokument za svaki kanal.
A picked document is no longer open. Jedan odabrani dokument više nije otvoren.
A picked document is no longer a grayscale document of the same size. Jedan odabrani dokument više nije dokument u nijansama sive iste veličine.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+30 -4
View File
@@ -203,6 +203,7 @@
Right Vpravo
Open Recent Otevřít nedávné
Clear Recent Files Vymazat nedávné soubory
Clear Recent File List Vymazat nedávné soubory
Remove from Recent Odebrat z nedávných
Remove {name} from Recent Odebrat {name} z nedávných
Close Zavřít
@@ -211,6 +212,10 @@
Restore Obnovit
Close All Zavřít vše
Close Others Zavřít ostatní
New Document… Nový dokument…
Open Document… Otevřít…
Consolidate All to Here Sloučit vše sem
Move to New Window Přesunout do nového okna
Save Uložit
Save As… Uložit jako…
Save a Copy… Uložit kopii…
@@ -262,6 +267,7 @@
Create Smart Object after Loading Layers Po načtení vrstev vytvořit chytrý objekt
Reveal in Finder Zobrazit ve Finderu
Show in Explorer Zobrazit v Průzkumníkovi
Reveal in Explorer Zobrazit v Průzkumníkovi
Show in Folder Zobrazit ve složce
File Info… Informace o souboru…
Print… Tisk…
@@ -350,6 +356,7 @@
Units & Rulers… Jednotky a pravítka…
Guides, Grid & Slices… Vodítka, mřížka a řezy…
Plug-ins… Zásuvné moduly…
Plugins… Zásuvné moduly…
Type… Text…
Enhanced Controls… Rozšířené ovládání…
Camera Raw… Camera Raw…
@@ -507,6 +514,8 @@
Smart Object Chytrý objekt
Convert to Smart Object Převést na chytrý objekt
Convert to Embedded Převést na vložený
Embed Linked Vložit propojené
Embed All Linked Vložit všechna propojená
Convert to Layers Převést na vrstvy
Convert to Linked… Převést na propojený…
Edit Contents Upravit obsah
@@ -764,6 +773,7 @@
Minimum… Minimum…
Offset… Posun…
Plug-ins Zásuvné moduly
Plugins Zásuvné moduly
Proof Setup Nastavení nátisku
Proof Colors Nátiskové barvy
Gamut Warning Upozornění na gamut
@@ -772,9 +782,9 @@
Working Magenta Plate Pracovní purpurový výtažek
Working Yellow Plate Pracovní žlutý výtažek
Working Black Plate Pracovní černý výtažek
Working CMY Plate Pracovní výtažky CMY
Legacy Macintosh RGB Starší Macintosh RGB
Internet Standard RGB Internetový standard RGB
Working CMY Plates Pracovní výtažky CMY
Legacy Macintosh RGB (Gamma 1.8) Starší Macintosh RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Internetový standard RGB (sRGB)
Monitor RGB RGB monitoru
Color Blindness — Protanopia-type Barvoslepost — protanopie
Color Blindness — Deuteranopia-type Barvoslepost — deuteranopie
@@ -866,7 +876,7 @@
Consolidate All to Tabs Sloučit vše do záložek
Float in Window Uvolnit do plovoucího okna
Float All in Windows Uvolnit vše do plovoucích oken
New Window for Document Nové okno dokumentu
New Window Nové okno dokumentu
Workspace Pracovní prostor
Essentials Základy
Graphic and Web Grafika a web
@@ -2041,6 +2051,11 @@
Show canvas dots Zobrazit tečky plátna
Clip thumbnails to layer bounds Oříznout miniatury na hranice vrstvy
Clip Thumbnails to Layer Bounds Oříznout miniatury na hranice vrstvy
No Thumbnails Bez miniatur
Small Thumbnails Malé miniatury
Medium Thumbnails Střední miniatury
Large Thumbnails Velké miniatury
Clip Thumbnails to Document Bounds Oříznout miniatury na hranice dokumentu
Canvas color Barva plátna
Canvas custom color Vlastní barva plátna
Color picker Výběr barvy
@@ -3910,6 +3925,9 @@ navigator Zoom Zvětšení
Sent to {printer} Odesláno do {printer}
set in Print Settings… nastavuje se v nastavení tisku…
ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů
Automatically Create First Snapshot Automaticky vytvořit první snímek
Automatically Create New Snapshot When Saving Automaticky vytvořit nový snímek při ukládání
Show New Snapshot Dialog by Default Ve výchozím nastavení zobrazit dialog Nový snímek
Memory usage percent Využití paměti (%)
Show Favorite Fonts Zobrazit oblíbená písma
No favorite fonts yet: click the star beside a font to add it. Zatím žádná oblíbená písma: přidejte písmo kliknutím na hvězdičku vedle něj.
@@ -4313,3 +4331,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
Replace Image… Nahradit obrázek…
The frame is locked Rámeček je zamčený
The frame is unavailable Rámeček není dostupný
# Channels panel menu (#11451)
Channel Options… Možnosti kanálu…
Specify Channels: Určit kanály:
Not enough grayscale documents for this mode. Pro tento režim není dost dokumentů ve stupních šedi.
Pick a different document for each channel. Pro každý kanál vyberte jiný dokument.
A picked document is no longer open. Jeden z vybraných dokumentů už není otevřený.
A picked document is no longer a grayscale document of the same size. Jeden z vybraných dokumentů už není dokument ve stupních šedi stejné velikosti.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+26 -4
View File
@@ -86,6 +86,7 @@
Right Højre
Open Recent Åbn seneste
Clear Recent Files Ryd seneste filer
Clear Recent File List Ryd seneste filer
Remove from Recent Fjern fra seneste
Remove {name} from Recent Fjern {name} fra seneste
Close Luk
@@ -94,6 +95,10 @@
Restore Gendan
Close All Luk alle
Close Others Luk andre
New Document… Nyt dokument…
Open Document… Åbn…
Consolidate All to Here Saml alle her
Move to New Window Flyt til nyt vindue
Save Gem
Save As… Gem som…
Save a Copy… Gem en kopi…
@@ -508,7 +513,6 @@
Match Location Afstem placering
Match Rotation Afstem rotation
Match All Afstem alle
New Window for Document Nyt vindue til dokument
Workspace Arbejdsområde
Extensions Udvidelser
Panels Paneler
@@ -686,6 +690,7 @@
Units & Rulers… Enheder og linealer…
Guides, Grid & Slices… Hjælpelinjer, gitter og udsnit…
Plug-ins… Plug-ins…
Plugins… Plug-ins…
Technology Previews Teknologieksempler
Scratch Disks… Arbejdsdiske…
Export Preferences… Eksportpræferencer…
@@ -920,8 +925,8 @@
ACES ACES
Industry Standard Industristandard
Web Web
Internet Standard RGB Internetstandard-RGB
Legacy Macintosh RGB Ældre Macintosh RGB
Internet Standard RGB (sRGB) Internetstandard-RGB (sRGB)
Legacy Macintosh RGB (Gamma 1.8) Ældre Macintosh RGB (Gamma 1.8)
Monitor RGB Skærm-RGB
Windows LCD Windows LCD
Coated CMYK Bestrøget CMYK
@@ -1742,6 +1747,7 @@
Saved Gemt
Reveal in Finder Vis i Finder
Show in Explorer Vis i Stifinder
Reveal in Explorer Vis i Stifinder
Show in Folder Vis i mappe
Before (Y) Før (Y)
Save Settings… Gem indstillinger…
@@ -1921,6 +1927,8 @@
Combine Shapes Kombinér former
Contract… Formindsk…
Convert to Embedded Konvertér til indlejret
Embed Linked Indlejr sammenkædet
Embed All Linked Indlejr alle sammenkædede
Convert to Layers Konvertér til lag
Convert to Linked… Konvertér til sammenkædet…
Count Optælling
@@ -2008,6 +2016,7 @@
Pixelate Pixelér
Place Scale Marker… Placér skalamarkør…
Plug-ins Plug-ins
Plugins Plug-ins
Print One Copy Udskriv én kopi
Print… Udskriv…
Range Spændvidde
@@ -2052,7 +2061,7 @@
Vertical Centers Lodrette midtpunkter
Video Frames to Layers… Videobilleder til lag…
WIA Support… WIA-support…
Working CMY Plate Arbejds-CMY-plade
Working CMY Plates Arbejds-CMY-plade
# tl! literals (third pass)
Absolute Absolut
@@ -2338,6 +2347,11 @@
Show canvas dots Vis lærredsprikker
Clip thumbnails to layer bounds Klip miniaturer til laggrænser
Clip Thumbnails to Layer Bounds Klip miniaturer til laggrænser
No Thumbnails Ingen miniaturer
Small Thumbnails Små miniaturer
Medium Thumbnails Mellemstore miniaturer
Large Thumbnails Store miniaturer
Clip Thumbnails to Document Bounds Klip miniaturer til dokumentgrænser
Canvas color Lærredsfarve
Canvas custom color Brugerdefineret lærredsfarve
Classic Klassisk
@@ -4787,3 +4801,11 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet
Pause For Pause i
seconds sekunder
Create a single history state Opret én historiktilstand
# Channels panel menu (#11451)
Channel Options… Kanalindstillinger…
Specify Channels: Angiv kanaler:
Not enough grayscale documents for this mode. Der er ikke nok dokumenter i gråtoner til denne tilstand.
Pick a different document for each channel. Vælg et andet dokument til hver kanal.
A picked document is no longer open. Et af de valgte dokumenter er ikke længere åbent.
A picked document is no longer a grayscale document of the same size. Et af de valgte dokumenter er ikke længere et dokument i gråtoner i samme størrelse.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+39 -4
View File
@@ -173,6 +173,7 @@
Right Rechts
Open Recent Zuletzt geöffnete Dateien
Clear Recent Files Zuletzt geöffnete Dateien leeren
Clear Recent File List Zuletzt geöffnete Dateien leeren
Remove from Recent Aus zuletzt geöffneten Dateien entfernen
Remove {name} from Recent {name} aus zuletzt geöffneten Dateien entfernen
Close Schließen
@@ -181,6 +182,10 @@
Restore Wiederherstellen
Close All Alle schließen
Close Others Andere schließen
New Document… Neues Dokument…
Open Document… Öffnen…
Consolidate All to Here Alle hier zusammenfassen
Move to New Window In neues Fenster verschieben
Save Speichern
Save As… Speichern unter…
Save a Copy… Kopie speichern…
@@ -233,6 +238,7 @@
Create Smart Object after Loading Layers Nach dem Laden der Ebenen Smart-Objekt erstellen
Reveal in Finder Im Finder anzeigen
Show in Explorer Im Explorer anzeigen
Reveal in Explorer Im Explorer anzeigen
Show in Folder Im Ordner anzeigen
File Info… Dateiinformationen…
Print… Drucken…
@@ -321,6 +327,7 @@
Units & Rulers… Einheiten & Lineale…
Guides, Grid & Slices… Hilfslinien, Raster & Slices…
Plug-ins… Plug-ins…
Plugins… Plug-ins…
Type… Text…
Enhanced Controls… Erweiterte Bedienelemente…
Camera Raw… Camera Raw…
@@ -478,6 +485,8 @@
Smart Object Smart-Objekt
Convert to Smart Object In Smart-Objekt konvertieren
Convert to Embedded In eingebettetes Smart-Objekt konvertieren
Embed Linked Verknüpftes einbetten
Embed All Linked Alle verknüpften einbetten
Convert to Layers In Ebenen konvertieren
Convert to Linked… In verknüpftes Smart-Objekt konvertieren…
Edit Contents Inhalt bearbeiten
@@ -551,7 +560,9 @@
Unite Shapes Formen vereinigen
Subtract Front Shape Vordere Form subtrahieren
Intersect Shape Areas Schnittmenge der Formen
Unite Shapes at Overlap Schnittmenge der Formen
Exclude Overlapping Shapes Überlappende Formen ausschließen
Subtract Shapes at Overlap Überlappende Formen ausschließen
Merge Shape Components Formkomponenten zusammenfügen
Path operations Pfadvorgänge
Align Ausrichten
@@ -735,6 +746,7 @@
Minimum… Minimum…
Offset… Versatz…
Plug-ins Plug-ins
Plugins Plug-ins
Proof Setup Proof-Setup
Proof Colors Proof-Farben
Gamut Warning Farbumfang-Warnung
@@ -743,9 +755,9 @@
Working Magenta Plate Arbeits-Magenta-Druckplatte
Working Yellow Plate Arbeits-Gelb-Druckplatte
Working Black Plate Arbeits-Schwarz-Druckplatte
Working CMY Plate Arbeits-CMY-Druckplatte
Legacy Macintosh RGB Legacy-Macintosh-RGB
Internet Standard RGB Internet-Standard-RGB
Working CMY Plates Arbeits-CMY-Druckplatte
Legacy Macintosh RGB (Gamma 1.8) Legacy-Macintosh-RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Internet-Standard-RGB (sRGB)
Monitor RGB Monitor-RGB
Color Blindness — Protanopia-type Farbenblindheit — Protanopie-Typ
Color Blindness — Deuteranopia-type Farbenblindheit — Deuteranopie-Typ
@@ -827,7 +839,7 @@
Consolidate All to Tabs Alle in Registerkarten zusammenfassen
Float in Window In Fenster schweben lassen
Float All in Windows Alle in Fenstern schweben lassen
New Window for Document Neues Fenster für Dokument
New Window Neues Fenster für Dokument
Workspace Arbeitsbereich
Essentials Essentials
Graphic and Web Grafik und Web
@@ -2000,6 +2012,11 @@
Show canvas dots Punkte der Zeichenfläche anzeigen
Clip thumbnails to layer bounds Miniaturbilder auf Ebenenbegrenzung beschneiden
Clip Thumbnails to Layer Bounds Miniaturbilder auf Ebenenbegrenzung beschneiden
No Thumbnails Keine Miniaturbilder
Small Thumbnails Kleine Miniaturbilder
Medium Thumbnails Mittlere Miniaturbilder
Large Thumbnails Große Miniaturbilder
Clip Thumbnails to Document Bounds Miniaturbilder auf Dokumentbegrenzung beschneiden
Canvas color Zeichenflächenfarbe
Canvas custom color Benutzerdefinierte Zeichenflächenfarbe
Color picker Farbwähler
@@ -3309,6 +3326,9 @@ curves Smooth Glätten
Sent to {printer} An {printer} gesendet
set in Print Settings… in den Druckeinstellungen festlegen…
ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt
Automatically Create First Snapshot Ersten Snapshot automatisch erstellen
Automatically Create New Snapshot When Saving Beim Speichern automatisch neuen Snapshot erstellen
Show New Snapshot Dialog by Default Dialogfeld „Neuer Snapshot“ standardmäßig anzeigen
Memory usage percent Speichernutzung (%)
Show Favorite Fonts Favorisierte Schriften anzeigen
No favorite fonts yet: click the star beside a font to add it. Noch keine favorisierten Schriften: Klicken Sie auf den Stern neben einer Schrift, um sie hinzuzufügen.
@@ -3599,3 +3619,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
The frame is unavailable Der Rahmen ist nicht verfügbar
X X
Y Y
# Channels panel menu (#11451)
New Channel… Neuer Kanal…
Duplicate Channel Kanal duplizieren
Delete Channel Kanal löschen
New Spot Channel… Neuer Schmuckfarbenkanal…
Merge Spot Channel Schmuckfarbenkanal zusammenfügen
Channel Options… Kanaloptionen…
Split Channels Kanäle teilen
Merge Channels… Kanäle zusammenfügen…
Specify Channels: Kanäle angeben:
Not enough grayscale documents for this mode. Nicht genug Graustufen-Dokumente für diesen Modus.
Pick a different document for each channel. Wählen Sie für jeden Kanal ein anderes Dokument.
A picked document is no longer open. Ein ausgewähltes Dokument ist nicht mehr geöffnet.
A picked document is no longer a grayscale document of the same size. Ein ausgewähltes Dokument ist kein Graustufen-Dokument derselben Größe mehr.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+35 -4
View File
@@ -144,6 +144,7 @@
Right Δεξιά
Open Recent Πρόσφατα αρχεία
Clear Recent Files Εκκαθάριση πρόσφατων αρχείων
Clear Recent File List Εκκαθάριση πρόσφατων αρχείων
Remove from Recent Αφαίρεση από τα πρόσφατα
Remove {name} from Recent Αφαίρεση του {name} από τα πρόσφατα
Close Κλείσιμο
@@ -152,6 +153,10 @@
Restore Επαναφορά
Close All Κλείσιμο όλων
Close Others Κλείσιμο των υπόλοιπων
New Document… Νέο έγγραφο…
Open Document… Άνοιγμα…
Consolidate All to Here Συγκέντρωση όλων εδώ
Move to New Window Μετακίνηση σε νέο παράθυρο
Save Αποθήκευση
Save As… Αποθήκευση ως…
Save a Copy… Αποθήκευση αντιγράφου…
@@ -203,6 +208,7 @@
Create Smart Object after Loading Layers Δημιουργία έξυπνου αντικειμένου μετά τη φόρτωση επιπέδων
Reveal in Finder Εμφάνιση στο Finder
Show in Explorer Εμφάνιση στην Εξερεύνηση αρχείων
Reveal in Explorer Εμφάνιση στην Εξερεύνηση αρχείων
Show in Folder Εμφάνιση στον φάκελο
File Info… Πληροφορίες αρχείου…
Print… Εκτύπωση…
@@ -1766,6 +1772,11 @@
Show canvas dots Εμφάνιση κουκκίδων καμβά
Clip thumbnails to layer bounds Περικοπή μικρογραφιών στα όρια του επιπέδου
Clip Thumbnails to Layer Bounds Περικοπή μικρογραφιών στα όρια του επιπέδου
No Thumbnails Χωρίς μικρογραφίες
Small Thumbnails Μικρές μικρογραφίες
Medium Thumbnails Μεσαίες μικρογραφίες
Large Thumbnails Μεγάλες μικρογραφίες
Clip Thumbnails to Document Bounds Περικοπή μικρογραφιών στα όρια του εγγράφου
Canvas color Χρώμα καμβά
Canvas custom color Προσαρμοσμένο χρώμα καμβά
Color picker Επιλογέας χρώματος
@@ -1847,6 +1858,7 @@
Units & Rulers… Μονάδες και χάρακες…
Guides, Grid & Slices… Οδηγοί, πλέγμα και τομές…
Plug-ins… Πρόσθετα…
Plugins… Πρόσθετα…
Type… Κείμενο…
Enhanced Controls… Προηγμένα χειριστήρια…
Raw Defaults… Προεπιλογές RAW…
@@ -1951,6 +1963,8 @@
Hide Selection Απόκρυψη επιλογής
From Transparency Από διαφάνεια
Convert to Embedded Μετατροπή σε ενσωματωμένο
Embed Linked Ενσωμάτωση συνδεδεμένου
Embed All Linked Ενσωμάτωση όλων των συνδεδεμένων
Convert to Linked… Μετατροπή σε συνδεδεμένο…
Edit Contents Επεξεργασία περιεχομένου
Replace Contents… Αντικατάσταση περιεχομένου…
@@ -2009,7 +2023,9 @@
Unite Shapes Ένωση σχημάτων
Subtract Front Shape Αφαίρεση μπροστινού σχήματος
Intersect Shape Areas Τομή περιοχών σχημάτων
Unite Shapes at Overlap Τομή περιοχών σχημάτων
Exclude Overlapping Shapes Εξαίρεση επικαλυπτόμενων σχημάτων
Subtract Shapes at Overlap Εξαίρεση επικαλυπτόμενων σχημάτων
Merge Shape Components Συγχώνευση συστατικών σχήματος
Path operations Λειτουργίες διαδρομών
Top Edges Επάνω ακμές
@@ -2665,7 +2681,7 @@
Install Plug-in Εγκατάσταση προσθέτου
Integrations Ενσωματώσεις
Interlaced Πεπλεγμένο
Internet Standard RGB Τυπικό RGB διαδικτύου
Internet Standard RGB (sRGB) Τυπικό RGB διαδικτύου (sRGB)
Interpolation: Παρεμβολή:
Interpret Footage Ερμηνεία πλάνων
Intersect with selection ({key}) Τομή με την επιλογή ({key})
@@ -2718,7 +2734,7 @@
Leave as is (don't color manage) Διατήρηση ως έχει (χωρίς διαχείριση χρώματος)
Left align text Αριστερή στοίχιση κειμένου
Legacy Παλαιού τύπου
Legacy Macintosh RGB Παλαιό Macintosh RGB
Legacy Macintosh RGB (Gamma 1.8) Παλαιό Macintosh RGB (Gamma 1.8)
Legacy compositing Παλαιός τρόπος σύνθεσης
Legal Μέγεθος Legal
Length: Μήκος:
@@ -2855,7 +2871,6 @@
New Video Group Νέα ομάδα βίντεο
New Video Layer Νέο επίπεδο βίντεο
New Video Layer from File Νέο επίπεδο βίντεο από αρχείο
New Window for Document Νέο παράθυρο για το έγγραφο
New Workspace Νέος χώρος εργασίας
New Workspace… Νέος χώρος εργασίας…
New document… Νέο έγγραφο…
@@ -2948,6 +2963,7 @@
Play Αναπαραγωγή
Play selection Αναπαραγωγή επιλογής
Plug-ins Πρόσθετα
Plugins Πρόσθετα
Point size Μέγεθος σε σημεία
Pointillize Πουαντιγισμός
Pointillize… Πουαντιγισμός…
@@ -3371,7 +3387,7 @@
Word Spacing Διάστημα λέξεων
Work Path Διαδρομή εργασίας
Working Black Plate Κανάλι μαύρου εργασίας
Working CMY Plate Κανάλια CMY εργασίας
Working CMY Plates Κανάλια CMY εργασίας
Working CMYK Χώρος εργασίας CMYK
Working Cyan Plate Κανάλι κυανού εργασίας
Working Gray Plate Κανάλι γκρι εργασίας
@@ -4268,3 +4284,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
The frame is unavailable Το πλαίσιο δεν είναι διαθέσιμο
X X
Y Y
# Channels panel menu (#11451)
New Channel… Νέο κανάλι…
Duplicate Channel Δημιουργία αντιγράφου καναλιού
Delete Channel Διαγραφή καναλιού
New Spot Channel… Νέο κανάλι πλακάτου χρώματος…
Merge Spot Channel Συγχώνευση καναλιού πλακάτου χρώματος
Channel Options… Επιλογές καναλιού…
Split Channels Διαχωρισμός καναλιών
Merge Channels… Συγχώνευση καναλιών…
Specify Channels: Καθορισμός καναλιών:
Not enough grayscale documents for this mode. Δεν υπάρχουν αρκετά έγγραφα σε κλίμακα του γκρι για αυτή τη λειτουργία.
Pick a different document for each channel. Επιλέξτε διαφορετικό έγγραφο για κάθε κανάλι.
A picked document is no longer open. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον ανοιχτό.
A picked document is no longer a grayscale document of the same size. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους.
Can't render this file because it contains an unexpected character in line 47 and column 53.
+26 -4
View File
@@ -139,6 +139,7 @@
Right Derecha
Open Recent Abrir recientes
Clear Recent Files Vaciar archivos recientes
Clear Recent File List Vaciar archivos recientes
Remove from Recent Quitar de recientes
Remove {name} from Recent Quitar {name} de recientes
Close Cerrar
@@ -147,6 +148,10 @@
Restore Restaurar
Close All Cerrar todo
Close Others Cerrar otros
New Document… Documento nuevo…
Open Document… Abrir…
Consolidate All to Here Consolidar todo aquí
Move to New Window Mover a una nueva ventana
Save Guardar
Save As… Guardar como…
Save a Copy… Guardar una copia…
@@ -199,6 +204,7 @@
Create Smart Object after Loading Layers Crear objeto inteligente tras cargar las capas
Reveal in Finder Mostrar en Finder
Show in Explorer Mostrar en el Explorador
Reveal in Explorer Mostrar en el Explorador
Show in Folder Mostrar en la carpeta
File Info… Información del archivo…
Print… Imprimir…
@@ -287,6 +293,7 @@
Units & Rulers… Unidades y reglas…
Guides, Grid & Slices… Guías, cuadrícula y sectores…
Plug-ins… Plugins…
Plugins… Plugins…
Type… Texto…
Enhanced Controls… Controles mejorados…
Camera Raw… Camera Raw…
@@ -444,6 +451,8 @@
Smart Object Objeto inteligente
Convert to Smart Object Convertir en objeto inteligente
Convert to Embedded Convertir en incrustado
Embed Linked Incrustar vinculado
Embed All Linked Incrustar todos los vinculados
Convert to Layers Convertir en capas
Convert to Linked… Convertir en vinculado…
Edit Contents Editar contenido
@@ -702,6 +711,7 @@
Minimum… Mínimo…
Offset… Desplazamiento…
Plug-ins Plugins
Plugins Plugins
Proof Setup Ajuste de prueba
Proof Colors Colores de prueba
Gamut Warning Advertencia de gama
@@ -710,9 +720,9 @@
Working Magenta Plate Placa magenta de trabajo
Working Yellow Plate Placa amarilla de trabajo
Working Black Plate Placa negra de trabajo
Working CMY Plate Placa CMY de trabajo
Legacy Macintosh RGB RGB heredado de Macintosh
Internet Standard RGB RGB estándar de Internet
Working CMY Plates Placa CMY de trabajo
Legacy Macintosh RGB (Gamma 1.8) RGB heredado de Macintosh (Gamma 1.8)
Internet Standard RGB (sRGB) RGB estándar de Internet (sRGB)
Monitor RGB RGB del monitor
Color Blindness — Protanopia-type Daltonismo — tipo protanopia
Color Blindness — Deuteranopia-type Daltonismo — tipo deuteranopia
@@ -804,7 +814,6 @@
Consolidate All to Tabs Consolidar todo en pestañas
Float in Window Hacer flotante en ventana
Float All in Windows Hacer flotantes todas en ventanas
New Window for Document Nueva ventana para el documento
Workspace Espacio de trabajo
Essentials Esenciales
Graphic and Web Gráficos y web
@@ -1973,6 +1982,11 @@
Show canvas dots Mostrar puntos del lienzo
Clip thumbnails to layer bounds Recortar miniaturas a los límites de la capa
Clip Thumbnails to Layer Bounds Recortar miniaturas a los límites de la capa
No Thumbnails Sin miniaturas
Small Thumbnails Miniaturas pequeñas
Medium Thumbnails Miniaturas medianas
Large Thumbnails Miniaturas grandes
Clip Thumbnails to Document Bounds Recortar miniaturas a los límites del documento
Canvas color Color del lienzo
Canvas custom color Color personalizado del lienzo
Color picker Selector de color
@@ -4477,3 +4491,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
Replace Image… Reemplazar imagen…
The frame is locked El marco está bloqueado
The frame is unavailable El marco no está disponible
# Channels panel menu (#11451)
Channel Options… Opciones de canal…
Specify Channels: Especificar canales:
Not enough grayscale documents for this mode. No hay suficientes documentos en escala de grises para este modo.
Pick a different document for each channel. Elija un documento distinto para cada canal.
A picked document is no longer open. Uno de los documentos elegidos ya no está abierto.
A picked document is no longer a grayscale document of the same size. Uno de los documentos elegidos ya no es un documento en escala de grises del mismo tamaño.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+26 -4
View File
@@ -87,6 +87,7 @@
Right Oikea
Open Recent Viimeksi avatut
Clear Recent Files Tyhjennä viimeisimmät tiedostot
Clear Recent File List Tyhjennä viimeisimmät tiedostot
Remove from Recent Poista viimeisimmistä
Remove {name} from Recent Poista {name} viimeisimmistä
Close Sulje
@@ -95,6 +96,10 @@
Restore Palauta
Close All Sulje kaikki
Close Others Sulje muut
New Document… Uusi asiakirja…
Open Document… Avaa…
Consolidate All to Here Kokoa kaikki tähän
Move to New Window Siirrä uuteen ikkunaan
Save Tallenna
Save As… Tallenna nimellä…
Save a Copy… Tallenna kopio…
@@ -509,7 +514,6 @@
Match Location Täsmäytä sijainti
Match Rotation Täsmäytä kierto
Match All Täsmäytä kaikki
New Window for Document Uusi ikkuna asiakirjalle
Workspace Työtila
Extensions Laajennukset
Panels Paneelit
@@ -687,6 +691,7 @@
Units & Rulers… Yksiköt ja viivaimet…
Guides, Grid & Slices… Apuviivat, ruudukko ja viipaleet…
Plug-ins… Laajennukset…
Plugins… Laajennukset…
Technology Previews Teknologian esikatselut
Scratch Disks… Työlevyt…
Export Preferences… Vientiasetukset…
@@ -921,8 +926,8 @@
ACES ACES
Industry Standard Alan standardi
Web Verkko
Internet Standard RGB Internetin standardi-RGB
Legacy Macintosh RGB Vanha Macintosh RGB
Internet Standard RGB (sRGB) Internetin standardi-RGB (sRGB)
Legacy Macintosh RGB (Gamma 1.8) Vanha Macintosh RGB (Gamma 1.8)
Monitor RGB Näytön RGB
Windows LCD Windows LCD
Coated CMYK CMYK, päällystetty paperi
@@ -1743,6 +1748,7 @@
Saved Tallennettu
Reveal in Finder Näytä Finderissa
Show in Explorer Näytä Resurssienhallinnassa
Reveal in Explorer Näytä Resurssienhallinnassa
Show in Folder Näytä kansiossa
Before (Y) Ennen (Y)
Save Settings… Tallenna asetukset…
@@ -1922,6 +1928,8 @@
Combine Shapes Yhdistä muodot
Contract… Supista…
Convert to Embedded Muunna upotetuksi
Embed Linked Upota linkitetty
Embed All Linked Upota kaikki linkitetyt
Convert to Layers Muunna tasoiksi
Convert to Linked… Muunna linkitetyksi…
Count Laskuri
@@ -2009,6 +2017,7 @@
Pixelate Pikselöinti
Place Scale Marker… Sijoita mittakaavamerkki…
Plug-ins Laajennukset
Plugins Laajennukset
Print One Copy Tulosta yksi kopio
Print… Tulosta…
Range Alue
@@ -2053,7 +2062,7 @@
Vertical Centers Pystykeskikohdat
Video Frames to Layers… Videoruudut tasoiksi…
WIA Support… WIA-tuki…
Working CMY Plate Työ-CMY-levy
Working CMY Plates Työ-CMY-levy
# tl! literals (third pass)
Absolute Absoluuttinen
@@ -2339,6 +2348,11 @@
Show canvas dots Näytä kankaan pisteet
Clip thumbnails to layer bounds Rajaa pikkukuvat tason rajoihin
Clip Thumbnails to Layer Bounds Rajaa pikkukuvat tason rajoihin
No Thumbnails Ei pikkukuvia
Small Thumbnails Pienet pikkukuvat
Medium Thumbnails Keskikokoiset pikkukuvat
Large Thumbnails Suuret pikkukuvat
Clip Thumbnails to Document Bounds Rajaa pikkukuvat asiakirjan rajoihin
Canvas color Kankaan väri
Canvas custom color Kankaan mukautettu väri
Classic Klassinen
@@ -4788,3 +4802,11 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen
Pause For Tauko
seconds sekuntia
Create a single history state Luo yksi historiatila
# Channels panel menu (#11451)
Channel Options… Kanavan asetukset…
Specify Channels: Määritä kanavat:
Not enough grayscale documents for this mode. Harmaasävyasiakirjoja ei ole tarpeeksi tähän tilaan.
Pick a different document for each channel. Valitse jokaiselle kanavalle eri asiakirja.
A picked document is no longer open. Yksi valituista asiakirjoista ei ole enää auki.
A picked document is no longer a grayscale document of the same size. Yksi valituista asiakirjoista ei ole enää samankokoinen harmaasävyasiakirja.
Can't render this file because it contains an unexpected character in line 6 and column 47.

Some files were not shown because too many files have changed in this diff Show More