diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index efdd3ad8bf..3925bf8321 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -341,7 +341,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/cmd/file_cmds/export.rs b/crates/engine/src/cmd/file_cmds/export.rs index 80213dba16..72e625ac0c 100644 --- a/crates/engine/src/cmd/file_cmds/export.rs +++ b/crates/engine/src/cmd/file_cmds/export.rs @@ -44,6 +44,7 @@ pub struct LayerExportReport { pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); + let trim=p.get("trim").and_then(Value::as_bool).unwrap_or(false); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); // Callers outside `Session::execute` (headless workspace exports) carry the session's gate // into `encode`, so a PSD embeds no file a linked smart object names. @@ -72,6 +73,22 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(& only.visible = true; only.clipped = false; one.layers = vec![only]; + if trim { + let mut bounds=Rect::EMPTY; + let _=photocraft_compose::render_bands(&one,one.bounds(),0,|band|->std::result::Result<(),()> { + let width=band.rect.width() as usize; + for (i,rgba) in band.px.iter().enumerate() { + if rgba[3]>0.0 && width>0 { + let x=band.rect.x0+(i%width) as i32; + let y=band.rect.y0+(i/width) as i32; + bounds=bounds.union(&Rect::new(x,y,x+1,y+1)); + } + } + Ok(()) + }); + if !bounds.is_empty() && bounds!=one.bounds(){crate::image_cmds::crop_doc(&mut one,cmd,bounds,true,[0.0;4])?;} + } + let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)); let (bytes, _warnings) = encode(&one, &name, save)?; write(&name, &bytes)?; diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 7eb770d844..d93c15fd82 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1159,6 +1159,61 @@ fn layers_to_files_writes_each_visible_layer() { assert_eq!(r["files"].as_array().unwrap().len(), 3); } +#[test] +fn layers_to_files_can_trim_transparent_margins_without_changing_the_document() { + let dir = tmp("layers-trim"); + for depth in [8, 16, 32] { + let mut s = session(12, 11, depth); + s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap(); + s.execute("select.rect", json!({"x": 3, "y": 2, "width": 4, "height": 5})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + + let before = doc(&s).clone(); + let regular = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": format!("full-{depth}"), "format": "png"})).unwrap(); + let full = std::fs::read(regular["files"][0].as_str().unwrap()).unwrap(); + let full = photocraft_io::import("full.png", &full).unwrap().document; + assert_eq!((full.size.width, full.size.height), (12, 11), "default is unchanged"); + + let trimmed = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": format!("trim-{depth}"), "format": "png", "trim": true})).unwrap(); + let image = std::fs::read(trimmed["files"][0].as_str().unwrap()).unwrap(); + let cropped = photocraft_io::import("trim.png", &image).unwrap().document; + assert_eq!((cropped.size.width, cropped.size.height), (4, 5), "depth {depth}"); + let px = photocraft_compose::render(&cropped, Rect::new(0, 0, 1, 1)).px[0]; + assert!(px[0] > 0.95 && px[1] < 0.05 && px[2] < 0.05, "depth {depth}: {px:?}"); + assert_eq!(*doc(&s), before, "export must not crop the original document"); + } +} + +#[test] +fn streamed_layer_export_respects_trim() { + let mut s = session(12, 11, 8); + s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap(); + s.execute("select.rect", json!({"x": 3, "y": 2, "width": 4, "height": 5})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let mut encoded = Vec::new(); + let names = super::export_layers_with(&s, &json!({"trim": true, "format": "png"}), |name, bytes| { + encoded.push((name.to_string(), bytes.to_vec())); + Ok(()) + }) + .unwrap(); + assert_eq!(names.len(), encoded.len()); + let cropped = photocraft_io::import(&encoded[0].0, &encoded[0].1).unwrap().document; + assert_eq!((cropped.size.width, cropped.size.height), (4, 5)); +} + +#[test] +fn trimming_an_empty_layer_keeps_a_valid_nonzero_canvas() { + let dir = tmp("layers-trim-empty"); + let mut s = session(6, 7, 8); + s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap(); + let result = s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "png", "trim": true})).unwrap(); + let bytes = std::fs::read(result["files"][0].as_str().unwrap()).unwrap(); + let back = photocraft_io::import("empty.png", &bytes).unwrap().document; + assert_eq!((back.size.width, back.size.height), (6, 7)); +} + #[test] fn load_files_into_stack_and_image_processor_and_batch() { let dir = tmp("batch"); diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 21a0c3cd08..c8c3e0b7d2 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -411,7 +411,7 @@ fn delete_cropped_pixels(layers: &mut [Layer], r: Rect) { /// 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<()> { +pub(crate) 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))); diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 7c0c9d74aa..fe6dee7e2e 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -416,7 +416,7 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; dialog( app, - json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true}), + json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true, "trim": false}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}), ) }