2026-09-30 09:37:18 -04:00
//! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and
//! bit depth) conversions, Duplicate.
2026-10-07 16:33:04 -04:00
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 };
2026-10-11 00:12:15 +07:00
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 )
}
2026-10-11 16:18:52 +07:00
// 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 ),
_ => {}
}
}
}
2026-10-08 10:44:21 +07:00
/// 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 );
}
}
}
2026-10-11 00:12:15 +07:00
/// 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 );
}
}
2026-10-12 03:37:15 -07:00
/// 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 ;
}
}
2026-10-11 14:25:21 +02:00
/// 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 ,
2026-10-11 14:25:21 +02:00
"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 ,
})
}
2026-10-09 10:03:08 +07:00
/// 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" ));
2026-10-12 18:12:35 -05:00
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 );
2026-10-09 10:03:08 +07:00
// 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).
2026-10-11 14:25:21 +02:00
if ! matches! ( resample , Method ::None ) && ( nw , nh ) != ( ow , oh ) {
2026-10-10 19:46:31 +02:00
check_resample_budget ( "image.imageSize" , & d . doc , nw , nh , ", or turn Resample off to change only the resolution" ) ? ;
2026-10-09 10:03:08 +07:00
}
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 );
}
2026-10-11 14:25:21 +02:00
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 ,
};
2026-10-10 19:46:31 +02:00
resample_doc ( doc , nw , nh , filter );
2026-09-30 09:37:18 -04:00
Ok (())
}) ? ;
2026-10-09 09:13:12 -07:00
// 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
}
2026-10-10 19:46:31 +02: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 < () > {
2026-10-10 19:46:31 +02:00
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 );
}
2026-10-08 12:05:25 -07:00
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)`.
2026-10-08 12:05:25 -07:00
///
/// 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).
2026-10-11 23:30:35 +02:00
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 {
2026-10-08 12:05:25 -07:00
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 ));
}
2026-10-04 16:17:39 -04:00
// 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 )));
2026-10-08 12:05:25 -07:00
Ok (())
2026-09-30 09:37:18 -04:00
}
/// Crops the document to `r` (in current document coordinates).
2026-10-11 22:46:09 +02:00
/// 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 < () > {
2026-10-08 12:05:25 -07:00
// 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 )));
};
2026-10-11 22:46:09 +02:00
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 ));
}
2026-10-08 12:05:25 -07:00
translate_doc ( doc , cmd , dx , dy ) ? ;
2026-09-30 09:37:18 -04:00
doc . size = Size ::new ( r . width (), r . height ());
2026-10-11 22:46:09 +02:00
// 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
2026-10-12 07:04:33 -04:00
&& let Some ( bg ) = doc . background_mut ()
2026-10-11 22:46:09 +02:00
&& 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 );
}
}
2026-10-04 16:17:39 -04:00
crate ::canvas_geom ::refresh ( doc , crate ::canvas_geom ::Refresh ::Shapes );
2026-10-08 12:05:25 -07:00
Ok (())
2026-09-30 09:37:18 -04:00
}
2026-10-12 11:13:20 +03: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 );
2026-10-12 11:13:20 +03:00
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 , _ | {
2026-10-08 12:05:25 -07:00
translate_doc ( doc , "image.canvasSize" , dx , dy ) ? ;
2026-09-30 09:37:18 -04:00
doc . size = Size ::new ( nw , nh );
2026-10-04 16:17:39 -04:00
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 );
2026-10-11 00:26:11 +07:00
// 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 )
2026-10-12 07:04:33 -04:00
&& let Some ( bg ) = doc . background_mut ()
2026-10-11 00:26:11 +07:00
{
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
2026-10-12 07:04:33 -04:00
&& 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 ] }))
}
2026-10-08 19:02:04 -07:00
/// 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 );
2026-10-08 00:02:12 +02:00
// 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" ) ? ,
) {
2026-10-08 12:05:25 -07:00
( Some ( x ), Some ( y ), Some ( w ), Some ( h )) if w > 0 && h > 0 => {
2026-10-08 19:02:04 -07:00
// 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" )));
}
2026-10-08 12:05:25 -07:00
// 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 ,
};
2026-10-10 19:46:31 +02:00
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 , "" ) ? ;
}
2026-10-09 19:27:27 +02:00
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`" )) ? ;
2026-10-10 19:46:31 +02:00
return rotated_crop ( s , rect , a , delete , & target );
2026-10-09 19:27:27 +02:00
}
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 )));
2026-10-11 22:46:09 +02:00
let extension = s . tools . background ;
2026-09-30 09:37:18 -04:00
s . edit ( "Crop" , | doc , _ | {
2026-10-11 22:46:09 +02:00
crop_doc ( doc , "image.crop" , r , delete , extension ) ? ;
2026-09-30 09:37:18 -04:00
doc . selection = None ;
2026-10-10 19:46:31 +02:00
target . apply ( doc );
2026-09-30 09:37:18 -04:00
Ok (())
}) ? ;
2026-10-10 19:46:31 +02:00
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 ),
2026-10-11 14:25:21 +02:00
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 ),
},
2026-10-10 19:46:31 +02:00
};
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
}
2026-10-09 19:27:27 +02: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.
2026-10-10 19:46:31 +02:00
fn rotated_crop ( s : & mut Session , r : Rect , angle : f64 , delete : bool , crop_target : & CropTarget ) -> Result < Value > {
2026-10-09 19:27:27 +02:00
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 )));
2026-10-11 22:46:09 +02:00
let extension = s . tools . background ;
2026-10-09 19:27:27 +02:00
s . edit ( "Crop" , | doc , _ | {
2026-10-11 22:46:09 +02:00
crop_doc ( doc , CMD , target , delete , extension ) ? ;
2026-10-09 19:27:27 +02:00
doc . selection = None ;
2026-10-10 19:46:31 +02:00
crop_target . apply ( doc );
2026-10-09 19:27:27 +02:00
Ok (())
})
}) ? ;
2026-10-10 19:46:31 +02:00
Ok ( crop_result ( s , json! ({ "x" : r . x0 , "y" : r . y0 , "width" : r . width (), "height" : r . height (), "angle" : angle })))
2026-10-09 19:27:27 +02:00
}
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 )));
2026-10-11 22:46:09 +02:00
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 > {
2026-10-05 07:37:12 +02:00
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 , _ | {
2026-10-12 14:45:48 +02:00
// 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 )));
}
2026-10-08 10:44:21 +07:00
convert_layers_depth ( & mut doc . layers , depth );
2026-10-12 14:45:48 +02:00
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 ))];
2026-10-11 00:12:15 +07:00
// 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
}
2026-10-11 00:12:15 +07: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" ],
2026-10-12 18:12:35 -05:00
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
),
2026-10-09 19:27:27 +02:00
spec! (
"image.crop" ,
"Crop" ,
[ "Image" ],
2026-10-10 19:46:31 +02:00
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)"## ,
2026-10-09 19:27:27 +02:00
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 ::* ;
2026-10-08 00:02:12 +02:00
#[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 ();
}
2026-10-08 12:05:25 -07:00
/// 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 () {
2026-10-11 14:25:21 +02:00
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 ]);
}
}
2026-10-07 16:33:04 -04:00
/// 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.
2026-10-09 10:03:08 +07:00
/// #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 ));
}
2026-10-07 16:33:04 -04:00
#[test]
fn image_size_keeps_canvas_edges_opaque () {
for depth in [ 8 , 16 , 32 ] {
2026-10-11 14:25:21 +02:00
for ( w , resample ) in [
( 60 , "bicubic" ),
( 41 , "lanczos" ),
( 15 , "bilinear" ),
( 77 , "preserveDetails" ),
( 21 , "bicubicSharper" ),
( 47 , "automatic" ),
( 9 , "bicubicAutomatic" ),
] {
2026-10-07 16:33:04 -04:00
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.0 f32 , 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 ();
2026-10-09 09:13:12 -07:00
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 );
2026-10-09 09:13:12 -07:00
// #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
}
2026-10-09 01:12:11 +05:30
/// #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 ];
2026-10-11 23:18:52 -06:00
assert! ( v . is_finite () && ! ( 0.0 1 ..= 0.99 ). contains ( & v ), "nearest produced an averaged value {v} at ({x},{y})" );
2026-10-09 01:12:11 +05:30
}
}
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 ));
}
2026-10-12 11:13:20 +03:00
#[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 ]);
}
2026-10-11 22:46:09 +02:00
#[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 ]);
}
2026-10-09 19:27:27 +02:00
/// 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 ));
}
}
2026-10-08 19:02:04 -07:00
#[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 ));
}
2026-10-10 19:46:31 +02:00
/// #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 ));
}
2026-10-12 14:45:48 +02:00
#[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 );
2026-10-05 07:37:12 +02:00
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" ));
}
}