Files
figcraft/crates/render/src/export_depth.rs
T
YTwsyandbflatastic 33dcb1aef6 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>
2026-10-14 05:23:28 +07:00

70 lines
2.5 KiB
Rust

//! 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 }));
}
}