Files
craft-libs/crates/figma-rest/README.md
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

115 lines
5.9 KiB
Markdown

# 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`](../fig/README.md)) and the `.sketch` reader ([`craft-sketch`](../sketch/README.md))
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
```rust
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:
```rust
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.