mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
The REST import's layer walk (also the .fig import's second stage) took about 4 KB of stack a level, a megabyte at the 256-level cap, and overflowed a 1 MB thread. It now runs on a work list in the same order, fitting groups once their children are in. The .fig mapping stays recursive at about 1.8 KB a level (455 KB at the cap). Both 256-deep imports now run on a 1 MB stack; the cap stays 256 everywhere.
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -18,6 +18,8 @@ mod auto_layout_sizing_tests;
|
||||
#[cfg(test)]
|
||||
mod connector_label_tests;
|
||||
#[cfg(test)]
|
||||
mod deep_import_tests;
|
||||
#[cfg(test)]
|
||||
mod expansion_cost_tests;
|
||||
mod export;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Files nested as deep as the importers go, on the browser's stack (#1101): a `.fig` file and a
|
||||
//! REST file 256 layers deep (and deeper, which the importers cut off) import on a 1 MB stack, the
|
||||
//! size wasm32 gets. The REST import's layer walk used about 4 KB of stack a level, a megabyte at
|
||||
//! 256; it now runs on a work list. The `.fig` mapping stays recursive at about 1.8 KB a level.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::kiwi::Kv;
|
||||
use super::map;
|
||||
|
||||
/// The stack wasm32 runs on.
|
||||
const WASM_STACK: usize = 1 << 20;
|
||||
|
||||
fn on_wasm_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
std::thread::Builder::new().stack_size(WASM_STACK).spawn(f).unwrap().join().unwrap()
|
||||
}
|
||||
|
||||
type Fields = Vec<(&'static str, Kv<'static>)>;
|
||||
|
||||
fn o(fields: Fields) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn g(id: u64) -> Kv<'static> {
|
||||
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
|
||||
}
|
||||
|
||||
fn node(id: u64, parent: Option<u64>, ty: &'static str) -> Kv<'static> {
|
||||
let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(format!("L{id}")))];
|
||||
if let Some(parent) = parent {
|
||||
fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("a".into()))])));
|
||||
}
|
||||
o(fields)
|
||||
}
|
||||
|
||||
/// A `.fig` message: a page holding `depth` frames, each inside the last.
|
||||
fn deep_fig(depth: u64) -> Kv<'static> {
|
||||
let mut nodes = vec![node(1, None, "DOCUMENT"), node(2, Some(1), "CANVAS")];
|
||||
nodes.extend((0..depth).map(|i| node(10 + i, Some(if i == 0 { 2 } else { 9 + i }), "FRAME")));
|
||||
o(vec![("nodeChanges", Kv::Array(nodes))])
|
||||
}
|
||||
|
||||
/// A REST file: a page holding `depth` frames, each inside the last.
|
||||
fn deep_rest(depth: usize) -> Value {
|
||||
let mut layer = json!({"id": format!("1:{depth}"), "type": "FRAME", "name": "Innermost"});
|
||||
for i in (0..depth - 1).rev() {
|
||||
layer = json!({"id": format!("1:{i}"), "type": "FRAME", "name": format!("L{i}"), "children": [layer]});
|
||||
}
|
||||
json!({"name": "Deep", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "name": "Page", "children": [layer]}]}})
|
||||
}
|
||||
|
||||
/// The `.fig` import of a file `depth` frames deep on the wasm stack: (layers, warnings).
|
||||
fn fig_import(depth: u64) -> (usize, Vec<String>) {
|
||||
on_wasm_stack(move || {
|
||||
let msg = deep_fig(depth);
|
||||
let (json, mut warnings, networks) = map::to_rest(&msg, "Deep", 2_000_000);
|
||||
let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, 2_000_000).unwrap();
|
||||
warnings.extend(more);
|
||||
(doc.node_count(), warnings)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fig_file_as_deep_as_the_import_goes_fits_the_wasm_stack() {
|
||||
let (layers, warnings) = fig_import(256);
|
||||
assert_eq!(layers, 257, "the page and every frame");
|
||||
assert!(!warnings.iter().any(|w| w.contains("deeper")), "{warnings:?}");
|
||||
// Deeper than that, the rest is cut off with a note (still on the small stack).
|
||||
let (layers, warnings) = fig_import(400);
|
||||
assert!(layers < 400, "{layers}");
|
||||
assert!(warnings.iter().any(|w| w.contains("nested deeper than 256 levels")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rest_file_as_deep_as_the_import_goes_fits_the_wasm_stack() {
|
||||
let (layers, warnings) = on_wasm_stack(|| {
|
||||
let v = deep_rest(256);
|
||||
let (doc, warnings) = crate::rest::import(&v, &|_: &str| None).unwrap();
|
||||
(doc.node_count(), warnings)
|
||||
});
|
||||
assert_eq!(layers, 257, "the page and every frame");
|
||||
assert!(!warnings.iter().any(|w| w.contains("deeper")), "{warnings:?}");
|
||||
let (layers, warnings) = on_wasm_stack(|| {
|
||||
let v = deep_rest(400);
|
||||
let (doc, warnings) = crate::rest::import(&v, &|_: &str| None).unwrap();
|
||||
(doc.node_count(), warnings)
|
||||
});
|
||||
assert!(layers < 400, "{layers}");
|
||||
assert!(warnings.iter().any(|w| w.contains("nested deeper than 256 levels")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_list_keeps_the_files_order_and_fits_groups_to_their_children() {
|
||||
// Siblings in order, depth first, and a group's bounds from its children once they are in.
|
||||
let v = json!({"name": "Order", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "name": "Page", "children": [
|
||||
{"id": "1:1", "type": "GROUP", "name": "G", "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1, "height": 1}, "children": [
|
||||
{"id": "1:2", "type": "RECTANGLE", "name": "A", "absoluteBoundingBox": {"x": 10, "y": 20, "width": 30, "height": 40}},
|
||||
{"id": "1:3", "type": "FRAME", "name": "B", "absoluteBoundingBox": {"x": 50, "y": 20, "width": 10, "height": 10}, "children": [
|
||||
{"id": "1:4", "type": "RECTANGLE", "name": "C", "absoluteBoundingBox": {"x": 52, "y": 22, "width": 2, "height": 2}}
|
||||
]}
|
||||
]},
|
||||
{"id": "1:5", "type": "RECTANGLE", "name": "D", "absoluteBoundingBox": {"x": 0, "y": 100, "width": 5, "height": 5}}
|
||||
]}]}});
|
||||
let (doc, _) = crate::rest::import(&v, &|_: &str| None).unwrap();
|
||||
let page = doc.first_page().unwrap();
|
||||
let name = |id| doc.node(id).unwrap().name.clone();
|
||||
let top: Vec<String> = doc.node(page).unwrap().children.iter().map(|c| name(*c)).collect();
|
||||
assert_eq!(top, ["G", "D"]);
|
||||
let g = doc.node(page).unwrap().children[0];
|
||||
let kids: Vec<String> = doc.node(g).unwrap().children.iter().map(|c| name(*c)).collect();
|
||||
assert_eq!(kids, ["A", "B"]);
|
||||
let b = doc.node(g).unwrap().children[1];
|
||||
assert_eq!(doc.node(b).unwrap().children.iter().map(|c| name(*c)).collect::<Vec<_>>(), ["C"]);
|
||||
let group = doc.node(g).unwrap();
|
||||
assert_eq!((group.width, group.height), (50.0, 40.0), "the group fits A and B");
|
||||
}
|
||||
@@ -397,6 +397,22 @@ impl Default for Space {
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer [`Ctx::build`] has yet to import, with where it goes.
|
||||
struct Layer<'a> {
|
||||
j: &'a Value,
|
||||
parent: NodeId,
|
||||
space: Space,
|
||||
parent_mode: Option<LayoutMode>,
|
||||
depth: usize,
|
||||
root_abs: bool,
|
||||
}
|
||||
|
||||
/// [`Ctx::build`]'s work list: layers to import, and groups to fit once their children are in.
|
||||
enum Build<'a> {
|
||||
Layer(Layer<'a>),
|
||||
Fit(NodeId),
|
||||
}
|
||||
|
||||
pub(crate) struct Ctx<'a> {
|
||||
doc: Document,
|
||||
images: ImageResolver<'a>,
|
||||
@@ -514,8 +530,26 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// Import layer `j` (and its subtree) under `parent`. `root_abs`: place it by its absolute
|
||||
/// bounds (roots of a nodes response, whose parent isn't in the file).
|
||||
/// bounds (roots of a nodes response, whose parent isn't in the file). Depth first in the
|
||||
/// file's order, on a work list rather than the call stack (#1101): a level took about 4 KB of
|
||||
/// stack, so the deepest files filled the browser's 1 MB.
|
||||
fn build(&mut self, j: &'a Value, parent: NodeId, space: Space, parent_mode: Option<LayoutMode>, depth: usize, root_abs: bool) {
|
||||
let mut work = vec![Build::Layer(Layer { j, parent, space, parent_mode, depth, root_abs })];
|
||||
while let Some(step) = work.pop() {
|
||||
match step {
|
||||
Build::Layer(layer) => self.build_layer(layer, &mut work),
|
||||
// A group or boolean fits its children once all of them are in.
|
||||
Build::Fit(id) => {
|
||||
let _ = self.doc.fit_to_children(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One layer of [`Self::build`]: its node, then its children pushed on `work` (and, when its
|
||||
/// bounds follow them, the fit after them).
|
||||
fn build_layer(&mut self, layer: Layer<'a>, work: &mut Vec<Build<'a>>) {
|
||||
let Layer { j, parent, space, parent_mode, depth, root_abs } = layer;
|
||||
if depth > MAX_DEPTH {
|
||||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return;
|
||||
@@ -570,12 +604,13 @@ impl<'a> Ctx<'a> {
|
||||
return;
|
||||
}
|
||||
let inner = Space { world, frame: if follows { space.frame } else { world } };
|
||||
for c in kids {
|
||||
self.build(c, id, inner, mode, depth + 1, false);
|
||||
}
|
||||
if follows {
|
||||
let _ = self.doc.fit_to_children(id);
|
||||
work.push(Build::Fit(id));
|
||||
}
|
||||
// Reversed, so the first child comes off the list first.
|
||||
work.extend(
|
||||
kids.iter().rev().map(|c| Build::Layer(Layer { j: c, parent: id, space: inner, parent_mode: mode, depth: depth + 1, root_abs: false })),
|
||||
);
|
||||
}
|
||||
|
||||
fn kind(&mut self, ty: &str, j: &'a Value) -> Option<NodeKind> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; 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; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; 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; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **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
|
||||
@@ -185,6 +185,7 @@ source paint coordinates.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1101: `.fig` and REST imports of files nested 256 levels deep (the cap) fit the browser's 1 MB stack: the REST layer walk no longer recurses. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1079: a `.figcraft` file unpacks at most 6 GiB in all when it opens (2 GiB in the browser), every area together, beyond each area's own cap; what passes it is left out with a note (images stay in the file, #1313). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1077: `.fig` import: instance expansion no longer walks variable nodes per instance or copies every outer override row into each nested instance; crafted files that took 14 s and 40 s import in 21 ms and 98 ms, normal files map as before. Numbers unchanged. |
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user