Files
craft-libs/crates/fig
Dylan M. Taylor 6f05ad84a3 Move craft-opc, -odf, -kra, -ora, -penpot, -fig and -sketch onto craft-zip (#995)
* craft-kra, craft-ora: read and write through craft-zip

Both drop their own bounded entry read (and craft-ora its zip writer). New: the declared entry
count is checked before the archive is indexed (MAX_ENTRIES, 100,000), and an archive with two
entries of one name is refused. craft-ora writes a fixed timestamp. zip_mimetype moves into
craft-zip as stored_mimetype; both crates keep their function.

* craft-zip: Archive is Clone; compressed_size and read_head

For the package crates moving onto it: a clone reads behind &self with its own count,
compressed_size gives a format the ratio an entry claims, and read_head reads a signature
without inflating the rest.

* craft-sketch: read through craft-zip

Its own budgets (ratio, per-member, JSON total, image total) stay. New: the declared entry
count is checked before the archive is indexed (MAX_ENTRIES, 100,000), also when sniffing, and
an archive with two entries of one name is refused.

* craft-fig: read and write through craft-zip

The image budget (per image, total, charged for failed reads, skipped count) stays this crate's.
A failed image read is now charged its declared size, which bounds it because the read stops one
byte past it; the separate checksum check at an exact budget goes, as the read now always reaches
the end of the entry. New: the declared entry count is checked before the archive is indexed
(MAX_ENTRIES, 1,000,000), also when sniffing, and two entries of one name are refused. The
writer writes a fixed timestamp.

* craft-penpot: read and write through craft-zip

The 1 GiB byte budget is now the archive's total in craft-zip, counted from bytes actually read;
the JSON value budget and the sticky exhaustion flag stay here. Duplicate names are found by
craft-zip (the hand-read end record goes). A read that does not fit what is left is refused
before it starts and fails every later check(); one that fails otherwise is charged its declared
size. embedded_fonts reads four bytes of each candidate with read_head. New: the entry count is
checked before the archive is indexed.

* craft-opc: read and write through craft-zip

The entry-count guard and its test sample move to craft-zip; check_entry_count and
zip64_entry_count_sample stay here as wrappers. Package and LazyPackage keep their own counts and
rules and read through craft-zip's bounded read. A part that declares more than is left is now
refused before it is inflated, so it no longer spends the inflate budget (the test that pinned
the old charge is updated). An exactly repeated entry name is logged, not refused. ZipOut wraps
craft-zip's Writer; its output is unchanged in kind (level 6, fixed timestamp).

* craft-odf: read and write through craft-zip

The entry-count guard moves to craft-zip; zip_guard::check and zip64_entry_count_sample stay
public here as wrappers. Package keeps its own count and reads through craft-zip's bounded
read: a damaged file is still Ok(None), now charged its declared size. Two files of one name
are refused. encrypt read every file whole and decrypt cut a file at the limit without saying
so; both now refuse a file declaring more than the per-file limit.

* craft-ora: a needless borrow in a test; Cargo.lock for the crates now on craft-zip
2026-11-11 19:48:05 -05:00
..

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 .fig file is a ZIP archive: canvas.fig (the document), meta.json (file_name, client_meta.background_color, client_meta.thumbnail_size), thumbnail.png and images/<hash> (the hash in hex, no extension). A bare canvas.fig is a file too: the clipboard carries one.
  • canvas.fig is an 8-byte magic (fig-kiwi; fig-jam. for a whiteboard), a little-endian u32 version, 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 nodeChanges list the layers (each with its id, its parent's id and its position among the siblings). Its blobs hold 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.

Not here yet

A typed model of the message (nodes, paints, text, components, variables, prototypes). FigCraft maps the Kv tree straight onto its own document today; a model mirroring the file's schema is the next step.