Files
Dylan M. Taylor 55d120f201 Add craft-figma-rest: a typed model of the Figma REST file JSON (#954)
* 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.
2026-11-11 20:05:20 -05:00
..

craft-figma-rest

A typed model of the Figma REST API file JSON: the response of GET /v1/files/:key (a document with CANVAS pages, plus components, componentSets and styles) and of GET /v1/files/:key/nodes. Read and written with serde. No unsafe.

This shape is the neutral model for design files in craft-libs: the .fig reader (craft-fig) and the .sketch reader (craft-sketch) map what they read onto it, and an application's importer takes it. It is the file's own shape, named as the API names it, and knows nothing of any application's document.

Provenance

Written from the published REST API reference only (the documented node types and their properties). No code or schema file of the API's owner or of any other product was copied.

The model

  • File: name, lastModified, version, schemaVersion, document, components, componentSets, styles. FileNodes / FileNode: the nodes response.
  • Node: a layer's props (NodeProps, read through the node: node.fills) and its children. One record for every node type, since the API's types share most properties.
  • NodeProps names: identity (id, name, type, visible, locked), geometry (absoluteBoundingBox, absoluteRenderBounds, relativeTransform, size, rotation, constraints), appearance (fills, strokes and the stroke properties, fillGeometry, strokeGeometry, cornerRadius, rectangleCornerRadii, cornerSmoothing, effects, opacity, blendMode, isMask, maskType), auto layout (layoutMode and the padding, spacing, sizing and alignment properties), layoutGrids, exportSettings, styles, text (characters, style, characterStyleOverrides, styleOverrideTable, lineTypes, lineIndentations), componentId, booleanOperation, arcData.
  • Paint, ColorStop, Effect, TypeStyle, LayoutGrid, LayoutConstraint, ExportSetting, Path, ArcData, Color, Vector, Rectangle, Transform, and the metadata records Component, ComponentSet, Style.
  • NodeType, PaintType, EffectType, BlendMode, StyleType: the values known here and Other(String) for the rest. Other enumerations are strings, with their values in the field's documentation.

Every field is optional: the API leaves out what has its default.

Not typed yet

Kept in extra (see below), as JSON: prototyping (interactions, flowStartingPoints, prototypeDevice, transitions), variables (boundVariables, explicitVariableModes, the local variables response), component properties (componentPropertyDefinitions, componentProperties, componentPropertyReferences, overrides) and grid auto layout tracks.

Nothing is lost

Every record has extra: the fields the model has no name for, in the JSON they came in, written back on output. A file read and written keeps properties the API added after this model was written. Two things change on a round trip: a field that was null is left out, and a whole number written as 10 comes back as 10.0.

Extensions

Fields that are not in the REST API, added by the readers that map other files onto this shape so that nothing those files hold is lost (ext documents each):

Field On Holds
vectorNetwork a VECTOR node the editable network (ext::VectorNetwork, typed on NodeProps): vertices with corner radii, segments with tangents, regions with loops and winding
vectorNetworkRef a VECTOR node an index into a list of networks handed over beside the JSON
pointCount, innerRadius STAR, REGULAR_POLYGON points or sides; the star's inner radius as a fraction
_letterSpacingPercent a text style letter spacing as a percentage of the font size

A consumer that doesn't know an extension ignores it; the public fields still describe the layer. Fields starting with _fig are private handoffs between one reader and one importer and are not part of this contract; they are kept like any unknown field.

For readers

Warnings collects a reader's notes (each once, with a count, at most 200) and style_name spells a font style the way this shape names it ("SemiBold" → "Semi Bold"), so the readers that build this shape report and name things alike.

Reading and writing

let file = craft_figma_rest::File::from_slice(&bytes)?;          // or from_value(value, max_nodes)
for node in file.document.iter().flat_map(|d| d.walk()) {         // parents first, no recursion
    if node.kind == Some(craft_figma_rest::NodeType::Text) {
        let text = node.characters.as_deref().unwrap_or_default();
    }
}
let json = file.to_value()?;

Building one:

use craft_figma_rest::{Node, NodeProps, NodeType};
let rect = Node::new(NodeProps { id: Some("1:1".into()), kind: Some(NodeType::Rectangle), ..Default::default() });
let page = Node::with_children(NodeProps { kind: Some(NodeType::Canvas), ..Default::default() }, vec![rect]);

Limits

Input is hostile. Nothing panics; every limit has a test.

Limit Value
JSON nesting (MAX_DEPTH): about 500 layers inside one another 1024 levels
Nodes (from_value takes the limit; from_slice uses DEFAULT_MAX_NODES) 200 000
JSON text nesting (from_slice) serde_json's 128 levels

Both limits are checked on the JSON, without recursion, before any typed value is built. from_value and to_value take the layer tree apart and put it together level by level, so a tree at the depth limit is read, written and dropped within a 2 MiB stack (tested). The Serialize and Deserialize implementations go through serde_json::Value as well, but their serializer recurses once per level: use to_value / from_value for deep trees.

A field of the wrong type fails the read, and the error names the type expected. A reader that must skip bad fields one by one (an importer taking files from anywhere) reads the serde_json::Value instead.