Merge approved PhotoCraft PR #3519: feat(export): optionally trim transparent margins from layer files

Port behavior and regression coverage into current modules; preserve current upstream behavior.
Port transparent-margin trim into current authorized streaming export report, retaining skipped-layer reasons and stable numbering. Measure alpha in bounded render bands, including masks/effects; keep empty-layer canvas and source document immutable. Preserve original regressions.
This commit is contained in:
FredApps
2026-10-29 06:15:09 +01:00
5 changed files with 75 additions and 3 deletions
+1 -1
View File
@@ -341,7 +341,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+17
View File
@@ -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<LayerExportReport> {
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)?;
+55
View File
@@ -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");
+1 -1
View File
@@ -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)));
@@ -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"]}),
)
}