diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b0..89a92876 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -765,12 +765,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: } /// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own -/// area), or their boxes with "include bounding box" and for slices. +/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, +/// so they take no room next to visible ones (a hidden-only export keeps its own area). pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { + let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); ids.iter() .filter_map(|i| { - let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) } + let node = doc.node(*i); + let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + if setting.include_bounding_box || slice { + doc.world_bounds(*i) + } else if any_visible && node.is_some_and(|n| !n.visible) { + None + } else { + figcraft_render::export_bounds(doc, *i) + } }) .reduce(|a, b| a.union(b)) } diff --git a/crates/mcp/tests/export_hidden_roots.rs b/crates/mcp/tests/export_hidden_roots.rs new file mode 100644 index 00000000..ded6f731 --- /dev/null +++ b/crates/mcp/tests/export_hidden_roots.rs @@ -0,0 +1,47 @@ +//! Hidden top-level layers in an export take no room in the image (issue #447). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use common::{Client, png_dims}; +use serde_json::{Value, json}; + +fn dimensions(result: Value) -> (u32, u32) { + assert_eq!(result["isError"], false, "{result}"); + let image = result["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap(); + png_dims(image["data"].as_str().unwrap()) +} + +#[test] +fn hidden_root_does_not_expand_export() { + let mut client = Client::new(); + let visible = client.create(json!({ + "type":"frame", "x":0, "y":0, "width":100, "height":100, + "props":{"fill":"#FF0000"} + })); + let hidden = client.create(json!({ + "type":"frame", "x":900, "y":0, "width":300, "height":300, + "props":{"visible":false} + })); + assert_eq!(client.node(&hidden)["visible"], false); + + let control = dimensions(client.call("export", json!({"ids":[visible], "format":"png", "size":"200w"}))); + assert_eq!(control, (200, 200)); + let mixed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "size":"200w"}))); + assert_eq!(mixed, control, "hidden roots must not enlarge the painted area"); + let natural = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png"}))); + assert_eq!(natural, (100, 100)); + + client.ok("select", json!({"ids":[visible, hidden]})); + let selected = dimensions(client.call("export", json!({"format":"png", "size":"200w"}))); + assert_eq!(selected, (200, 200)); + client.ok("select", json!({"ids":[]})); + let page = dimensions(client.call("export", json!({"format":"png"}))); + assert_eq!(page, (100, 100)); + + // Controls: the bounding-box option and a hidden-only export keep their areas. + let boxed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "includeBoundingBox":true}))); + assert_eq!(boxed, (1200, 300)); + let alone = dimensions(client.call("export", json!({"ids":[hidden], "format":"png"}))); + assert_eq!(alone, (300, 300)); +}