openpencil/packages/fig/docs/export.md
Danila Poyarkov e77eeff11c
fix(fig): write text glyphs from the layout the renderer draws (#928)
* fix(fig): write text glyphs from the layout the renderer draws

Text without saved glyphs was written to .fig with outlines from a
character-by-character fallback: one unwrapped line at y = lineHeight,
no alignment, advances in pixels. Figma lays saved text out from that
data, so every wrapped OpenPencil label opened in Figma on one line, and
since saved glyphs now draw before the paragraph, OpenPencil did the same
after a reopen (#914). The Figma clipboard had a second writer with its
own shaping that matched outlines to characters by index.

One builder in @open-pencil/fig now writes derivedTextData for both. It
keeps glyphs a layer already has and otherwise asks the export runtime to
shape the text. Core shapes with the paragraph the renderer draws, so
lines, alignment, and baselines match, and takes each outline from the
glyph ID CanvasKit chose, so ligatures and contextual forms keep their
shapes. CanvasKit does not say which font drew a run, so outlines are
written only when the run's own font covers it; otherwise the layout is
written without outlines and readers lay the text out themselves.
Advances are in em units and the character offset map has one entry per
character, as in Figma's files.

The reader drops glyphs the earlier fallback wrote, recognised by its
offset map one entry longer than the text with every glyph on the one
written baseline, and any glyph set with a missing outline.

* fix(scene-graph): reflow resized text instead of stretching its glyphs

Resizing scaled a text layer's saved glyphs into the new box. That suits
path text, whose glyphs follow its path, but flat text reflows, and with
saved glyphs drawn before the paragraph a resized Figma text layer was
drawn stretched. Scale glyphs only for path text, including baked path
text that kept only rotated glyphs, and let the width change drop the
rest.

* docs(fig): describe how derived text is written

Figma draws saved text from derivedTextData even when it has the font, so the export docs record which glyphs the shared writer keeps, shapes, or leaves without outlines, and the units it writes. The README names the runtime service by what it now does.

* fix(fig): write text without glyphs when shaping fails

Glyphs are derived data, so a shaper error must not fail the .fig save or Figma clipboard copy that writes them. The builder now writes the layer without glyphs and logs a warning; the clipboard used to swallow the error silently, which hid a font-provider mismatch.
2026-10-06 11:37:02 +00:00

7.2 KiB

Export

editable SceneGraph
  +-- canonical node/resource identities
  +-- explicit owner-scoped overrides
  +-- retained source metadata + edited-field tracking
                         |
                         v
              export-scoped GUID allocation
                         |
              node records + full-path claims
                         |
             schema encoding + blobs + images
                         |
                         v
                     FIG archive

Identity and references

Export must assign one identity to each node/resource and use it consistently in references. A source GUID string is not necessarily the destination graph ID. INSTANCE_SWAP defaults and assignments must be linked to graph nodes before export GUID resolution.

Shared styles are emitted once through the resource path, not again through ordinary scene traversal. Sibling positions must use one consistent ordering scheme matching runtime order. Remote style references and local style references are distinct: remote assets can require a key/version rather than a locally assigned GUID.

Override addressing

owner + target correspondence
           |
           v
[root component key]                  self claim
[nested instance, source child]        descendant claim
[nested A, nested B, source child]     deeper descendant claim

Do not serialize a nested claim as only its final source GUID. Structured self overrides use an empty target ID internally; the exporter must interpret that as the owner, not discard it.

A segment names the target record by GUID, or by its overrideKey when the target came from a published library and carries one — Figma writes both forms and the reader accepts either. material3.fig addresses 51,332 segments by GUID against 24 by key, while every segment in the library-based gold-preview.fig is a key.

Which scene fields are claimable, which raw field each serializes to, and which are placed-space lengths comes from one registry (instance-overrides/fields.ts); materialization records claims and export serializes them from the same table.

A descendant size claim has no effect in Figma, which does not allow resizing a layer inside an instance: no size claim in gold-preview.fig, material3.fig or nuxtui.fig targets a descendant that is not an instance, and the ones that target a nested instance restate that instance's own size. OpenPencil permits the edit, so the writer records it and the reader restores it; Figma keeps the layer at its component's size on open.

A paint colour alias lives inside the paint, not in the node's parameter map. A fills or strokes claim is therefore written with each paint's colorVar, and a boundVariables/fills/N/color override is serialized as that paint claim rather than as a consumption entry, which has no field for it. Reading a claimed paint records its binding claim as well, so a later component sync cannot restore the component's variable.

Values and layout

  • Root size claims and placed sizes occupy different coordinate spaces when uniformly scaled.
  • Do not apply the root-size scaling rule indiscriminately to padding or other fields.
  • Preserve explicit sizing/grow/alignment claims, not just text or final rectangles.
  • Untouched layout metadata retains omission and implicit-size semantics.
  • Edited fields export current values rather than stale retained metadata.
  • Text sizing is explicit state. Being inside auto-layout does not justify forcing HEIGHT.

These rules are supported by focused tests, but coordinate-space handling across all nested scaling and layout combinations is not complete.

Root size claim       Uniform scale       Placed node size
100 x 40              0.5                 50 x 20
     |                                         |
     +-- export in root-claim space            +-- export as node.size

Writing 50 x 20 into both places can apply the scale twice.

This example describes root dimensions only. Padding and other fields require their own coordinate-space contracts; do not apply the same division to every numeric property.

Derived text

Figma draws saved text from derivedTextData, even when it has the font, so the glyphs written here are what Figma shows. buildNodeDerivedTextData writes them for .fig export and the Figma clipboard alike:

  • Glyphs a layer already has (from Figma, or placed along a path) are kept, with their lines recovered from their baselines.
  • Other text is shaped by the export runtime's shapeText, which Core implements with the renderer's paragraph: lines, alignment, and baselines match what OpenPencil draws, and each outline belongs to the glyph ID the shaper chose, so ligatures and contextual forms survive.
  • When the shaper cannot tell which font drew a run (a missing, variable, or fallback font), the layout is written without outlines and readers lay the text out themselves. A partial set of outlines would draw the text with gaps, so it is all or none.
  • Advances are em units, and logicalIndexToCharacterOffsetMap has one entry per character, measured from the start of its line, as in Figma's own files.

The reader drops glyphs an earlier writer laid on one unwrapped line, recognised by an offset map one entry longer than the text with every glyph on the one written baseline.

Component properties and parameters

Current Figma records use typed varValue data for definitions and assignments, and PROP_REF entries in parameterConsumptionMap for bindings. Older value/reference forms also exist. Writing only the older fields can produce visible content with nonfunctional property controls.

Variable bindings and component-property bindings share parameter maps. Combining them must preserve both; one must not replace the whole map containing the other.

Validation boundary

An archive that encodes successfully is not necessarily interpreted correctly by Figma. A writer and reader can agree on the same mistake. Require both record-level regressions and external reopen checks, including actual property actions after import.

Known limitations: explicit-name persistence, complete style associations, all override fields, and structural editing/export interactions remain acceptance gaps. Do not present selected passing properties as complete editable-document compatibility.

Implementation and tests