A .fig message looked up each tag's field with a linear scan of its
definition, so a wide schema and many tags cost their product. Schema
decoding capped fields per definition but not in total, and inflated the
schema chunk under the 512 MiB message cap. Empty non-byte arrays spent
nothing from the decoded-value budget while still growing the output.
Field ids (and enum values) are now found through a per-definition map
built on first use, keeping first-match semantics. A schema holds at most
200 000 fields together and its chunk may inflate to 16 MiB. Every array
field spends one value for itself before its elements.
Distinct image hashes pointing at one embedded blob shared its copy, but
the import resolved each hash separately: the blob was copied out and
hashed again for every alias. Aliases without an images/ member of their
own are now renamed to the first hash naming the blob before layers are
built, so the blob is copied and hashed once.
Image paints also spelled out their whole hash as hex for every layer,
whatever its length; hashes longer than any image can be named by (64
bytes) now resolve to nothing, as a missing hash does.
* M6: a pasted image the clipboard doesn't carry shows as missing and is named (#1646)
The placeholder an import stores for an image it lacks is now striped, not flat grey; the fill keeps the image's name; .fig and paste give a fixed, translated note per image; edit.pasteDesignLayers returns missingImages, the paste's message names those layers, and a paste with notes opens the import report.
* M6: the missing-image placeholder's stripes stay clean when scaled, and a .fig gives one note (#1646)
* M6: pasted layers keep the colours of their shared paint styles (#1638)
A clipboard links a style by the guid it has in its library, which it doesn't hold, and by its library asset, whose copy it does hold. Mapper::resource_id now takes a guid only when the file holds a node by it, else the asset, so .fig import and paste agree; a link to a style that really isn't there keeps the layer's stored colours, with a note.
* M6: Swedish catalog: keep both new import notes after the merge
The .fig import gave a file one decoded value per byte of its compressed canvas (at least 20M, at
most 128M). Dense legitimate files decode past that, further since every scalar field counts, and
the whole import was refused as "not a valid FigCraft file: .fig file: message: too many values".
The budget is now four values per compressed byte; the floor and both ceilings (128M, 32M on 32-bit
targets) are unchanged, so a small file that unpacks past the floor is still refused and no file
decodes more values than the ceiling allowed before. Generated 21 MB and 15 MB canvases (220,044
and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole
without warnings; the 49.7 MB one's test peaks at about 9 GB.
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.
Variable and collection nodes are left out of the layer tree (variables are read from the node list), so an instance's expansion no longer walks them again for every instance outside the layer cap. A nested instance takes only the override rows addressed below it, found by range, and shares its outer instances' property values and slot contents instead of copying them. Crafted files: 13.8 s -> 21 ms and 40.5 s -> 98 ms; normal files map as before.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M6: FigJam .jam files open like .fig files (#1342)
A .jam is the same archive as a .fig (its canvas header reads fig-jam.), which the .fig reader already took; File > Open and its filter, drag and drop, file.import and the command line now take .jam too, and the home screen shows its thumbnail. FigJam layers come in as the .fig reader makes them (stickies and shapes with text as frames with their generated layers, other FigJam layers as frames or vectors with a note, unknown types left out with a note). Tests use a synthetic fig-jam. canvas and a .jam made from our own export.
* M12: a FigJam .jam file opens as a board (#1342)
The .fig reader marks a canvas with FigJam's fig-jam. header as FileKind::Board, so a .jam opens with the board toolbar, the dot grid and the board panels and saves as a board; a .fig stays a design file, and importing a .jam into an open file leaves its kind alone. Its objects stay ordinary layers (frames and vectors with the existing import notes). Builds on the .jam opening branch (#1586).
* M6: .fig import keeps the file's saved line breaks while the text and its box are unedited (#1236)
The line breaker already keeps trailing spaces and the last glyph's letter spacing out of the fit test and doesn't round box widths; imported text wrapped a word early or mid-word because local fonts measure slightly differently from the file's. The import keeps the saved line starts (derivedTextData) on the text with the box width and a stable key of its layout content; layout follows them while both match and no line overflows its box by more than 3% (+1 px).
* M6: local check that imported text wraps at the line breaks its file saved (#1236)
Runs only when FIGCRAFT_FIG_SAMPLES names a folder of .fig files: each fixed-width text with saved breaks wraps there once imported, or falls back by the overflow slack; prints the counts.
- The entry size cap is figcraft_doc::ImageData::MAX_BYTES (256 MiB), the
cap the .fig, .sketch and REST importers already read images with (now
one constant), instead of 32 MiB: an imported image larger than 32 MiB
made the document unjoinable and its server snapshot unrestorable.
- History written before the checks (a restored or joined snapshot, a
replayed server log, a file published to a server) applies without the
image entries they refuse (docprops::without_refused_images), instead of
the file not opening or a restart dropping every edit after the image.
- A key that already holds an image can't be given other bytes: producers
key images by their bytes (the REST importer's placeholder keys always
hold the same picture), and merge treats a key's image as never
changing.
- REST import: an unresolved imageRef shaped like a content hash shares
the rest-image-missing placeholder (the placeholder's bytes don't hash to
it); ImageData::claims_content_hash is shared with collab.
- Tests: collab image_tests (4 new, size test uses a small cap), format
image_tests (1 new).
- Docs: collab-protocol.md, collaboration-parity.md, file-format-parity.md.
Images carried inside a `.fig` message (`image.dataBlob` indexing the
message's `blobs`) were copied out before the message is dropped, one copy
per image hash. Nothing stops a file from pointing many hashes at one
blob: 100 hashes on a 250 MB blob made 25 GB of copies, an out-of-memory
abort from a small file.
The walk now records each hash's blob index, and `embedded_copies` makes
one shared copy per blob for every hash that names it, so the copies are
bounded by the message's own size. Each resolved image is still copied
once on its way into the document, where images are kept by content hash.
Regression: fifty hashes on one blob share one copy; a hash naming a
missing blob is left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w