* Add craft-figma-rest: a typed model of the Figma REST file JSON The neutral model for design files: the .fig and .sketch readers map onto it and an application's importer takes it. Typed records for the document tree, paints, effects, text styles, layout grids, constraints, export settings and the component and style metadata; every record keeps the fields it has no name for, so a file read and written loses nothing. The vectorNetwork extension is typed and documented with the others. The layer tree is converted level by level in both directions, and the depth and node limits are checked on the JSON before any typed value is built. * craft-figma-rest: Warnings and style_name for the readers that build this shape Moved from FigCraft's REST importer and .fig reader (crates/format/src/rest.rs Warnings, fig.rs style_name), which the .fig and Sketch mappings share. * craft-fig: map the message onto the REST file JSON FigCraft's .fig mapping (crates/format/src/fig/map and fig/text_path.rs at e8b53e33) moved onto craft-figma-rest: rest::to_rest turns a decoded message into the REST API file JSON, with the vector networks layers name by vectorNetworkRef handed over beside it. Connector routing and the text path measure check are the caller's geometry (rest::Geometry; rest::Straight when it has none). The plugin data key under which a writer keeps an outlined stroke is an option. The geometry blob is read with craft_fig::path instead of a second decoder. * craft-fig: an empty overriddenSymbolID is no swap The empty GUID (4294967295:4294967295) in overriddenSymbolID means "no swap". The mapping took it as the main, found no node and made the instance an empty frame; it now keeps the main its symbolData names, in the instance mapping and in the list of mains a file's instances use. Ported from storytold/figcraft#2208. * craft-fig: a swapped nested instance takes its new main's look A nested instance swapped to another main stores the previous main's look on its own node, so the mapping drew that. Each group of the look (fills, strokes, stroke settings, effects, corners) whose stored values are a copy of the previous main's now takes the new main's; a group the node or a row above it changes is kept, and a style link is decided by the topmost layer that names it. Ported from storytold/figcraft#2204 (the fig/map part; the warning's locale entry stays in the app). * craft-fig: a vector point without a corner radius of its own takes the layer's A point whose style sets no cornerRadius, or that has no style, takes the layer's cornerRadius, as the file draws it; a point's own value (an explicit 0 included) still wins. The layer's radius is part of the network cache key, so an instance overriding the radius doesn't share its main's network. Ported from storytold/figcraft#2115 (the fig/map part; its export change stays in the app). * craft-fig: map deep files within a 1 MiB stack The mapping recursed once per level of layer nesting in a frame of about 2 KB (an unoptimised build): 300 nested frames, or a chain of instances of mains holding each other, overflowed a 1 MiB stack before the 256 level limit could stop them, a crash on hostile input. The work on one layer now happens in layer_head and layer_tail, which return before and after its children are mapped, and what they hand over is boxed, so the recursive frame is about 800 bytes. An unoptimised build now maps past the limit on 512 KiB; the limit stays 256. The test at the limit runs on a 1 MiB thread, for frames and for instances, and overflowed before this change. --------- Co-authored-by: Trevor Brown <trevorbrown.dev@gmail.com> Co-authored-by: Giovanni Almeida <gioalmeida07@gmail.com>
craft-fig
A clean-room reader and writer for the .fig container: the local copies Figma Design saves
(.fig) and FigJam whiteboards (.jam, the same archive with another canvas header). No unsafe.
This crate is the wire layer. It opens the archive, decompresses the canvas, hands the schema and
the message to craft-kiwi, finds images, and reads and writes the two blob
encodings (vector networks and outline geometry). What the decoded node changes mean, and how
they map onto an application's document, stays with the caller: the message is a kiwi::Kv tree
read by field name, in the file's own terms.
Provenance
Written by FigCraft's contributors, from public format knowledge only: public descriptions of the
container, the kiwi format's published specification, and black-box observation of files made
from synthetic documents. Nothing inside the incumbent's application bundle, web bundle or
network traffic was read, disassembled or copied, and no GPL, LGPL or AGPL code was consulted.
Written files were checked by opening them in other software. Moved here from
figcraft/crates/format/src/fig.rs, fig/network.rs, fig/export.rs (the container half) and
fig/map/layer.rs (the geometry blob).
The format
- A
.figfile is a ZIP archive:canvas.fig(the document),meta.json(file_name,client_meta.background_color,client_meta.thumbnail_size),thumbnail.pngandimages/<hash>(the hash in hex, no extension). A barecanvas.figis a file too: the clipboard carries one. canvas.figis an 8-byte magic (fig-kiwi;fig-jam.for a whiteboard), a little-endianu32version, then length-prefixed chunks, each compressed with raw deflate, zlib or zstd. The first chunk is a kiwi binary schema, the second a message encoded with it.- The message's
nodeChangeslist the layers (each with its id, its parent's id and its position among the siblings). Itsblobshold vector networks (vectorData.vectorNetworkBlob), outline geometry (fillGeometry[].commandsBlob) and, in files written by plugins and other tools, the bytes of images (image.dataBlob).
Reading
let archive = craft_fig::Archive::open(&bytes)?; // a zip or a bare canvas
let packed = archive.canvas.len();
let (schema, message) = craft_fig::decode_canvas(&archive.canvas)?;
let root = schema.find("Message").ok_or("no Message definition")?;
let msg = schema.decode_message_within(root, &message, craft_fig::value_budget(packed))?;
let embedded = craft_fig::embedded_copies(&msg, craft_fig::Limits::default().max_image);
let image = archive.image("0a1b…").or_else(|| embedded.get("0a1b…").map(|b| b.to_vec()));
let network = craft_fig::network::decode(blob_bytes); // Option<Network>
let outline = craft_fig::path::decode(blob_bytes); // Option<Vec<Command>>
Writing
let canvas = craft_fig::encode_canvas(&kiwi::encode::schema(&schema), &kiwi::encode::message(&schema, root, &msg)?)?;
let bytes = craft_fig::write_archive(&craft_fig::Package {
file_name: "Untitled", background: [1.0, 1.0, 1.0, 1.0], canvas: &canvas, images: &images, thumbnail: None,
})?;
Limits
Input is hostile. Nothing panics; every limit has a test.
| Limit | Value |
|---|---|
canvas.fig read or written (MAX_CANVAS) |
256 MiB |
A decompressed chunk (MAX_CHUNK) |
512 MiB |
The decompressed schema chunk (MAX_SCHEMA) |
16 MiB |
Chunks read (MAX_CHUNKS) |
16 |
meta.json |
1 MiB |
Values decoded from the message (value_budget) |
4 per compressed canvas byte, at least 20 million, at most 128 million (32 million on 32-bit targets) |
An image (Limits::max_image) |
256 MiB |
Image bytes read in all, failed reads included (Limits::max_image_total) |
4 GiB (1 GiB on 32-bit targets) |
An image hash (MAX_IMAGE_HASH) |
64 bytes |
Vertices, segments, regions or loop entries in a vector network (network::MAX_ITEMS) |
200 000 |
Commands in a geometry blob (path::MAX_COMMANDS) |
200 000 |
| A written file (canvas and images) | 2 GiB |
The value budget follows the compressed size, which the file itself bounds, not what it unpacks to: a small file can't claim a large tree however far its chunks inflate.
ZIP
The archive is read with the zip crate directly, with the bounds above applied to each member.
A shared bounded ZIP reader is planned (craft-libs #126); this crate moves to it when it lands.
The message as REST file JSON (rest)
rest::to_rest maps a decoded message onto the REST API file JSON that
craft-figma-rest models: pages, the layer tree, geometry, paints,
strokes, effects, text with styled runs, auto layout and grids, components, component sets and
instances (expanded from their main, with overrides and property values applied), shared styles,
variables, prototype interactions and flows. Layer kinds the REST shape doesn't have (sticky
notes, connectors, tables, …) come out as the closest frames, text and vectors. What isn't
understood is skipped with a warning.
let mapped = craft_fig::rest::to_rest(&msg, "Untitled", &craft_fig::rest::Options {
max_nodes: craft_fig::rest::layer_budget(packed),
..Default::default()
});
let file = craft_figma_rest::File::from_value(mapped.json, usize::MAX)?; // or read the JSON
// mapped.networks: the vector networks layers name by `vectorNetworkRef`
// mapped.warnings: what was skipped or approximated
- Geometry the caller owns. A connector's route between its ends, and whether a text path can
be measured, come from
Options::geometry(therest::Geometrytrait): an editor routes imported connectors as it routes its own.rest::Straightdraws straight lines and accepts every text path. - Extensions. Besides the public fields, the JSON carries the extensions craft-figma-rest
documents and private
_fig*handoffs (each layer's provenance, cached library definitions, mains the layer cap kept out, region paints, saved line breaks, text paths) for an importer that wants them. - Budgets. At most
Options::max_nodeslayers are produced or scanned, instance expansion included (rest::layer_budgetfollows the compressed canvas: 8 bytes a layer, 200 000 to 2 000 000); nesting is followed 256 levels deep (the walk recurses once per level in small frames: an unoptimised build maps past that limit on 512 KiB, half the 1 MiB stack of a WebAssembly build, and a test at the limit runs on a 1 MiB thread); conditional actions 16, expressions 32; grid tracks 1 000; region paint elements 200 000 per file; scale factors between 0.001 and 1000.
The mapping was written in FigCraft (crates/format/src/fig/map) by its contributors, from the
same public format knowledge as the rest of this crate.