mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 22:36:51 -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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user