Refuse incomplete SVG/PDF exports beyond the depth limit (#218)

SVG and PDF exports check the visible subtree's depth before bounds or drawing and refuse, with an actionable message, a document nested past 256 levels instead of silently leaving the deep layers out; nothing partial is written, and an inner layer can still be exported. The CLI and MCP report the same error. Fixes #208.

Also: on Windows, figcraft and figcraft-cli link with an 8 MB main-thread stack (Windows gives 1 MB where Linux and macOS give 8), so an export at the depth limit no longer overflows it.

Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>
This commit is contained in:
YTwsy
2026-10-14 05:23:28 +07:00
committed by GitHub
co-authored by bflatastic
parent e8d7dc24f2
commit 33dcb1aef6
20 changed files with 441 additions and 17 deletions
+13 -2
View File
@@ -782,8 +782,11 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
if ids.is_empty() {
return Err(nothing());
}
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
let drawn = drawn_layers(doc, ids, setting);
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) {
figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?;
}
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
// The layers' own area unless an option or a slice says otherwise.
let own_area = setting.contents_only
&& !setting.include_bounding_box
@@ -797,7 +800,10 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
simplify_stroke: setting.svg_simplify_stroke,
bounds: Some(area),
};
figcraft_svg::export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).ok_or_else(nothing)
figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).map_err(|e| match e {
figcraft_svg::SvgError::Empty => nothing(),
other => EngineError::Other(other.to_string()),
})
}
ExportFormat::Pdf => {
let doc = &*doc.in_srgb();
@@ -877,6 +883,11 @@ pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
/// The scale that gives `ids` (their export area together) `setting`'s export size.
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
// Size-based exports ask for bounds before encoding. Let encoding report the depth error
// without entering a recursive bounds walk here; SVG/PDF do not use this raster scale.
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) && figcraft_render::check_export_depth(doc, ids).is_err() {
return 1.0;
}
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
}
+71
View File
@@ -0,0 +1,71 @@
use crate::Session;
use crate::cmd::file::{export_bytes, export_bytes_with};
use figcraft_doc::{ExportFormat, ExportSetting, NodeId, NodeKind};
use serde_json::json;
fn nested(depth: usize) -> (Session, NodeId) {
let mut s = Session::new();
let doc = s.doc_mut().unwrap();
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "deep", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for _ in 1..depth {
parent = doc.create(parent, NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
}
doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
(s, root)
}
#[test]
fn export_depth_errors_reach_commands_and_preserve_existing_files() {
let (mut s, root) = nested(257);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-export-depth-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
let path = dir.join(format!("deep.{}", format.ext()));
std::fs::write(&path, b"previous export").unwrap();
s.doc_mut().unwrap().get_mut(root).unwrap().exports = vec![ExportSetting { format, ..Default::default() }];
let mut commands = vec![
("file.export", json!({"ids": [root], "format": format.ext(), "size": "512w", "path": path})),
("file.exportLayers", json!({"ids": [root], "dir": dir})),
];
if format == ExportFormat::Pdf {
commands.push(("file.exportFramesPdf", json!({"path": path})));
}
for (command, params) in commands {
let error = s.execute(command, &params).unwrap_err().to_string();
assert!(error.contains("maximum export depth of 256"), "{command}: {error}");
assert!(error.contains("select an inner layer"), "{command}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no staging or partial export files");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn export_depth_is_relative_to_drawn_roots_including_slice_contents() {
let (mut s, root) = nested(257);
let doc = s.doc_mut().unwrap();
let page = doc.first_page().unwrap();
let shallow = doc.create(page, NodeKind::Rectangle, "shallow", 0.0, 0.0, 8.0, 8.0).unwrap();
let slice = doc.create(page, NodeKind::Slice, "slice", 0.0, 0.0, 32.0, 32.0).unwrap();
let inner = doc.node(root).unwrap().children[0];
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
assert!(export_bytes(doc, &[shallow], format, 1.0).is_ok());
assert!(export_bytes(doc, &[inner], format, 1.0).is_ok());
assert!(export_bytes(doc, &[slice], format, 1.0).unwrap_err().to_string().contains("maximum export depth"));
let setting = ExportSetting { format, contents_only: false, ..Default::default() };
assert!(export_bytes_with(doc, &[shallow], &setting, 1.0).unwrap_err().to_string().contains("maximum export depth"));
}
}
#[test]
fn export_depth_is_checked_before_recursive_bounds() {
let (mut s, root) = nested(2048);
// Unclipped frames require recursive descendant bounds unless preflight refuses them first.
s.doc_mut().unwrap().get_mut(root).unwrap().clip = false;
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
assert!(export_bytes(s.doc().unwrap(), &[root], format, 1.0).unwrap_err().to_string().contains("maximum export depth"));
}
}
+2
View File
@@ -1395,6 +1395,8 @@ mod effects_tests;
#[cfg(test)]
mod export_behaviour_tests;
#[cfg(test)]
mod export_depth_tests;
#[cfg(test)]
mod extended_chain_tests;
#[cfg(test)]
mod fig_export_assets_tests;