mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
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:
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¶ms).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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user