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:
parent
e8d7dc24f2
commit
33dcb1aef6
|
|
@ -1667,6 +1667,7 @@ dependencies = [
|
|||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"thiserror 2.0.21",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
|
||||
fn main() {
|
||||
main_thread_stack();
|
||||
}
|
||||
|
||||
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
|
||||
/// to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
fn main_thread_stack() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
|
|
@ -102,8 +102,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
|||
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
|
||||
}
|
||||
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
|
||||
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.map_err(|e| CliError::Failed(e.to_string()))?
|
||||
.into_bytes(),
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
|
|
@ -379,4 +379,31 @@ mod tests {
|
|||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_preserve_conversion_destinations() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..257 {
|
||||
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
|
||||
panic!("depth overflow must be an operation failure");
|
||||
};
|
||||
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for depth in 1..257 {
|
||||
let name = if depth == 1 { "inner-root" } else { "group" };
|
||||
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let input = dir.join("deep.figcraft");
|
||||
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
for verb in ["convert", "export"] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
command.arg(verb).arg(&input);
|
||||
if verb == "export" {
|
||||
command.arg("-o");
|
||||
}
|
||||
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
let inner = dir.join(format!("inner.{ext}"));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
.arg("export")
|
||||
.arg(&input)
|
||||
.arg("-o")
|
||||
.arg(&inner)
|
||||
.args(["--node", "inner-root", "--no-system-fonts"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let bytes = std::fs::read(inner).unwrap();
|
||||
if ext == "svg" {
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
|
||||
} else {
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere).
|
||||
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
|
||||
//! (no-op elsewhere).
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
|
|
@ -14,6 +15,10 @@ fn main() {
|
|||
if let Err(e) = res.compile() {
|
||||
println!("cargo::warning=winresource: {e}");
|
||||
}
|
||||
// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers
|
||||
// nest up to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();
|
||||
println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
use super::*;
|
||||
use crate::tests::inflated;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
fn nested(depth: usize) -> (Document, NodeId, NodeId) {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, NodeKind::Frame, "root", 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();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "deep-leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
(doc, root, leaf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_boundary_keeps_the_leaf() {
|
||||
for depth in [255, 256] {
|
||||
let (doc, root, _) = nested(depth);
|
||||
let pdf = export_pdf(&doc, &[root], &PdfOptions::default()).unwrap();
|
||||
assert!(inflated(&pdf).contains("1 0 0 rg"), "the red leaf at depth {depth} was omitted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_overflow_is_refused_by_every_public_entry_point() {
|
||||
let (doc, root, leaf) = nested(257);
|
||||
let opts = PdfOptions::default();
|
||||
let error = || PdfError::Depth(figcraft_render::ExportDepthError { root, node: leaf });
|
||||
assert_eq!(export_pdf(&doc, &[root], &opts), Err(error()));
|
||||
assert_eq!(export_nodes_pdf(&doc, &[root]), Err(error()));
|
||||
assert_eq!(export_groups(&doc, &[vec![root]], &opts), Err(error()));
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
assert!(inflated(&export_pdf(&doc, &[inner], &opts).unwrap()).contains("1 0 0 rg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_checks_every_page_and_explicit_area() {
|
||||
let (mut doc, root, leaf) = nested(257);
|
||||
let shallow = doc.create(doc.first_page().unwrap(), NodeKind::Rectangle, "shallow", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let opts = PdfOptions { area: Some(Rect::new(0.0, 0.0, 32.0, 32.0)), ..Default::default() };
|
||||
assert!(export_pdf(&doc, &[shallow], &opts).is_ok());
|
||||
assert_eq!(
|
||||
export_groups(&doc, &[vec![shallow], vec![root]], &opts),
|
||||
Err(PdfError::Depth(figcraft_render::ExportDepthError { root, node: leaf }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_ignores_hidden_subtrees_and_checks_masks_and_raster_fallbacks() {
|
||||
let (mut doc, root, _) = nested(257);
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
doc.get_mut(inner).unwrap().visible = false;
|
||||
assert!(export_pdf(&doc, &[root], &PdfOptions::default()).is_ok());
|
||||
doc.get_mut(inner).unwrap().visible = true;
|
||||
doc.get_mut(inner).unwrap().is_mask = true;
|
||||
assert!(matches!(export_pdf(&doc, &[root], &PdfOptions::default()), Err(PdfError::Depth(_))));
|
||||
doc.get_mut(inner).unwrap().is_mask = false;
|
||||
doc.get_mut(root).unwrap().effects =
|
||||
vec![figcraft_doc::Effect { kind: figcraft_doc::EffectKind::LayerBlur(figcraft_doc::Blur::uniform(1.0)), visible: true }];
|
||||
assert!(matches!(export_pdf(&doc, &[root], &PdfOptions::default()), Err(PdfError::Depth(_))));
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ use crate::PdfOptions;
|
|||
use crate::print::Ink;
|
||||
|
||||
/// Deepest node nesting drawn (the renderer's limit).
|
||||
pub(crate) const MAX_DEPTH: usize = 256;
|
||||
pub(crate) const MAX_DEPTH: usize = figcraft_render::MAX_EXPORT_DEPTH;
|
||||
/// Largest side of any raster made for the PDF, in pixels.
|
||||
pub(crate) const MAX_RASTER_SIDE: f64 = 4096.0;
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ impl Default for PdfOptions {
|
|||
pub enum PdfError {
|
||||
#[error("nothing to export")]
|
||||
Empty,
|
||||
#[error(transparent)]
|
||||
Depth(#[from] figcraft_render::ExportDepthError),
|
||||
#[error("no such node {0}")]
|
||||
NoNode(NodeId),
|
||||
#[error("node {0} has no exportable area")]
|
||||
|
|
@ -93,6 +95,10 @@ pub fn export_nodes_pdf(doc: &Document, ids: &[NodeId]) -> Result<Vec<u8>, PdfEr
|
|||
/// One page per group of ids. For print, a page of one frame is trimmed to the frame's box (its
|
||||
/// shadows and outside strokes go into the bleed).
|
||||
pub fn export_groups(doc: &Document, groups: &[Vec<NodeId>], opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
|
||||
// All pages are checked before bounds, raster fallbacks or PDF writing start.
|
||||
for group in groups {
|
||||
figcraft_render::check_export_depth(doc, group)?;
|
||||
}
|
||||
let mut pages: Vec<(Vec<NodeId>, Rect)> = Vec::new();
|
||||
for g in groups.iter().filter(|g| !g.is_empty()) {
|
||||
let mut bounds: Option<Rect> = None;
|
||||
|
|
@ -208,6 +214,8 @@ fn write(doc: &Document, pages: &[(Vec<NodeId>, Rect)], opts: &PdfOptions) -> Re
|
|||
#[cfg(test)]
|
||||
mod backdrop_tests;
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
mod effect_order_tests;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
|
|
|
|||
|
|
@ -240,14 +240,15 @@ fn hostile_inputs_error_or_skip() {
|
|||
if let NodeKind::Text(td) = &mut d.get_mut(t).unwrap().kind {
|
||||
td.style.font_size = f64::NAN;
|
||||
}
|
||||
// Deep nesting beyond the depth limit.
|
||||
let pdf = export_pdf(&d, &[f], &PdfOptions { raster_scale: f64::NAN, ..Default::default() }).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
// Deep nesting beyond the depth limit must refuse the export, not return partial artwork.
|
||||
let mut parent = f;
|
||||
for _ in 0..400 {
|
||||
parent = d.create(parent, NodeKind::Group, "g", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
}
|
||||
d.create(parent, NodeKind::Rectangle, "deep", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
let pdf = export_pdf(&d, &[f], &PdfOptions { raster_scale: f64::NAN, ..Default::default() }).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
assert!(matches!(export_pdf(&d, &[f], &PdfOptions::default()), Err(PdfError::Depth(_))));
|
||||
// A zero-size node still exports (as a tiny page); a NaN-sized frame errors or exports.
|
||||
let z = d.create(p, NodeKind::Rectangle, "Zero", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
assert!(export_pdf(&d, &[z], &PdfOptions::default()).is_ok());
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ figcraft-text = { workspace = true }
|
|||
figcraft-pathops = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
|
||||
fn nested(depth: usize) -> (Document, NodeId, NodeId) {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, NodeKind::Frame, "root", 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();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "deep-leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
(doc, root, leaf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_boundary_keeps_the_leaf() {
|
||||
let opts = SvgOptions { include_ids: true, ..Default::default() };
|
||||
for depth in [255, 256] {
|
||||
let (doc, root, _) = nested(depth);
|
||||
let svg = export_svg(&doc, &[root], &opts).unwrap();
|
||||
assert!(svg.contains("id=\"deep-leaf\""), "the leaf at depth {depth} was omitted");
|
||||
assert!(svg.contains("#FF0000"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_overflow_is_refused_and_an_inner_root_still_exports() {
|
||||
let (doc, root, leaf) = nested(257);
|
||||
let opts = SvgOptions { include_ids: true, ..Default::default() };
|
||||
assert!(export_svg(&doc, &[root], &opts).is_none(), "an incomplete SVG must not report success");
|
||||
assert_eq!(try_export_svg(&doc, &[root], &opts), Err(SvgError::Depth(ExportDepthError { root, node: leaf })));
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
assert!(export_svg(&doc, &[inner], &opts).unwrap().contains("id=\"deep-leaf\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_checks_all_roots_and_explicit_bounds() {
|
||||
let (mut doc, root, leaf) = nested(257);
|
||||
let shallow = doc.create(doc.first_page().unwrap(), NodeKind::Rectangle, "shallow", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let opts = SvgOptions { bounds: Some(Rect::new(0.0, 0.0, 32.0, 32.0)), ..Default::default() };
|
||||
assert!(try_export_svg(&doc, &[shallow], &opts).is_ok());
|
||||
assert_eq!(try_export_svg(&doc, &[shallow, root], &opts), Err(SvgError::Depth(ExportDepthError { root, node: leaf })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_ignores_hidden_subtrees_and_checks_masks_and_raster_fallbacks() {
|
||||
let (mut doc, root, _) = nested(257);
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
doc.get_mut(inner).unwrap().visible = false;
|
||||
assert!(try_export_svg(&doc, &[root], &SvgOptions::default()).is_ok());
|
||||
doc.get_mut(inner).unwrap().visible = true;
|
||||
doc.get_mut(inner).unwrap().is_mask = true;
|
||||
assert!(matches!(try_export_svg(&doc, &[root], &SvgOptions::default()), Err(SvgError::Depth(_))));
|
||||
doc.get_mut(inner).unwrap().is_mask = false;
|
||||
doc.get_mut(root).unwrap().effects = vec![figcraft_doc::Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(1.0)), visible: true }];
|
||||
assert!(matches!(try_export_svg(&doc, &[root], &SvgOptions::default()), Err(SvgError::Depth(_))));
|
||||
}
|
||||
|
|
@ -27,7 +27,15 @@ use figcraft_color::{Color, GradientKind, Paint, PaintKind};
|
|||
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, SPREAD_MITER_LIMIT, Shadow};
|
||||
use figcraft_geom::{Affine, BezPath, Rect};
|
||||
use figcraft_render::shadow::Outline;
|
||||
use figcraft_render::{Alone, Pixmap, View, backdrop};
|
||||
use figcraft_render::{Alone, ExportDepthError, MAX_EXPORT_DEPTH, Pixmap, View, backdrop, check_export_depth};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum SvgError {
|
||||
#[error("nothing to export")]
|
||||
Empty,
|
||||
#[error(transparent)]
|
||||
Depth(#[from] ExportDepthError),
|
||||
}
|
||||
|
||||
/// Device pixels per page unit of embedded renders (blurs, glass, texture).
|
||||
const RASTER_SCALE: f64 = 2.0;
|
||||
|
|
@ -362,7 +370,7 @@ impl W<'_> {
|
|||
}
|
||||
|
||||
fn node(&mut self, id: NodeId, xf: Affine, depth: usize) {
|
||||
if depth > 256 {
|
||||
if depth > MAX_EXPORT_DEPTH {
|
||||
return;
|
||||
}
|
||||
let Some(n) = self.doc.node(id) else { return };
|
||||
|
|
@ -920,10 +928,18 @@ fn sniff_mime(b: &[u8]) -> &'static str {
|
|||
}
|
||||
|
||||
/// Export `ids` (in z-order) as one SVG document covering their export bounds.
|
||||
/// Returns `None` for an empty or over-depth export. Use [`try_export_svg`] for the reason.
|
||||
pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<String> {
|
||||
try_export_svg(doc, ids, opts).ok()
|
||||
}
|
||||
|
||||
/// Like [`export_svg`], with an actionable error when the visible subtree is too deep. No partial
|
||||
/// SVG is returned; the depth limit is relative to each exported root, not the document's page.
|
||||
pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Result<String, SvgError> {
|
||||
check_export_depth(doc, ids)?;
|
||||
let bounds: Rect = match opts.bounds {
|
||||
Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,
|
||||
_ => ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?,
|
||||
_ => ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b)).ok_or(SvgError::Empty)?,
|
||||
};
|
||||
let origin = Affine::translate((-bounds.x0, -bounds.y0));
|
||||
let mut w = W {
|
||||
|
|
@ -944,7 +960,7 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S
|
|||
}
|
||||
let (bw, bh) = (bounds.width().max(0.0), bounds.height().max(0.0));
|
||||
let defs = if w.defs.is_empty() { String::new() } else { format!("<defs>{}</defs>", w.defs) };
|
||||
Some(format!(
|
||||
Ok(format!(
|
||||
"<svg width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">{}{}</svg>\n",
|
||||
num(bw),
|
||||
num(bh),
|
||||
|
|
@ -955,6 +971,8 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S
|
|||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ page 1 "Screens": 380 nodes, 4 top-level
|
|||
data…) is printed as `note:` lines.
|
||||
- The format comes from `--format`, else `<out>`'s extension, else png. `--scale` (default 1, max 64)
|
||||
applies to raster formats.
|
||||
- SVG/PDF exports with visible descendants more than 256 levels below an exported root fail with
|
||||
an error instead of writing an incomplete file. Select an inner layer with `--node` or reduce
|
||||
nesting. An existing destination file is kept on this failure.
|
||||
- A `.pdf` `<out>` without `--node` holds the page's top-level frames, one PDF page each in reading
|
||||
order, each the frame's size in points (a page without frames goes on one page). Shapes, text
|
||||
and gradients stay vector, text is real (selectable, with subset fonts) and its links clickable;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
|
|
@ -40,12 +40,19 @@ and prototype interactions are still unchecked there.
|
|||
|
||||
## Export
|
||||
|
||||
SVG and PDF refuse an export whose visible layer tree exceeds 256 descendant levels from any
|
||||
exported root (the root is depth zero). The error identifies the root and offending layer and
|
||||
suggests exporting an inner layer or reducing nesting. Hidden subtrees do not count; masks and
|
||||
subtrees rendered into embedded images do. Refusal returns no partial SVG/PDF and leaves the
|
||||
existing destination file intact. Selecting an inner root starts a new depth count. This is an
|
||||
export limit, not a native document nesting limit.
|
||||
|
||||
| Format | Figma | FigCraft | Notes | Hours |
|
||||
|---|---|---|---|---|
|
||||
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — |
|
||||
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
|
||||
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
|
||||
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports | CMYK output intent 2–4 |
|
||||
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
|
||||
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
|
||||
| WebP | — | yes (ours) | | — |
|
||||
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
|
||||
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — |
|
||||
|
|
@ -155,6 +162,7 @@ each.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
|
||||
| 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder that mirrors the decoder and the schema of the fields written; what's left out is a warning. The import gained ellipse arcs, boolean operation types and component set descriptions, and reads durations as whole milliseconds. Verified only by round trips through our own importer. Summary ~59% → ~60%; `.fig` write 15–30 h → 5–15 h (checks against real files) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue