Files
photocraft/crates/engine/src/image_cmds.rs
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1420 lines
70 KiB
Rust
Raw Normal View History

2026-09-30 09:37:18 -04:00
//! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and
//! bit depth) conversions, Duplicate.
use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface};
2026-10-01 08:25:06 -04:00
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size};
2026-09-30 09:37:18 -04:00
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
/// Applies `f` to every pixel surface of a layer tree: content (raster or
/// cached text/shape/smart pixels), fill caches, and (if `masks`) masks.
pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn FnMut(&mut Surface, bool)) {
for l in layers {
if masks && let Some(m) = &mut l.mask {
f(&mut m.surface, true);
}
if let Some(fc) = &mut l.fill_cache {
f(&mut fc.surface, false);
}
match &mut l.content {
LayerContent::Raster(s) => f(s, false),
LayerContent::Text(t) => {
if let Some(c) = &mut t.cache {
f(c, false)
}
}
LayerContent::Shape(sh) => {
if let Some(c) = &mut sh.cache {
f(c, false)
}
}
LayerContent::Smart(sm) => {
if let Some(c) = &mut sm.cache {
f(c, false)
}
// Selection-derived smart-filter masks live in document coordinates. Every
// canvas resize/translation must carry them with the smart object's cache;
// otherwise toggling a filter recomputes its coverage at stale positions (#3077).
if masks && let Some(mask) = &mut sm.filter_mask {
f(&mut mask.surface, true);
}
2026-09-30 09:37:18 -04:00
}
LayerContent::Group(g) => for_each_surface(&mut g.children, masks, f),
_ => {}
}
}
}
/// Converts every pixel surface of a layer tree (masks included) to `depth`.
pub(crate) fn convert_layers_depth(layers: &mut [Layer], depth: SampleType) {
for_each_surface(layers, true, &mut |surf, _| {
let f = surf.format().with_sample(depth);
*surf = surf.convert(f);
});
}
pub(crate) fn for_each_layer(layers: &mut [Layer], f: &mut dyn FnMut(&mut Layer)) {
2026-09-30 09:37:18 -04:00
for l in layers {
f(l);
if let Some(ch) = l.children_mut() {
for_each_layer(ch, f);
}
}
}
/// Assign new layer ids to a copied document and update every saved layer-comp reference.
///
/// A document copy needs fresh identities, but its comps must still target the copied
/// layers. Deliberately leave references to already-deleted layers untouched so their
/// missing-layer warnings remain meaningful. The same applies to Last Document State.
pub(crate) fn reidentify_copied_layers(doc: &mut Document) {
let mut ids = std::collections::HashMap::new();
for_each_layer(&mut doc.layers, &mut |layer| {
let previous = layer.id;
layer.id = LayerId::fresh();
ids.insert(previous, layer.id);
});
let remap = |comp: &mut photocraft_doc::LayerComp| {
for state in &mut comp.states {
if let Some(&new_id) = ids.get(&state.layer) {
state.layer = new_id;
}
}
};
for comp in &mut doc.layer_comps {
remap(comp);
}
if let Some(last) = &mut doc.last_document_state {
remap(last);
}
}
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style ›
/// Scale Effects, and previews that draw a style at a reduced resolution).
pub fn scale_effects(fx: &mut Effects, k: f32) {
2026-09-30 09:37:18 -04:00
for e in &mut fx.items {
match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
s.distance *= k;
s.size *= k;
}
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size *= k,
Effect::Stroke(s) => s.size *= k,
Effect::Satin(s) => {
s.distance *= k;
s.size *= k;
}
Effect::BevelEmboss(b) => {
b.size *= k;
b.soften *= k;
}
Effect::PatternOverlay { scale, .. } => *scale *= k,
Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {}
}
if let Effect::Stroke(s) = e
&& let FxPaint::Pattern { scale, .. } = &mut s.paint
{
*scale *= k;
}
}
// Effects changed: the preserved PSD block no longer applies.
if k != 1.0 {
fx.psd_raw = None;
}
}
/// Image Size's resampling methods; `None` changes the resolution only.
#[derive(Clone, Copy)]
enum Method {
None,
Filter(Resample),
/// Photoshop's Automatic: Preserve Details where the image grows, Bicubic Sharper otherwise.
Automatic,
}
fn parse_resample(s: &str) -> Method {
Method::Filter(match s {
"none" => return Method::None,
"automatic" => return Method::Automatic,
2026-09-30 09:37:18 -04:00
"nearest" | "nearestNeighbor" => Resample::Nearest,
"bilinear" => Resample::Bilinear,
"bicubicSmoother" => Resample::BicubicSmoother,
"bicubicSharper" => Resample::BicubicSharper,
"bicubicAutomatic" => Resample::BicubicAutomatic,
2026-09-30 09:37:18 -04:00
"lanczos" => Resample::Lanczos,
"preserveDetails" => Resample::PreserveDetails,
_ => Resample::Bicubic,
})
}
/// The largest raster Image Size resamples to: the decoders' default allocation budget.
const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
2026-09-30 09:37:18 -04:00
/// Image → Image Size.
fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let (ow, oh) = (d.doc.size.width, d.doc.size.height);
let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic"));
let mut pw = p.get("width").and_then(Value::as_f64);
let mut ph = p.get("height").and_then(Value::as_f64);
// `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins.
if let Some(pc) = p.get("percent") {
let pc = pc.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("image.imageSize", "`percent` must be a positive number"))?;
pw = pw.or(Some(ow as f64 * pc / 100.0));
ph = ph.or(Some(oh as f64 * pc / 100.0));
}
2026-09-30 09:37:18 -04:00
// Missing dimension keeps the aspect ratio.
let (nw, nh) = match (pw, ph) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w * oh as f64 / ow.max(1) as f64),
(None, Some(h)) => (h * ow as f64 / oh.max(1) as f64, h),
(None, None) => (ow as f64, oh as f64),
};
let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32);
// Each side may reach 300000 px, but resampling writes real pixels: refuse a canvas whose
// raster alone would pass the budget, before allocating any of it (#1544).
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")?;
}
2026-09-30 09:37:18 -04:00
let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32);
2026-10-11 10:43:09 -05:00
if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) {
// Color Sampler points scale with the pixels (same map `transform_geometry` applies).
let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1)));
s.set_sampler_map(photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] });
}
2026-09-30 09:37:18 -04:00
s.edit("Image Size", |doc, _| {
if let Some(r) = dpi {
doc.resolution_dpi = r.clamp(1.0, 10_000.0);
}
let filter = match resample {
Method::None => return Ok(()),
Method::Filter(f) => f,
Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails,
Method::Automatic => Resample::BicubicSharper,
};
resample_doc(doc, nw, nh, filter);
2026-09-30 09:37:18 -04:00
Ok(())
})?;
// The size the document has now: without resampling `width`/`height` are ignored (#1815).
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
Ok(json!({ "width": size.width, "height": size.height }))
2026-09-30 09:37:18 -04:00
}
/// Refuses to resample `doc` to `nw` x `nh` px when the raster alone would pass the budget, before
/// allocating any of it (#1544). `hint` ends the message with what the caller can do instead.
2026-10-11 13:28:11 +02:00
pub(crate) fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, hint: &str) -> Result<()> {
let bpp = doc.pixel_format().bytes_per_pixel().max(1) as u64;
let bytes = u64::from(nw).saturating_mul(u64::from(nh)).saturating_mul(bpp);
if bytes <= MAX_RESAMPLE_BYTES {
return Ok(());
}
let mp = u64::from(nw).saturating_mul(u64::from(nh)) / 1_000_000;
let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000;
Err(bad(cmd, format!("{nw} x {nh} px is {mp} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size{hint}")))
}
/// 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.
fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) {
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;
}
// Content that reaches the canvas edge keeps it: a Background stays opaque to the border.
let canvas = Rect::from_xywh(0, 0, ow, oh);
for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| {
*surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas);
});
let k = ((sx + sy) / 2.0) as f32;
for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k));
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = resize_surface_in_canvas(&ch.surface, sx, sy, Resample::Bilinear, canvas);
}
if let Some(sel) = &doc.selection {
doc.selection = Some(resize_surface_in_canvas(sel, sx, sy, Resample::Bilinear, canvas));
}
// Vector geometry, guides and marks scale with the pixels; vectors re-render sharp.
crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] });
doc.size = Size::new(nw, nh);
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
}
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
2026-09-30 09:37:18 -04:00
/// Moves every surface, channel, selection and guide by `(dx, dy)`.
///
/// Fails, before moving anything, when some pixels would land outside the i32 coordinate
/// range: the surface copy saturates its target rectangle there, which no longer matches
/// the pixel data and panics (#959).
pub(crate) fn translate_doc(doc: &mut Document, cmd: &str, dx: i32, dy: i32) -> Result<()> {
2026-09-30 09:37:18 -04:00
if dx == 0 && dy == 0 {
return Ok(());
}
let fits = |r: Rect| {
r.is_empty() || (r.x0.checked_add(dx).is_some() && r.x1.checked_add(dx).is_some() && r.y0.checked_add(dy).is_some() && r.y1.checked_add(dy).is_some())
};
let mut ok = true;
for_each_surface(&mut doc.layers, true, &mut |surf, _| ok = ok && fits(surf.content_bounds()));
ok = ok
&& doc.channels.iter().chain(doc.quick_mask.as_ref()).all(|ch| fits(ch.surface.content_bounds()))
&& doc.selection.as_ref().is_none_or(|sel| fits(sel.content_bounds()));
if !ok {
return Err(bad(cmd, format!("moving the document by ({dx}, {dy}) would push its pixels outside the 32-bit coordinate range")));
2026-09-30 09:37:18 -04:00
}
for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = translate_surface(surf, dx, dy));
2026-10-01 08:25:06 -04:00
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
2026-09-30 09:37:18 -04:00
ch.surface = translate_surface(&ch.surface, dx, dy);
}
if let Some(sel) = &doc.selection {
doc.selection = Some(translate_surface(sel, dx, dy));
}
// Type, shapes, smart objects, vector masks, paths, guides, slices, notes… (caches above
// are already translated exactly, so nothing needs re-rendering for the move itself).
crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy)));
Ok(())
2026-09-30 09:37:18 -04:00
}
/// Crops the document to `r` (in current document coordinates).
/// A frame that extends past the canvas fills the Background's new area with `extension` (the
/// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there.
fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extension: [f32; 4]) -> Result<()> {
// The origin moves to (0, 0); `-i32::MIN` has no i32 value (#959).
let (Some(dx), Some(dy)) = (r.x0.checked_neg(), r.y0.checked_neg()) else {
return Err(bad(cmd, format!("the crop origin ({}, {}) can't be moved to (0, 0) within the 32-bit coordinate range", r.x0, r.y0)));
};
let old_canvas = doc.bounds();
let extends = r.x0 < old_canvas.x0 || r.y0 < old_canvas.y0 || r.x1 > old_canvas.x1 || r.y1 > old_canvas.y1;
2026-09-30 09:37:18 -04:00
if delete_pixels {
for_each_surface(&mut doc.layers, false, &mut |surf, _| *surf = crop_surface(surf, r));
}
translate_doc(doc, cmd, dx, dy)?;
2026-09-30 09:37:18 -04:00
doc.size = Size::new(r.width(), r.height());
// A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area
// an extending Crop adds is filled with the extension (background) colour.
if extends
&& let Some(bg) = doc.background_mut()
&& let LayerContent::Raster(surf) = &mut bg.content
{
let old = old_canvas.translate(dx, dy);
let fmt = surf.format();
let px = photocraft_raster::from_rgba(&fmt, [extension[0], extension[1], extension[2], 1.0]);
let keep = surf.content_bounds().intersect(&old);
let saved = surf.to_interleaved(keep);
surf.fill_rect(Rect::from_xywh(0, 0, r.width(), r.height()), &px);
if !keep.is_empty() {
surf.write_interleaved(keep, &saved);
}
}
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
Ok(())
2026-09-30 09:37:18 -04:00
}
/// The nine documented canvas-size anchors, in the order the command lists them, each mapped to
/// its (x, y) factor (0.0 = top/left edge, 0.5 = centre, 1.0 = bottom/right edge).
const ANCHORS: [(&str, (f64, f64)); 9] = [
("topLeft", (0.0, 0.0)),
("top", (0.5, 0.0)),
("topRight", (1.0, 0.0)),
("left", (0.0, 0.5)),
("center", (0.5, 0.5)),
("right", (1.0, 0.5)),
("bottomLeft", (0.0, 1.0)),
("bottom", (0.5, 1.0)),
("bottomRight", (1.0, 1.0)),
];
/// Resolves an anchor name to its (x, y) factors, matching the nine documented values exactly.
/// Anything else is an error naming `anchor` and listing them, rather than resolving the two axes
/// independently (which let a misspelling like `"topleft"` land between two valid anchors, #2946).
fn anchor_factors(cmd: &str, a: &str) -> Result<(f64, f64)> {
ANCHORS.iter().find(|(name, _)| *name == a).map(|&(_, f)| f).ok_or_else(|| {
let names = ANCHORS.iter().map(|&(n, _)| n).collect::<Vec<_>>().join("|");
bad(cmd, format!("`anchor` must be one of {names}"))
})
2026-09-30 09:37:18 -04:00
}
fn extension_color(s: &Session, p: &Value) -> [f32; 4] {
match p.get("extensionColor").and_then(Value::as_str).unwrap_or("background") {
"foreground" => s.tools.foreground,
"white" => [1.0; 4],
"black" => [0.0, 0.0, 0.0, 1.0],
"transparent" => [0.0; 4],
hex if hex.starts_with('#') => {
let h = hex.trim_start_matches('#');
let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map_or(0.0, |v| f32::from(v) / 255.0);
[c(0), c(2), c(4), 1.0]
}
_ => s.tools.background,
}
}
/// Image → Canvas Size.
fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let (ow, oh) = (d.doc.size.width as f64, d.doc.size.height as f64);
let relative = p.get("relative").and_then(Value::as_bool).unwrap_or(false);
let w = p.get("width").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { ow });
let h = p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh });
let (nw, nh) = if relative { (ow + w, oh + h) } else { (w, h) };
let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32);
let (ax, ay) = anchor_factors("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?;
2026-09-30 09:37:18 -04:00
let dx = ((nw as f64 - ow) * ax).round() as i32;
let dy = ((nh as f64 - oh) * ay).round() as i32;
let ext = extension_color(s, p);
2026-10-11 10:43:09 -05:00
s.set_sampler_map(photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy)));
2026-09-30 09:37:18 -04:00
s.edit("Canvas Size", |doc, _| {
translate_doc(doc, "image.canvasSize", dx, dy)?;
2026-09-30 09:37:18 -04:00
doc.size = Size::new(nw, nh);
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
2026-09-30 09:37:18 -04:00
let canvas = doc.bounds();
let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32);
// A Background cannot contain transparent pixels. When a transparent
// extension adds canvas area, make it a normal paintable layer first.
// A shrink or no-op resize must not silently convert the Background.
if ext[3] == 0.0
&& (nw > ow as u32 || nh > oh as u32)
&& let Some(bg) = doc.background_mut()
{
crate::extra_cmds::unlock_background(bg);
}
2026-09-30 09:37:18 -04:00
// The locked Background layer is extended with the extension colour.
if ext[3] > 0.0
&& let Some(bg) = doc.background_mut()
2026-09-30 09:37:18 -04:00
&& let LayerContent::Raster(surf) = &mut bg.content
{
let fmt = surf.format();
let px = photocraft_raster::from_rgba(&fmt, ext);
let keep = surf.content_bounds().intersect(&old);
let saved = surf.to_interleaved(keep);
surf.fill_rect(canvas, &px);
if !keep.is_empty() {
surf.write_interleaved(keep, &saved);
}
}
Ok(())
})?;
Ok(json!({ "width": nw, "height": nh, "offset": [dx, dy] }))
}
/// The largest side an explicit crop may give the canvas (the `file.new` and Canvas Size limit).
const MAX_CROP_SIDE: i32 = 300_000;
2026-09-30 09:37:18 -04:00
/// Image → Crop (to the selection bounds).
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true);
// Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to
// the numbers passed; reject them (see `commands::int_i32`).
let explicit = match (
crate::commands::int_i32("image.crop", p, "x")?,
crate::commands::int_i32("image.crop", p, "y")?,
crate::commands::int_i32("image.crop", p, "width")?,
crate::commands::int_i32("image.crop", p, "height")?,
) {
(Some(x), Some(y), Some(w), Some(h)) if w > 0 && h > 0 => {
// The new canvas size: refuse what File › New, Image Size and Canvas Size won't make,
// before anything changes. Unbounded, a following full-canvas flatten (Trim, Duplicate
// Merged) aborted on allocation (#960).
if w > MAX_CROP_SIDE || h > MAX_CROP_SIDE {
return Err(bad("image.crop", format!("{w} x {h} exceeds the {MAX_CROP_SIDE} px limit per side")));
}
// A far edge past i32::MAX used to saturate, silently cropping less than asked (#959).
let end = |o: i32, len: i32, ko: &str, kl: &str| {
o.checked_add(len)
.ok_or_else(|| bad("image.crop", format!("`{ko}` + `{kl}` = {} is outside the 32-bit coordinate range", i64::from(o) + i64::from(len))))
};
Some(Rect::new(x, y, end(x, w, "x", "width")?, end(y, h, "y", "height")?))
}
2026-09-30 09:37:18 -04:00
_ => None,
};
let target = crop_target(p)?;
if let Some((tw, th)) = target.size {
let d = s.active().ok_or(EngineError::NoDocument)?;
check_resample_budget("image.crop", &d.doc, tw, th, "")?;
}
if let Some(a) = crop_angle(p)? {
let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?;
return rotated_crop(s, rect, a, delete, &target);
}
2026-09-30 09:37:18 -04:00
// An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped.
let r = explicit
.or_else(|| s.active().and_then(|d| d.doc.selection.as_ref().map(|sel| sel.content_bounds().intersect(&d.doc.bounds()))))
.unwrap_or(Rect::EMPTY);
if r.is_empty() {
return Err(EngineError::Other("nothing to crop: pass x/y/width/height or make a selection".into()));
}
2026-10-11 10:43:09 -05:00
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0)));
let extension = s.tools.background;
2026-09-30 09:37:18 -04:00
s.edit("Crop", |doc, _| {
crop_doc(doc, "image.crop", r, delete, extension)?;
2026-09-30 09:37:18 -04:00
doc.selection = None;
target.apply(doc);
2026-09-30 09:37:18 -04:00
Ok(())
})?;
Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })))
}
/// The Crop tool's W x H x Resolution: the size (px) the cropped area is resampled to and the
/// resolution the document gets, both optional.
struct CropTarget {
size: Option<(u32, u32)>,
resolution: Option<f32>,
filter: Resample,
}
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) {
if let Some((w, h)) = self.size {
resample_doc(doc, w, h, self.filter);
}
if let Some(r) = self.resolution {
doc.resolution_dpi = r;
}
}
}
/// The highest resolution (ppi) a crop may set, as for Image Size.
const MAX_CROP_RESOLUTION: f64 = 10_000.0;
/// `image.crop`'s optional `targetWidth`/`targetHeight` (whole px, both or neither), `resolution`
/// (ppi) and `resample` (as for `image.imageSize`; "none" is refused with a target size).
fn crop_target(p: &Value) -> Result<CropTarget> {
const CMD: &str = "image.crop";
let side = |key: &str| -> Result<Option<u32>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| (1..=MAX_CROP_SIDE as u32).contains(n))
.map(Some)
.ok_or_else(|| bad(CMD, format!("`{key}` = {v} must be a whole number of pixels from 1 to {MAX_CROP_SIDE}"))),
}
};
let size = match (side("targetWidth")?, side("targetHeight")?) {
(Some(w), Some(h)) => Some((w, h)),
(None, None) => None,
_ => return Err(bad(CMD, "`targetWidth` and `targetHeight` go together")),
};
let resolution = match p.get("resolution") {
None | Some(Value::Null) => None,
Some(v) => {
let r = v.as_f64().filter(|r| r.is_finite() && (1.0..=MAX_CROP_RESOLUTION).contains(r));
Some(r.ok_or_else(|| bad(CMD, format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_CROP_RESOLUTION}")))? as f32)
}
};
let filter = match p.get("resample") {
None | Some(Value::Null) => Some(Resample::Bicubic),
Some(v) => match parse_resample(v.as_str().ok_or_else(|| bad(CMD, "`resample` must be a string"))?) {
Method::None => None,
Method::Filter(f) => Some(f),
// The direction is known only once the frame is: bicubic's own automatic choice.
Method::Automatic => Some(Resample::BicubicAutomatic),
},
};
let filter = match (filter, size) {
(Some(f), _) => f,
(None, None) => Resample::Bicubic,
(None, Some(_)) => return Err(bad(CMD, "a `targetWidth` x `targetHeight` crop needs resampling: `resample` can't be \"none\"")),
};
Ok(CropTarget { size, resolution, filter })
}
/// A crop's result: the frame it cropped to, plus the document's size and resolution afterwards.
fn crop_result(s: &Session, mut frame: Value) -> Value {
if let (Some(d), Some(o)) = (s.active(), frame.as_object_mut()) {
o.insert("documentWidth".into(), json!(d.doc.size.width));
o.insert("documentHeight".into(), json!(d.doc.size.height));
o.insert("resolution".into(), json!(d.doc.resolution_dpi));
}
frame
2026-09-30 09:37:18 -04:00
}
/// The largest crop angle accepted (degrees), as for Image Rotation › Arbitrary.
const MAX_CROP_ANGLE: f64 = 3600.0;
/// `image.crop`'s optional `angle` in degrees, normalised to (-180, 180]; `None` when it is absent,
/// null or (after normalising) zero, so an unrotated crop takes the plain, resampling-free path.
fn crop_angle(p: &Value) -> Result<Option<f64>> {
let a = match p.get("angle") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v.as_f64().ok_or_else(|| bad("image.crop", "`angle` must be a number of degrees"))?,
};
if !a.is_finite() || a.abs() > MAX_CROP_ANGLE {
return Err(bad("image.crop", format!("`angle` must be a finite number of degrees within ±{MAX_CROP_ANGLE}")));
}
let a = a.rem_euclid(360.0);
let a = if a > 180.0 { a - 360.0 } else { a };
Ok((a.abs() >= 1e-9).then_some(a))
}
/// The Crop tool's rotated frame: `r` (document px) turned clockwise on screen by `angle` degrees
/// about its centre. The document is rotated counter-clockwise by `angle` (Image Rotation ›
/// Arbitrary: the canvas grows to fit, the Background fills what the rotation uncovers with the
/// background colour, other layers keep transparency there and every pixel of theirs), which makes
/// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in
/// one undo step.
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: &CropTarget) -> Result<Value> {
const CMD: &str = "image.crop";
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
let (w, h) = (f64::from(r.width()), f64::from(r.height()));
let (cx, cy) = (f64::from(r.x0) + w / 2.0, f64::from(r.y0) + h / 2.0);
// Where the frame's centre lands: the rotation `image.rotation.arbitrary` applies, clockwise
// by `-angle` (x' = c·x − s·y, y' = s·x + c·y) about the old and new canvas centres.
let new = crate::mode_cmds::rotated_size(size, -angle);
let (sn, cs) = (-angle).to_radians().sin_cos();
let (dx, dy) = (cx - f64::from(size.width) / 2.0, cy - f64::from(size.height) / 2.0);
let (ncx, ncy) = (f64::from(new.width) / 2.0 + cs * dx - sn * dy, f64::from(new.height) / 2.0 + sn * dx + cs * dy);
let (x, y) = ((ncx - w / 2.0).round(), (ncy - h / 2.0).round());
let far = || bad(CMD, "the rotated frame is outside the 32-bit coordinate range");
let limit = f64::from(i32::MAX / 2);
if !(x.is_finite() && y.is_finite()) || x.abs() > limit || y.abs() > limit {
return Err(far());
}
let (x, y) = (x as i32, y as i32);
let end = |o: i32, len: u32| i32::try_from(len).ok().and_then(|l| o.checked_add(l));
let (Some(x1), Some(y1)) = (end(x, r.width()), end(y, r.height())) else { return Err(far()) };
let target = Rect::new(x, y, x1, y1);
crate::analysis_cmds::compound(s, "Crop", |s| {
crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?;
2026-10-11 10:43:09 -05:00
// Color Sampler points follow the crop's origin shift too (the rotation moved them above).
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0)));
let extension = s.tools.background;
s.edit("Crop", |doc, _| {
crop_doc(doc, CMD, target, delete, extension)?;
doc.selection = None;
crop_target.apply(doc);
Ok(())
})
})?;
Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle })))
}
2026-09-30 09:37:18 -04:00
/// Image → Trim.
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let buf = photocraft_compose::flatten(&d.doc);
let based = p.get("basedOn").and_then(Value::as_str).unwrap_or("transparent");
let w = canvas.width() as usize;
let reference = match based {
"topLeft" => Some(buf.px[0]),
"bottomRight" => buf.px.last().copied(),
_ => None,
};
let differs = |q: [f32; 4]| match reference {
None => q[3] > 0.0,
Some(r) => (0..4).any(|c| (q[c] * q[3] - r[c] * r[3]).abs() > 0.5 / 255.0 || (c == 3 && (q[3] - r[3]).abs() > 0.5 / 255.0)),
};
let mut b = Rect::EMPTY;
for (i, q) in buf.px.iter().enumerate() {
if differs(*q) {
let (x, y) = ((i % w) as i32, (i / w) as i32);
b = if b.is_empty() { Rect::new(x, y, x + 1, y + 1) } else { b.union(&Rect::new(x, y, x + 1, y + 1)) };
}
}
if b.is_empty() {
return Err(EngineError::Other("nothing to trim to".into()));
}
let side = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
let r = Rect::new(
if side("left") { b.x0 } else { canvas.x0 },
if side("top") { b.y0 } else { canvas.y0 },
if side("right") { b.x1 } else { canvas.x1 },
if side("bottom") { b.y1 } else { canvas.y1 },
);
2026-10-11 10:43:09 -05:00
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0)));
let extension = s.tools.background;
s.edit("Trim", |doc, _| crop_doc(doc, "image.trim", r, true, extension))?;
2026-09-30 09:37:18 -04:00
Ok(json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }))
}
2026-10-01 08:25:06 -04:00
/// Image → Mode → colour model, through the CMS (see [`crate::color_cmds::convert_mode`]).
fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value> {
crate::color_cmds::convert_mode(s, mode, p)
2026-09-30 09:37:18 -04:00
}
/// Image → Mode → 8/16/32 Bits/Channel.
fn convert_depth(s: &mut Session, depth: SampleType) -> Result<Value> {
let current = s.active().ok_or(EngineError::NoDocument)?.doc.depth;
if current == depth {
// History::record clears redo even when an edit closure leaves the document unchanged.
return Ok(Value::Null);
}
2026-09-30 09:37:18 -04:00
s.edit("Bit Depth", |doc, _| {
// 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured
// in Photoshop 27.11), so the colour samples cross the document's tone curve when the
// depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear
// value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour.
let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale);
let to_linear = depth == SampleType::F32;
let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth });
if crosses && !to_linear {
for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0)));
}
convert_layers_depth(&mut doc.layers, depth);
if crosses && to_linear {
for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v)));
}
2026-10-01 08:25:06 -04:00
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
2026-09-30 09:37:18 -04:00
let f = ch.surface.format().with_sample(depth);
ch.surface = ch.surface.convert(f);
}
doc.depth = depth;
Ok(())
})?;
Ok(Value::Null)
}
/// Image → Duplicate.
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let mut doc = (*d.doc).clone();
doc.id = photocraft_doc::DocId::fresh();
doc.name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{} copy", d.doc.name));
if p.get("mergedOnly").and_then(Value::as_bool).unwrap_or(false) {
let buf = photocraft_compose::flatten(&doc);
let fmt = doc.pixel_format();
let mut surf = Surface::new(fmt);
let vals: Vec<f32> = buf.px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
surf.write_region(doc.bounds(), &vals);
surf.prune();
doc.layers = vec![Layer::new("Background", LayerContent::Raster(surf))];
// A merged-only copy has no original layers for saved comps to restore.
// Don't carry dangling comp state into a single-layer flattened document.
doc.layer_comps.clear();
doc.last_applied_comp = None;
doc.last_document_state = None;
2026-09-30 09:37:18 -04:00
}
reidentify_copied_layers(&mut doc);
2026-09-30 09:37:18 -04:00
let name = doc.name.clone();
let index = s.add_document(doc, None);
Ok(json!({ "document": index, "name": name }))
}
macro_rules! spec {
($id:literal, $label:literal, [$($m:literal),*], $params:literal, $en:expr, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: $en, run: $run, journal: true }
};
}
/// Image menu command specs.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"image.imageSize",
"Image Size…",
["Image"],
r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##,
2026-09-30 09:37:18 -04:00
has_doc,
image_size
),
spec!(
"image.canvasSize",
"Canvas Size…",
["Image"],
r##"{"width":px,"height":px,"relative":bool=false,"anchor":"topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight"="center","extensionColor":"background|foreground|white|black|transparent|#rrggbb"="background"}"##,
has_doc,
canvas_size
),
spec!(
"image.crop",
"Crop",
["Image"],
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"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)"##,
has_doc,
crop
),
2026-09-30 09:37:18 -04:00
spec!(
"image.trim",
"Trim…",
["Image"],
r##"{"basedOn":"transparent|topLeft|bottomRight"="transparent","top":bool=true,"bottom":bool=true,"left":bool=true,"right":bool=true}"##,
has_doc,
trim
),
2026-10-01 08:25:06 -04:00
spec!(
"image.mode.rgb",
"RGB Color",
["Image", "Mode"],
r##"{"profile":"<builtin id>|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##,
has_doc,
|s, p| convert_mode(s, ColorMode::Rgb, p)
),
spec!(
"image.mode.grayscale",
"Grayscale",
["Image", "Mode"],
r##"{"profile":"<builtin id>|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##,
has_doc,
|s, p| convert_mode(s, ColorMode::Grayscale, p)
),
spec!(
"image.mode.cmyk",
"CMYK Color",
["Image", "Mode"],
r##"{"profile":"<builtin id>|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##,
has_doc,
|s, p| convert_mode(s, ColorMode::Cmyk, p)
),
spec!(
"image.mode.lab",
"Lab Color",
["Image", "Mode"],
r##"{"profile":"<builtin id>|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##,
has_doc,
|s, p| convert_mode(s, ColorMode::Lab, p)
),
2026-09-30 09:37:18 -04:00
spec!("image.mode.bits8", "8 Bits/Channel", ["Image", "Mode"], "{}", has_doc, |s, _| convert_depth(s, SampleType::U8)),
spec!("image.mode.bits16", "16 Bits/Channel", ["Image", "Mode"], "{}", has_doc, |s, _| convert_depth(s, SampleType::U16)),
spec!("image.mode.bits32", "32 Bits/Channel", ["Image", "Mode"], "{}", has_doc, |s, _| convert_depth(s, SampleType::F32)),
spec!("image.duplicate", "Duplicate…", ["Image"], r##"{"name":str,"mergedOnly":bool=false}"##, has_doc, duplicate),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crop_rejects_rectangles_that_would_wrap() {
let mut s = session();
// 2^32 + 100 wrapped to `x = 100` through the narrowing casts: a crop rectangle
// somewhere unrelated to the numbers passed.
let err = s.execute("image.crop", json!({"x": 4_294_967_396_i64, "y": 0, "width": 10, "height": 10})).unwrap_err();
assert!(err.to_string().contains("32-bit"), "{err}");
// In-range rectangles past the canvas still work (clamped by the crop itself).
s.execute("image.crop", json!({"x": -10, "y": -10, "width": 1000, "height": 1000})).unwrap();
}
/// A crop whose translation to the origin can't be represented in i32 is rejected before
/// anything changes (#959): `-i32::MIN` overflowed the negation in `crop_doc`, `x + width`
/// past i32::MAX silently shortened the crop, and an origin one past i32::MIN moved the
/// layer pixels beyond i32::MAX (a length-mismatch panic while translating surfaces).
#[test]
fn crop_rejects_origins_that_cannot_move_to_zero() {
const MIN: i64 = i32::MIN as i64;
const MAX: i64 = i32::MAX as i64;
let cases = [
json!({"x": MIN, "y": 0, "width": 1, "height": 1}),
json!({"x": 0, "y": MIN, "width": 1, "height": 1}),
json!({"x": MIN, "y": MIN, "width": 1, "height": 1}),
json!({"x": MIN, "y": 0, "width": 1, "height": 1, "deleteCroppedPixels": false}),
json!({"x": MAX - 5, "y": 0, "width": 10, "height": 1}),
json!({"x": 0, "y": MAX - 5, "width": 1, "height": 10}),
json!({"x": MIN + 1, "y": 0, "width": 1, "height": 1, "deleteCroppedPixels": false}),
json!({"x": 0, "y": MIN + 1, "width": 1, "height": 1, "deleteCroppedPixels": false}),
];
for p in cases {
let mut s = session();
let before = s.active().unwrap().doc.clone();
let (rev, steps) = (s.active().unwrap().revision, s.active().unwrap().history.entries().len());
let err = s.execute("image.crop", p.clone()).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err}");
assert!(err.to_string().contains("32-bit"), "{p}: {err}");
let st = s.active().unwrap();
assert!(std::sync::Arc::ptr_eq(&st.doc, &before), "{p}: document changed");
assert_eq!(st.doc.size, Size::new(40, 20), "{p}");
assert_eq!(st.doc.layers[1].surface().unwrap().content_bounds(), Rect::new(10, 5, 20, 15), "{p}");
assert_eq!((st.revision, st.history.entries().len()), (rev, steps), "{p}: history step recorded");
}
// Control: a legal negative origin past the canvas still crops (and extends) the canvas.
let mut s = session();
s.execute("image.crop", json!({"x": -10, "y": -5, "width": 60, "height": 30})).unwrap();
assert_eq!(doc(&s).size, Size::new(60, 30));
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 10), vec![1.0, 0.0, 0.0, 1.0]);
// A far-off crop that deletes the (non-overlapping) pixels has nothing left to move.
let mut s = session();
s.execute("image.crop", json!({"x": MIN + 1, "y": 0, "width": 1, "height": 1})).unwrap();
assert_eq!(doc(&s).size, Size::new(1, 1));
}
2026-09-30 09:37:18 -04:00
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 20})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let l = doc.layer_mut(active.unwrap()).unwrap();
l.surface_mut().unwrap().fill_rect(Rect::new(10, 5, 20, 15), &[1.0, 0.0, 0.0, 1.0]);
l.mask = Some(photocraft_doc::LayerMask::reveal_all());
l.effects.items.push(Effect::default_drop_shadow());
Ok(())
})
.unwrap();
s
}
fn doc(s: &Session) -> &Document {
&s.active().unwrap().doc
}
#[test]
fn image_size_scales_everything() {
for resample in ["bicubic", "bicubicSmoother", "bicubicSharper", "bicubicAutomatic", "automatic", "bilinear", "nearest", "lanczos", "preserveDetails"] {
2026-09-30 09:37:18 -04:00
let mut s = session();
s.execute("image.imageSize", json!({"width": 80, "resample": resample})).unwrap();
let d = doc(&s);
assert_eq!(d.size, Size::new(80, 40), "{resample}");
let l = &d.layers[1];
let b = l.surface().unwrap().content_bounds();
assert!((b.x0 - 20).abs() <= 1 && (b.x1 - 40).abs() <= 1, "{resample} {b:?}");
let Effect::DropShadow(sh) = &l.effects.items[0] else { panic!() };
assert_eq!(sh.distance, 10.0);
assert_eq!(l.mask.as_ref().unwrap().surface.default_pixel(), vec![1.0]);
}
}
/// The canvas border stays opaque after Image Size (it used to fade into transparency, so a
/// Background or a 200 % export got a translucent frame), and a full selection stays full.
/// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands
/// without a budget. It is refused before any allocation and the document is untouched; a
/// change of resolution only, and an ordinary resize, still work.
#[test]
fn image_size_refuses_a_raster_past_the_budget() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let before = (doc(&s).size, s.active().unwrap().history.past_len());
let err = s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000})).unwrap_err().to_string();
assert!(err.contains("megapixels"), "{err}");
assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), before);
s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000, "resample": "none", "resolution": 300})).unwrap();
s.execute("image.imageSize", json!({"width": 128, "height": 96})).unwrap();
assert_eq!(doc(&s).size, Size::new(128, 96));
}
#[test]
fn image_size_keeps_canvas_edges_opaque() {
for depth in [8, 16, 32] {
for (w, resample) in [
(60, "bicubic"),
(41, "lanczos"),
(15, "bilinear"),
(77, "preserveDetails"),
(21, "bicubicSharper"),
(47, "automatic"),
(9, "bicubicAutomatic"),
] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 30, "height": 20, "depth": depth, "background": "#336699"})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("image.imageSize", json!({"width": w, "resample": resample})).unwrap();
let d = doc(&s);
let flat = photocraft_compose::flatten(d);
let (w, h) = (flat.rect.width() as usize, flat.rect.height() as usize);
let mid = flat.px[(h / 2) * w + w / 2];
assert!((mid[3] - 1.0).abs() < 1e-3, "{depth} {resample}: {mid:?}");
for p in &flat.px {
let off = p.iter().zip(mid).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(off < 0.01, "{depth} {resample} {w}: {p:?} vs {mid:?}");
}
let sel = d.selection.as_ref().unwrap();
let (x1, y1) = (d.size.width as i32 - 1, d.size.height as i32 - 1);
for (x, y) in [(0, 0), (x1, 0), (0, y1), (x1, y1)] {
assert!(sel.pixel(x, y)[0] > 0.99, "{depth} {resample}: selection at ({x},{y})");
}
}
}
}
2026-09-30 09:37:18 -04:00
#[test]
fn resolution_only_change() {
let mut s = session();
let r = s.execute("image.imageSize", json!({"resolution": 300, "resample": "none", "width": 999})).unwrap();
2026-09-30 09:37:18 -04:00
assert_eq!(doc(&s).size, Size::new(40, 20));
assert_eq!(doc(&s).resolution_dpi, 300.0);
// #1815: the result is the size the document has, not the ignored request.
assert_eq!(r, json!({"width": 40, "height": 20}));
}
#[test]
fn image_size_reports_the_size_it_applied() {
// #1815: every path reports the document's size afterwards, at every depth.
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth})).unwrap();
let size = |s: &Session| json!({"width": doc(s).size.width, "height": doc(s).size.height});
for p in [
json!({"width": 999, "height": 777, "resample": "none", "resolution": 150}),
json!({"width": 32}),
json!({"height": 96, "resample": "nearest"}),
json!({"width": 96, "height": 72, "resample": "none"}),
json!({}),
] {
let r = s.execute("image.imageSize", p.clone()).unwrap();
assert_eq!(r, size(&s), "{depth}-bit {p}");
}
// 64×48 → (none) → 32×24 → 128×96 (aspect kept) → (none) → unchanged.
assert_eq!(doc(&s).size, Size::new(128, 96), "{depth}-bit");
}
2026-09-30 09:37:18 -04:00
}
/// #1114: nearest neighbor reduction keeps source values without averaging.
#[test]
fn nearest_neighbor_reduction_keeps_source_values() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
for y in 0..8 {
for x in 0..8 {
if (x + y) % 2 == 0 {
s.execute("select.rect", json!({"x": x, "y": y, "width": 1, "height": 1})).unwrap();
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
}
}
}
s.execute("select.deselect", json!({})).unwrap();
let st = s.active().unwrap();
let before = st.doc.layers[0].surface().unwrap().rgba(0, 0);
assert!(before[0] < 0.01, "setup: (0,0) should be black, got {before:?}");
s.execute("image.imageSize", json!({"width": 4, "height": 4, "resample": "nearest"})).unwrap();
let st = s.active().unwrap();
let surf = st.doc.layers[0].surface().unwrap();
for (x, y) in [(0, 0), (1, 1), (2, 3)] {
let v = surf.rgba(x, y)[0];
assert!(v.is_finite() && !(0.01..=0.99).contains(&v), "nearest produced an averaged value {v} at ({x},{y})");
}
}
2026-09-30 09:37:18 -04:00
#[test]
fn canvas_size_anchors_and_extends_background() {
let mut s = session();
s.execute("image.canvasSize", json!({"width": 60, "height": 30, "anchor": "topLeft", "extensionColor": "#00ff00"})).unwrap();
let d = doc(&s);
assert_eq!(d.size, Size::new(60, 30));
assert_eq!(d.layers[1].surface().unwrap().content_bounds(), Rect::new(10, 5, 20, 15));
let bg = d.layers[0].surface().unwrap();
assert_eq!(bg.pixel(50, 25), vec![0.0, 1.0, 0.0, 1.0]);
assert_eq!(bg.pixel(5, 5), vec![1.0, 1.0, 1.0, 1.0]);
let mut s = session();
s.execute("image.canvasSize", json!({"width": 20, "height": 10, "relative": true, "anchor": "center"})).unwrap();
assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 20));
}
#[test]
fn canvas_size_anchor_offsets_and_rejects_unlisted() {
// 64x48 -> 100x80: dx spans 36 and dy spans 32, so each anchor factor lands on a known
// offset. All nine documented values must anchor exactly where they do today (#2946).
let expected = [
("topLeft", [0, 0]),
("top", [18, 0]),
("topRight", [36, 0]),
("left", [0, 16]),
("center", [18, 16]),
("right", [36, 16]),
("bottomLeft", [0, 32]),
("bottom", [18, 32]),
("bottomRight", [36, 32]),
];
for (anchor, off) in expected {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
let r = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap();
assert_eq!(r["offset"], json!(off), "anchor {anchor}");
}
// An omitted anchor still defaults to center.
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
assert_eq!(s.execute("image.canvasSize", json!({"width": 100, "height": 80})).unwrap()["offset"], json!([18, 16]));
// A misspelling or unknown value is a BadParams error naming `anchor`, never an anchor
// between two of the nine (the old independent-axis matching produced top-centre etc.).
for anchor in ["topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE"] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
let err = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "anchor {anchor:?} must be rejected, got {err:?}");
assert!(err.to_string().contains("anchor"), "the error for {anchor:?} must name `anchor`: {err}");
}
}
2026-09-30 09:37:18 -04:00
#[test]
fn crop_to_selection_and_trim() {
let mut s = session();
assert!(s.execute("image.crop", json!({})).is_err(), "no selection, no rect");
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
s.execute("image.crop", json!({})).unwrap();
let d = doc(&s);
assert_eq!(d.size, Size::new(20, 10));
assert!(d.selection.is_none());
assert_eq!(d.layers[1].surface().unwrap().content_bounds(), Rect::new(5, 3, 15, 10));
// Trim transparent: hide the background, trim to the red square.
let mut s = session();
s.execute("layer.setProps", json!({"layer": doc(&s).layers[0].id.0, "visible": false})).unwrap();
s.execute("image.trim", json!({"basedOn": "transparent"})).unwrap();
// The red square (10×10) plus its drop shadow (distance 5, size 5).
let size = doc(&s).size;
assert!(size.width > 10 && size.width < 30 && size.height > 10 && size.height < 25, "{size:?}");
}
#[test]
fn crop_to_explicit_rect_can_extend_canvas() {
let mut s = session();
s.execute("image.crop", json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap();
assert_eq!(doc(&s).size, Size::new(10, 10));
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(0, 0), vec![1.0, 0.0, 0.0, 1.0]);
let mut s = session();
s.execute("image.crop", json!({"x": -10, "y": 0, "width": 60, "height": 20})).unwrap();
assert_eq!(doc(&s).size, Size::new(60, 20));
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn crop_extension_fills_the_background_with_the_background_colour() {
let mut shrink = Session::new();
shrink.execute("file.new", json!({"width": 16, "height": 16, "background": "#ff0000"})).unwrap();
shrink.execute("image.crop", json!({"x": 2, "y": 0, "width": 12, "height": 16})).unwrap();
let layer = &doc(&shrink).layers[0];
assert_eq!(layer.name, "Background", "a shrink-only crop keeps the Background");
assert!(layer.locks.transparency && layer.locks.position);
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "background": "#ff0000"})).unwrap();
s.tools.background = [0.0, 1.0, 0.0, 1.0];
s.execute("image.crop", json!({"x": -4, "y": 0, "width": 20, "height": 16})).unwrap();
let layer = &doc(&s).layers[0];
assert_eq!(layer.name, "Background", "an extending crop keeps the Background (#3081)");
assert!(layer.locks.transparency && layer.locks.position);
assert_eq!(layer.surface().unwrap().rgba(1, 8), [0.0, 1.0, 0.0, 1.0], "the added border takes the background colour");
assert_eq!(layer.surface().unwrap().rgba(8, 8), [1.0, 0.0, 0.0, 1.0], "the original pixels stay");
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
let layer = &doc(&s).layers[0];
assert_eq!(layer.surface().unwrap().rgba(1, 8), [0.0, 0.0, 1.0, 1.0], "Fill paints the extended border");
assert_eq!(layer.surface().unwrap().rgba(8, 8), [0.0, 0.0, 1.0, 1.0]);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let layer = &doc(&s).layers[0];
assert_eq!(doc(&s).size, Size::new(16, 16));
assert_eq!(layer.surface().unwrap().rgba(8, 8), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(layer.surface().unwrap().content_bounds(), Rect::new(0, 0, 16, 16), "undo removes the filled border");
// A normal layer gets transparent pixels in the added area.
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "background": "#ff0000"})).unwrap();
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
s.execute("image.crop", json!({"x": -4, "y": 0, "width": 20, "height": 16})).unwrap();
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 8), [0.0; 4]);
}
/// A document (`mode`, `depth`) with a white Background and a layer holding a red pixel at
/// (12, 8) and a blue one at (30, 3).
fn marked(mode: &str, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 20, "mode": mode, "depth": depth, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
for (x, y, c) in [(12, 8, "#ff0000"), (30, 3, "#0000ff")] {
s.execute("select.rect", json!({"x": x, "y": y, "width": 1, "height": 1})).unwrap();
s.execute("edit.fill", json!({"color": c})).unwrap();
}
s.execute("select.deselect", json!({})).unwrap();
s
}
fn layer_rgba(s: &Session, layer: usize, x: i32, y: i32) -> [f32; 4] {
doc(s).layers[layer].surface().unwrap().rgba(x, y)
}
/// #1792: a frame turned 90° clockwise crops what Image Rotation › Arbitrary 90° CCW and a
/// plain crop give, in one undo step that restores the document, at 8, 16 and 32 bits.
#[test]
fn crop_at_90_degrees_is_rotate_then_crop_in_one_step() {
for depth in [8, 16, 32] {
let mut s = marked("rgb", depth);
let before = doc(&s).clone();
let past = s.active().unwrap().history.past_len();
// The 10 × 20 frame at (10, 0), turned 90° about (15, 10), covers x 5..25, y 5..15.
let r = s.execute("image.crop", json!({"x": 10, "y": 0, "width": 10, "height": 20, "angle": 90})).unwrap();
assert_eq!(r["angle"], json!(90.0), "{depth}");
assert_eq!(doc(&s).size, Size::new(10, 20), "{depth}");
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{depth}: one undo step");
// (12, 8) is 2.5 left of and 1.5 above the centre: in the upright frame, 1.5 left of
// and 2.5 below its centre (5, 10).
let red = layer_rgba(&s, 1, 3, 12);
assert!(red[0] > 0.9 && red[1] < 0.1 && red[3] > 0.9, "{depth}: {red:?}");
let mut reference = marked("rgb", depth);
reference.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "ccw"})).unwrap();
reference.execute("image.crop", json!({"x": 5, "y": 15, "width": 10, "height": 20})).unwrap();
for l in 0..2 {
for y in 0..20 {
for x in 0..10 {
let (a, b) = (layer_rgba(&s, l, x, y), layer_rgba(&reference, l, x, y));
assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}");
}
}
}
s.execute("edit.undo", json!({})).unwrap();
let d = doc(&s);
assert_eq!(d.size, before.size, "{depth}");
assert_eq!(d.depth, before.depth);
for y in 0..20 {
for x in 0..40 {
assert_eq!(layer_rgba(&s, 1, x, y), before.layers[1].surface().unwrap().rgba(x, y), "{depth} undo at ({x}, {y})");
}
}
}
}
/// A slightly turned frame keeps its own size, the pixel under its centre and the Background's
/// colour; the turn's sign matters (a counter-clockwise frame is a negative angle).
#[test]
fn crop_at_a_small_angle_keeps_the_frame_size_in_every_mode() {
for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 16), ("cmyk", 8), ("lab", 16)] {
for angle in [5.0, -7.5, 365.0] {
let mut s = marked(mode, depth);
s.execute("image.crop", json!({"x": 2, "y": 1, "width": 21, "height": 15, "angle": angle})).unwrap();
let d = doc(&s);
assert_eq!(d.size, Size::new(21, 15), "{mode} {depth} {angle}");
assert_eq!(d.depth.bytes() * 8, depth as usize, "{mode} {depth}");
assert_eq!(format!("{:?}", d.mode).to_lowercase(), mode, "{mode} {depth}");
assert_eq!(d.layers.len(), 2);
// The frame's centre (12.5, 8.5) is the red pixel (the layer's only paint nearby);
// it stays at the centre, its colour kept.
let want = layer_rgba(&marked(mode, depth), 1, 12, 8);
let red = layer_rgba(&s, 1, 10, 7);
assert!(red[3] > 0.5 && (0..3).all(|c| (red[c] - want[c]).abs() < 0.2), "{mode} {depth} {angle}: {red:?} vs {want:?}");
// The Background stays opaque white inside the canvas it came from.
let bg = layer_rgba(&s, 0, 10, 7);
assert!(bg.iter().all(|v| (v - 1.0).abs() < 0.02), "{mode} {depth} {angle}: {bg:?}");
}
}
}
/// Angle 0 (or a whole turn) is the plain crop: no resampling, the same document.
#[test]
fn crop_at_angle_zero_is_the_plain_crop() {
let mut plain = marked("rgb", 16);
plain.execute("image.crop", json!({"x": 3, "y": 2, "width": 30, "height": 10})).unwrap();
for angle in [json!(0), json!(0.0), json!(360), json!(-720), json!(null)] {
let mut s = marked("rgb", 16);
let r = s.execute("image.crop", json!({"x": 3, "y": 2, "width": 30, "height": 10, "angle": angle})).unwrap();
assert!(r.get("angle").is_none(), "{angle}: {r}");
assert_eq!(doc(&s).size, Size::new(30, 10));
for l in 0..2 {
for y in 0..10 {
for x in 0..30 {
assert_eq!(layer_rgba(&s, l, x, y), layer_rgba(&plain, l, x, y), "{angle} layer {l} ({x}, {y})");
}
}
}
}
}
/// Bad angles fail cleanly, before anything changes.
#[test]
fn crop_angle_rejects_bad_values_without_changing_the_document() {
let bad = [
json!({"x": 0, "y": 0, "width": 10, "height": 10, "angle": "NaN"}),
json!({"x": 0, "y": 0, "width": 10, "height": 10, "angle": "12"}),
json!({"x": 0, "y": 0, "width": 10, "height": 10, "angle": [1]}),
json!({"x": 0, "y": 0, "width": 10, "height": 10, "angle": true}),
json!({"x": 0, "y": 0, "width": 10, "height": 10, "angle": 1e308}),
json!({"x": 0, "y": 0, "width": 10, "height": 10, "angle": -3601}),
json!({"x": 0, "y": 0, "width": 10, "height": 10, "angle": f64::MAX}),
// An angle needs the frame: a selection crop has none.
json!({"angle": 10}),
json!({"x": 0, "y": 0, "width": 0, "height": 10, "angle": 10}),
json!({"x": 0, "y": 0, "width": 300_001, "height": 10, "angle": 10}),
json!({"x": i32::MAX - 5, "y": 0, "width": 10, "height": 10, "angle": 10}),
];
for p in bad {
let mut s = marked("rgb", 8);
s.execute("select.all", json!({})).unwrap();
let before = doc(&s).clone();
let past = s.active().unwrap().history.past_len();
assert!(s.execute("image.crop", p.clone()).is_err(), "{p}");
assert_eq!(doc(&s).size, before.size, "{p}");
assert_eq!(s.active().unwrap().history.past_len(), past, "{p}");
}
// Far-off but representable frames turn and crop without panicking.
for (x, y) in [(-1_000_000, 0), (0, 1_000_000), (i32::MIN / 4, i32::MAX / 4)] {
let mut s = marked("rgb", 8);
s.execute("image.crop", json!({"x": x, "y": y, "width": 4, "height": 3, "angle": 33.3})).unwrap();
assert_eq!(doc(&s).size, Size::new(4, 3));
}
}
#[test]
fn crop_refuses_a_canvas_side_past_the_new_document_limit() {
// #960: an unbounded explicit crop left a canvas that a later Trim or Duplicate Merged
// could only abort on allocating.
for (w, h) in [(100_000_000, 100_000_000), (300_001, 10), (10, 300_001)] {
let mut s = session();
let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len());
let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": w, "height": h})).unwrap_err();
assert!(err.to_string().contains("300000 px limit"), "{w} x {h}: {err}");
assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (size, past), "{w} x {h}: nothing changed");
}
// At the limit it still extends the canvas (the tiles stay lazy).
let mut s = session();
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 300_000, "height": 2})).unwrap();
assert_eq!(doc(&s).size, Size::new(300_000, 2));
}
/// #2443: the Crop tool's W x H x Resolution crops, resamples the cropped area to exactly the
/// target size and sets the resolution, in one undo step, at 8 and 16 bits.
#[test]
fn crop_to_a_target_size_and_resolution_is_one_step() {
for depth in [8, 16] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 300, "resolution": 72, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(100, 100, 200, 225), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
let past = s.active().unwrap().history.past_len();
// 4 x 5 in at 300 ppi from a 100 x 125 px frame.
let r = s
.execute("image.crop", json!({"x": 100, "y": 100, "width": 100, "height": 125, "targetWidth": 1200, "targetHeight": 1500, "resolution": 300}))
.unwrap();
assert_eq!((r["documentWidth"].as_u64(), r["documentHeight"].as_u64()), (Some(1200), Some(1500)), "{depth}: {r}");
assert_eq!(r["resolution"].as_f64(), Some(300.0), "{depth}");
let d = doc(&s);
assert_eq!(d.size, Size::new(1200, 1500), "{depth}");
assert_eq!(d.resolution_dpi, 300.0, "{depth}");
assert_eq!(d.layers[0].surface().unwrap().format().sample, if depth == 8 { SampleType::U8 } else { SampleType::U16 });
// The red frame filled the crop; it fills the result.
let red = d.layers[1].surface().unwrap();
for (x, y) in [(0, 0), (600, 750), (1199, 1499)] {
assert_eq!(red.pixel(x, y), vec![1.0, 0.0, 0.0, 1.0], "{depth}: ({x}, {y})");
}
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{depth}: one history step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(400, 300), 72.0), "{depth}: undo restores the original");
}
}
#[test]
fn crop_target_resamples_after_a_turned_crop_and_resolution_alone_keeps_the_pixels() {
let mut s = session();
let past = s.active().unwrap().history.past_len();
s.execute("image.crop", json!({"x": 10, "y": 0, "width": 10, "height": 20, "angle": 90, "targetWidth": 30, "targetHeight": 60, "resample": "nearest"}))
.unwrap();
assert_eq!(doc(&s).size, Size::new(30, 60));
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "rotate, crop and resample: one step");
let mut s = session();
s.execute("image.crop", json!({"x": 2, "y": 3, "width": 30, "height": 10, "resolution": 150})).unwrap();
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0));
}
#[test]
fn crop_target_rejects_bad_params_without_changing_the_document() {
let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10});
let bad_params = [
json!({"targetWidth": 100}),
json!({"targetHeight": 100}),
json!({"targetWidth": 0, "targetHeight": 10}),
json!({"targetWidth": -5, "targetHeight": 10}),
json!({"targetWidth": 10.5, "targetHeight": 10}),
json!({"targetWidth": "10", "targetHeight": 10}),
json!({"targetWidth": 300_001, "targetHeight": 10}),
json!({"targetWidth": 4_294_967_306_u64, "targetHeight": 10}),
json!({"targetWidth": 300_000, "targetHeight": 300_000}),
json!({"targetWidth": 10, "targetHeight": 10, "resample": "none"}),
json!({"targetWidth": 10, "targetHeight": 10, "resample": 3}),
json!({"resolution": 0}),
json!({"resolution": -72}),
json!({"resolution": 1e9}),
json!({"resolution": "300"}),
];
for extra in bad_params {
let mut s = session();
let (size, dpi, past) = (doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len());
let mut p = rect.clone();
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
assert!(s.execute("image.crop", 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");
}
}
2026-09-30 09:37:18 -04:00
#[test]
fn trim_based_on_top_left_colour() {
let mut s = session();
s.edit("no fx", |doc, _| {
doc.layers[1].effects.items.clear();
Ok(())
})
.unwrap();
s.execute("image.trim", json!({"basedOn": "topLeft"})).unwrap();
assert_eq!(doc(&s).size, Size::new(10, 10));
}
#[test]
fn thirty_two_bit_holds_linear_light() {
// Photoshop 27.11: an 8-bit sRGB grey 128 becomes 0.2159 in 32 bits (and 32 → 0.0144),
// and comes back unchanged; alpha keeps its value.
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 1, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let l = doc.layer_mut(active.unwrap()).unwrap();
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &[128.0 / 255.0, 32.0 / 255.0, 1.0, 0.5]);
Ok(())
})
.unwrap();
s.execute("image.mode.bits32", json!({})).unwrap();
let px = doc(&s).layers[1].surface().unwrap().pixel(0, 0);
for (got, want) in px.iter().zip([0.2159, 0.0144, 1.0, 128.0 / 255.0]) {
assert!((got - want).abs() < 2e-4, "{px:?}");
}
s.execute("image.mode.bits8", json!({})).unwrap();
let px = doc(&s).layers[1].surface().unwrap().pixel(0, 0);
for (got, want) in px.iter().zip([128.0 / 255.0, 32.0 / 255.0, 1.0, 128.0 / 255.0]) {
assert!((got - want).abs() < 0.6 / 255.0, "{px:?}");
}
}
2026-09-30 09:37:18 -04:00
#[test]
fn mode_and_depth_conversions() {
let mut s = session();
s.execute("image.mode.grayscale", json!({})).unwrap();
assert_eq!(doc(&s).mode, ColorMode::Grayscale);
assert_eq!(doc(&s).layers[1].surface().unwrap().format().mode, ColorMode::Grayscale);
s.execute("image.mode.bits16", json!({})).unwrap();
assert_eq!(doc(&s).depth, SampleType::U16);
assert_eq!(doc(&s).layers[1].surface().unwrap().format().sample, SampleType::U16);
assert_eq!(doc(&s).layers[1].mask.as_ref().unwrap().surface.format().sample, SampleType::U16);
s.execute("image.mode.cmyk", json!({})).unwrap();
s.execute("image.mode.bits32", json!({})).unwrap();
s.execute("image.mode.lab", json!({})).unwrap();
s.execute("image.mode.rgb", json!({})).unwrap();
s.execute("image.mode.bits8", json!({})).unwrap();
let d = doc(&s);
assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::U8));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s).depth, SampleType::F32);
let revision = s.active().unwrap().revision;
s.execute("image.mode.bits32", json!({})).unwrap();
assert_eq!(s.active().unwrap().revision, revision, "an unchanged bit depth must preserve the history branch");
assert!(s.redo(), "selecting the current depth must not discard the undone conversion");
assert_eq!(doc(&s).depth, SampleType::U8);
2026-09-30 09:37:18 -04:00
}
#[test]
fn duplicate_document() {
let mut s = session();
let r = s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap();
assert_eq!(r["document"], json!(1));
assert_eq!(s.documents().len(), 2);
assert_eq!(doc(&s).layers.len(), 1);
assert!(doc(&s).name.ends_with("copy"));
}
}