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
+1 -1
View File
@@ -362,7 +362,7 @@ const MAX_EFFECT_MARGIN_PX: f64 = 2048.0;
/// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels.
pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) {
if depth > 256 || ctx.skip.contains(&id) {
if depth > crate::MAX_EXPORT_DEPTH || ctx.skip.contains(&id) {
return;
}
let Some(n) = ctx.doc.node(id) else { return };
+69
View File
@@ -0,0 +1,69 @@
//! Preflight for vector exports, including subtrees rendered into embedded images.
use figcraft_doc::{Document, NodeId};
/// Maximum descendant depth for SVG/PDF export; each selected root is at depth zero.
pub const MAX_EXPORT_DEPTH: usize = 256;
/// Exporting this visible subtree would silently omit content at the traversal limit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExportDepthError {
pub root: NodeId,
pub node: NodeId,
}
impl std::fmt::Display for ExportDepthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"cannot export layer {}: layer {} exceeds the maximum export depth of {MAX_EXPORT_DEPTH}; select an inner layer or reduce nesting",
self.root, self.node
)
}
}
impl std::error::Error for ExportDepthError {}
/// Refuse a visible descendant deeper than [`MAX_EXPORT_DEPTH`] before computing bounds or
/// drawing. Hidden subtrees are not exported. Masks, boolean operands and rasterised effects
/// still depend on their descendants, so they participate in the same preflight.
///
/// The walk uses one iterator per ancestor (bounded auxiliary memory), rather than recursion or
/// collecting a whole subtree. A cycle also reaches the depth limit and is refused.
pub fn check_export_depth(doc: &Document, roots: &[NodeId]) -> Result<(), ExportDepthError> {
for root in roots {
let mut stack = vec![(std::slice::from_ref(root).iter(), 0)];
while let Some((siblings, depth)) = stack.last_mut() {
let Some(&id) = siblings.next() else {
stack.pop();
continue;
};
let depth = *depth;
let Some(node) = doc.node(id) else { continue };
if !node.visible {
continue;
}
if depth > MAX_EXPORT_DEPTH {
return Err(ExportDepthError { root: *root, node: id });
}
if !node.children.is_empty() {
stack.push((node.children.iter(), depth + 1));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use figcraft_doc::NodeKind;
#[test]
fn export_depth_preflight_refuses_a_cyclic_subtree() {
let mut doc = Document::new("cycle");
let root = doc.create(doc.first_page().unwrap(), NodeKind::Group, "root", 0.0, 0.0, 1.0, 1.0).unwrap();
doc.get_mut(root).unwrap().children.push(root);
assert_eq!(check_export_depth(&doc, &[root]), Err(ExportDepthError { root, node: root }));
}
}
+2
View File
@@ -9,6 +9,7 @@ pub mod backdrop;
pub mod blur;
pub mod boolean;
pub mod draw;
mod export_depth;
pub mod fx;
pub mod glass;
pub mod grain;
@@ -21,6 +22,7 @@ pub mod retained;
pub mod shadow;
pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path};
pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth};
pub use retained::{Retained, Work};
pub use tiny_skia::{FillRule, Pixmap};