From 8b6bfe46ea07f49ebdfc323ad8a6cea2e7562996 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:25:18 +0700 Subject: [PATCH 1/2] feat(export): optionally trim transparent margins from layer files --- crates/engine/src/file_cmds.rs | 25 +++++++++++++++++- crates/engine/src/file_cmds/tests.rs | 38 ++++++++++++++++++++++++++++ crates/engine/src/image_cmds.rs | 2 +- crates/ui-egui/src/view_cmds.rs | 2 +- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index b84572cf46..563f98a99d 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1103,6 +1103,7 @@ fn layers_to_files(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string(); let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); + let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false); let save = SaveOpts::from_params(p, cmd)?; let d = s.active().ok_or(EngineError::NoDocument)?; let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name)); @@ -1119,6 +1120,28 @@ fn layers_to_files(s: &mut Session, p: &Value) -> Result { only.visible = true; only.clipped = false; one.layers = vec![only]; + if trim { + // Trim what this isolated layer actually renders, including its mask, + // vector geometry and layer effects; never alter the source document. + let rendered = photocraft_compose::flatten(&one); + let width = one.size.width as usize; + let mut bounds = Rect::EMPTY; + if width > 0 { + for (pixel, rgba) in rendered.px.iter().enumerate() { + if rgba[3] > 0.0 { + let x = (pixel % width) as i32; + let y = (pixel / width) as i32; + let at = Rect::new(x, y, x + 1, y + 1); + bounds = if bounds.is_empty() { at } else { bounds.union(&at) }; + } + } + } + // An entirely transparent layer has no content bounds. Keep a valid + // full-size file for it rather than trying to crop to zero pixels. + if !bounds.is_empty() && bounds != one.bounds() { + crate::image_cmds::crop_doc(&mut one, cmd, bounds, true)?; + } + } let path = join(&dir, &format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name))); save_doc(&one, &path, save)?; files.push(path); @@ -1460,7 +1483,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?,"tiffLayers":bool=false,"tgaBits":24|32?} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index b7a92ff927..8a457b6504 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -549,6 +549,44 @@ 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 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/image_cmds.rs b/crates/engine/src/image_cmds.rs index 34ad2d6a55..61ed323388 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -276,7 +276,7 @@ fn translate_doc(doc: &mut Document, cmd: &str, dx: i32, dy: i32) -> Result<()> } /// Crops the document to `r` (in current document coordinates). -fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool) -> Result<()> { +pub(crate) fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool) -> 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/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 435df87ec7..a8542c88aa 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1009,7 +1009,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Date: Mon, 12 Oct 2026 13:09:55 +0700 Subject: [PATCH 2/2] style(tests): apply rustfmt corrections to layer export coverage --- crates/engine/src/file_cmds/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 97ac56fa1e..3486bf92c7 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -570,7 +570,6 @@ 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"); @@ -608,7 +607,8 @@ fn streamed_layer_export_respects_trim() { let names = super::export_layers_with(&s, &json!({"trim": true, "format": "png"}), |name, bytes| { encoded.push((name.to_string(), bytes.to_vec())); Ok(()) - }).unwrap(); + }) + .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));