@1agh/maude 0.59.0 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/apps/studio/bin/_import-figma.mjs +314 -30
  2. package/apps/studio/client/panels/SyncPanel.jsx +93 -2
  3. package/apps/studio/client/styles/3-shell-maude.css +10 -0
  4. package/apps/studio/context.ts +4 -0
  5. package/apps/studio/dist/client.bundle.js +547 -547
  6. package/apps/studio/dist/styles.css +1 -1
  7. package/apps/studio/figma/fig-decode.test.ts +100 -14
  8. package/apps/studio/figma/fig-decode.ts +247 -25
  9. package/apps/studio/figma/fig-differential.test.ts +182 -0
  10. package/apps/studio/figma/fig-translator.test.ts +192 -0
  11. package/apps/studio/figma/fig-vector.test.ts +113 -0
  12. package/apps/studio/figma/fig-vector.ts +145 -0
  13. package/apps/studio/figma/sanitize.ts +7 -0
  14. package/apps/studio/figma/to-artboard.ts +41 -1
  15. package/apps/studio/http.ts +47 -0
  16. package/apps/studio/sync/asset-push-worker.ts +84 -0
  17. package/apps/studio/sync/asset-push.ts +101 -7
  18. package/apps/studio/sync/asset-sweep.ts +262 -0
  19. package/apps/studio/sync/index.ts +29 -5
  20. package/apps/studio/sync/presentation.ts +21 -0
  21. package/apps/studio/sync/supervisor.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
  23. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  24. package/apps/studio/test/sync-asset-push.test.ts +157 -8
  25. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  26. package/apps/studio/test/sync-panel-surface.test.ts +34 -1
  27. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  28. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  29. package/apps/studio/whats-new.json +16 -0
  30. package/package.json +8 -8
@@ -15,7 +15,7 @@ import { crc32, deflateRawSync, zstdCompressSync } from 'node:zlib';
15
15
  import {
16
16
  decodeFigArchive,
17
17
  FigDecodeError,
18
- KNOWN_CONTAINER_VERSIONS,
18
+ OBSERVED_CONTAINER_VERSIONS,
19
19
  readFigContainer,
20
20
  } from './fig-decode.ts';
21
21
  import { findRootDefinition, parseKiwiSchema } from './fig-kiwi.ts';
@@ -224,10 +224,32 @@ describe('tier 1 — container framing', () => {
224
224
  expect(design.schemaSha256.slice(0, 8)).toBe('c22712ff');
225
225
  });
226
226
 
227
- test('an unknown container version REFUSES and names the version', () => {
227
+ test('an unobserved container version DECODES and is flagged, not refused', () => {
228
+ // Measured 2026-08-12 on a real third-party export: version 101, LOWER than
229
+ // the fixtures' 106 despite a later export date, a different schema, and
230
+ // byte-identical framing that decodes cleanly. The version predicts nothing,
231
+ // so refusing on it only rejected valid files (DDR-221 D3, amended).
232
+ const archive = hostileArchive(baseDefs(), baseData());
228
233
  const bytes = containerBytes(schemaBytes(baseDefs()), baseData(), 107);
229
- expect(() => readFigContainer(bytes)).toThrow(/version 107/);
230
- expect(KNOWN_CONTAINER_VERSIONS.has(107)).toBe(false);
234
+ expect(readFigContainer(bytes).version).toBe(107);
235
+ expect(OBSERVED_CONTAINER_VERSIONS.has(107)).toBe(false);
236
+ expect(decodeFigArchive(archive, { fileKey: KEY }).report.containerVersionKnown).toBe(true);
237
+ });
238
+
239
+ test('STRUCTURE is what refuses — the checks that replaced the version gate', () => {
240
+ // Each of these is a framing violation the version number could never have
241
+ // caught, and together they are why dropping the allowlist is safe.
242
+ const defs = baseDefs();
243
+ expect(() =>
244
+ readFigContainer(containerBytes(schemaBytes(defs), baseData(), 106, 'notafig'))
245
+ ).toThrow(/prelude/);
246
+ // A schema with trailing bytes cannot be a Kiwi schema.
247
+ const padded = new Uint8Array([...schemaBytes(defs), 0, 0, 0]);
248
+ expect(() =>
249
+ decodeFigArchive(zipOf([{ name: 'canvas.fig', data: containerBytes(padded, baseData()) }]), {
250
+ fileKey: KEY,
251
+ })
252
+ ).toThrow(/trailing bytes/);
231
253
  });
232
254
 
233
255
  test('an unrecognised prelude refuses without echoing the bytes as text', () => {
@@ -362,16 +384,18 @@ describe('tier 3 — the fixture vocabulary survives the door', () => {
362
384
  expect(JSON.stringify(report)).not.toContain('Maude import fixture');
363
385
  });
364
386
 
365
- test('an unmapped node type degrades and is REPORTED, bounded', async () => {
366
- const { report } = decodeFigArchive(await fixture('design.fig'), { fileKey: KEY });
367
- // The design fixture carries a SYMBOL, which FigmaNodeType has no member
368
- // for. `reportToken` lowercases — the report trades exact casing for the
369
- // one-token-no-spaces guarantee, which is the property that matters.
370
- expect(report.unmappedTypes.map((u) => u.type)).toContain('symbol');
371
- for (const u of report.unmappedTypes) {
372
- expect(u.type.length).toBeLessThanOrEqual(24);
373
- expect(u.type).not.toContain(' ');
374
- }
387
+ test('the internal vocabulary is mapped to REST, so a clean file reports nothing', async () => {
388
+ const { document, report } = decodeFigArchive(await fixture('design.fig'), { fileKey: KEY });
389
+ // SYMBOL/ROUNDED_RECTANGLE/FRAME-with-resizeToFit are Figma INTERNAL names.
390
+ // They are mapped to the public REST vocabulary (COMPONENT/RECTANGLE/GROUP),
391
+ // so a legitimate file has no vocabulary gap at all. Measured by the Tier-2
392
+ // differential; before the mapping this fixture reported `symbol`.
393
+ expect(report.unmappedTypes).toEqual([]);
394
+ const types = new Set<string>();
395
+ walkNodes(document.root, (n) => types.add(n.type));
396
+ expect(types).toContain('COMPONENT');
397
+ expect(types).toContain('GROUP');
398
+ expect(types).not.toContain('UNKNOWN');
375
399
  });
376
400
 
377
401
  test("Figma's internal-only canvas is skipped and counted", async () => {
@@ -645,6 +669,68 @@ describe('silent-wrongness sweep — a hostile file must not produce a plausible
645
669
  });
646
670
  });
647
671
 
672
+ describe('image fills resolve out of the archive (DDR-221 D6)', () => {
673
+ test("a paint's 20-byte image.hash becomes the hex imageRef that names the archive entry", () => {
674
+ const defs = baseDefs();
675
+ defs.push({
676
+ name: 'ImageRefStruct',
677
+ kind: 1,
678
+ fields: [
679
+ ['hash', -2, true, 0],
680
+ ['name', -6, false, 0],
681
+ ],
682
+ });
683
+ defs.push({
684
+ name: 'Paint',
685
+ kind: 2,
686
+ fields: [
687
+ ['type', -6, false, 1],
688
+ ['visible', -1, false, 2],
689
+ ['image', 4, false, 3],
690
+ ],
691
+ });
692
+ defs[2].fields.push(['fillPaints', 5, true, 38]);
693
+
694
+ const hash = [0x0f, 0x0e, 0x1f, 0xf4, 0xb9, 0xf8, 0xbe, 0x0c];
695
+ const w = new W();
696
+ w.varuint(6).varuint(1);
697
+ w.varuint(1).varuint(0).varuint(0);
698
+ w.varuint(4).varuint(1);
699
+ w.varuint(5).str('Document');
700
+ w.varuint(38).varuint(1); // one Paint
701
+ w.varuint(1).str('IMAGE');
702
+ w.varuint(2).byte(1);
703
+ w.varuint(3); // image: ImageRefStruct (a STRUCT — fields in order, no ids)
704
+ w.varuint(hash.length);
705
+ for (const b of hash) w.byte(b);
706
+ w.str('photo');
707
+ w.varuint(0); // end Paint
708
+ w.varuint(0).varuint(0);
709
+
710
+ const { document } = decodeFigArchive(hostileArchive(defs, w.out), { fileKey: KEY });
711
+ const fill = document.root.fills?.[0];
712
+ // Measured on a real export: this hex IS the `images/<name>` entry, which is
713
+ // what makes the offline door resolve pictures with no network at all.
714
+ expect(fill?.imageRef).toBe('0f0e1ff4b9f8be0c78b7e0a28320f60808027d35'.slice(0, 16));
715
+ expect(fill?.type).toBe('IMAGE');
716
+ });
717
+
718
+ test('an archive entry is fetched by exact name and verified, never by path', async () => {
719
+ // The lookup key rule (D6): `images/<hex>` is matched literally against the
720
+ // central directory, and the bytes are CRC-checked on the way out.
721
+ const png = new TextEncoder().encode('not-really-a-png-but-bytes-are-bytes');
722
+ const zip = zipOf([
723
+ { name: 'canvas.fig', data: containerBytes(schemaBytes(baseDefs()), baseData()) },
724
+ { name: 'images/0f0e1ff4b9f8be0c78b7e0a28320f60808027d35', data: png },
725
+ ]);
726
+ const archive = readFigZip(zip);
727
+ expect(archive.has('images/0f0e1ff4b9f8be0c78b7e0a28320f60808027d35')).toBe(true);
728
+ expect(archive.get('images/0f0e1ff4b9f8be0c78b7e0a28320f60808027d35')).toEqual(png);
729
+ // A ref that is not an entry is simply absent — no traversal, no guessing.
730
+ expect(archive.get('images/../canvas.fig')).toBeUndefined();
731
+ });
732
+ });
733
+
648
734
  // ── Fuzz corpus — mandatory for a parser fed untrusted bytes ────────────────
649
735
 
650
736
  describe('fuzz — mutated real archives never crash the process', () => {
@@ -27,7 +27,9 @@
27
27
  import { createHash } from 'node:crypto';
28
28
  import { inflateRawSync, zstdDecompressSync } from 'node:zlib';
29
29
 
30
+ import { styleToWeight } from './codegen-fonts.ts';
30
31
  import { decodeKiwi, findRootDefinition, type KiwiSchema, parseKiwiSchema } from './fig-kiwi.ts';
32
+ import { pathFromBlob, type VectorPath } from './fig-vector.ts';
31
33
  import { FigZipError, readFigZip } from './fig-zip.ts';
32
34
  import { reportToken } from './sanitize.ts';
33
35
  import {
@@ -46,12 +48,28 @@ const PRELUDES: Record<string, 'design' | 'board'> = {
46
48
  };
47
49
 
48
50
  /**
49
- * Known container versions. An unknown one REFUSES (DDR-221 D3) — this is the
50
- * decision most likely to annoy someone the first time Figma ships 107, and the
51
- * trade is deliberate: the message names the version, the schema-hash alarm is
52
- * designed to see it coming, and the REST door still works.
51
+ * Container versions we have actually observed. INFORMATIONAL — not a gate.
52
+ *
53
+ * DDR-221 D3 originally REFUSED an unrecognised version, on the assumption that
54
+ * the number predicts framing compatibility. Measured on a real third-party
55
+ * export (2026-08-12): it does not. That file is version **101** — LOWER than
56
+ * the fixtures' 106 despite being exported nine days later — carries a
57
+ * DIFFERENT schema (`7ae1921b` vs `c22712ff`), and decodes perfectly under the
58
+ * same code, because the framing is byte-identical and the file brings its own
59
+ * schema (D1's whole thesis).
60
+ *
61
+ * So the version was refusing valid files while predicting nothing. What
62
+ * actually gates correctness is STRUCTURE, all of it still enforced: the
63
+ * prelude, exactly two chunks with zero trailing bytes, a raw-deflate schema
64
+ * that parses and consumes every byte, the zstd magic, STRICT root resolution
65
+ * (exactly one `Message` of kind MESSAGE with a `nodeChanges: NodeChange[]`),
66
+ * and a data chunk that decodes with nothing left over. A file passing all of
67
+ * those is a Figma document whatever integer sits in bytes 8–11.
68
+ *
69
+ * The version is reported instead, and an unobserved one is worth noticing —
70
+ * see `FigDecodeReport.containerVersion` and the schema-hash alarm (D8).
53
71
  */
54
- export const KNOWN_CONTAINER_VERSIONS: ReadonlySet<number> = new Set([106]);
72
+ export const OBSERVED_CONTAINER_VERSIONS: ReadonlySet<number> = new Set([101, 106]);
55
73
 
56
74
  /** ~1 500x the measured 42 KB. */
57
75
  export const MAX_CANVAS_FIG_BYTES = 64 * 1024 * 1024;
@@ -87,7 +105,10 @@ export interface FigContainer {
87
105
  }
88
106
 
89
107
  export interface FigDecodeReport {
108
+ /** Observed, not validated. See `OBSERVED_CONTAINER_VERSIONS`. */
90
109
  containerVersion: number;
110
+ /** True when this version is one we have seen before — a soft drift signal. */
111
+ containerVersionKnown: boolean;
91
112
  schemaSha256: string;
92
113
  /** From `meta.json`. Makes a dated fixture corpus self-labelling. */
93
114
  exportedAt?: string;
@@ -99,11 +120,24 @@ export interface FigDecodeReport {
99
120
  unmappedTypes: Array<{ type: string; count: number }>;
100
121
  /** Nodes dropped because Figma marks them internal (e.g. the hidden canvas). */
101
122
  internalNodesSkipped: number;
123
+ /**
124
+ * Fields this door CANNOT reproduce from a local file, with how many nodes
125
+ * each affected. Reported rather than left implicit: the plan's bar is that
126
+ * known-lossy fields are named, never silently degraded.
127
+ */
128
+ lossyFields: Array<{ field: string; count: number; why: string }>;
102
129
  }
103
130
 
104
131
  export interface FigDecodeResult {
105
132
  document: NormalizedDocument;
106
133
  report: FigDecodeReport;
134
+ /**
135
+ * Vector geometry the archive carries, by node id. Kept OUT of the normalized
136
+ * tree on purpose: REST has no equivalent, so putting it there would make the
137
+ * Tier-2 differential diverge for a field the REST door cannot produce. The
138
+ * local importer reads this to build SVGs without asking Figma to render.
139
+ */
140
+ vectors: Map<string, VectorPath>;
107
141
  }
108
142
 
109
143
  function u32le(b: Uint8Array, o: number): number {
@@ -150,13 +184,9 @@ export function readFigContainer(bytes: Uint8Array): FigContainer {
150
184
  throw new FigDecodeError(`unrecognised Figma container prelude (bytes: ${hex})`);
151
185
  }
152
186
 
187
+ // Read, reported, NOT gated — see OBSERVED_CONTAINER_VERSIONS for why the
188
+ // allowlist was removed. Structure gates; the integer does not.
153
189
  const version = u32le(bytes, 8);
154
- if (!KNOWN_CONTAINER_VERSIONS.has(version)) {
155
- throw new FigDecodeError(
156
- `unsupported Figma container version ${version} (known: ${[...KNOWN_CONTAINER_VERSIONS].join(', ')}). ` +
157
- 'Import this file over the Figma API instead.'
158
- );
159
- }
160
190
 
161
191
  const chunks: Uint8Array[] = [];
162
192
  let offset = 12;
@@ -295,14 +325,97 @@ function guidOf(v: unknown): string | undefined {
295
325
  * schema, so a generic copy would let them choose which REST field each value
296
326
  * lands in (`absoluteBoundingBox`, `characters`, `children`).
297
327
  */
298
- function toRestNode(change: Record<string, unknown>, absolute: Matrix): Record<string, unknown> {
328
+ /**
329
+ * `.fig` carries Figma's INTERNAL node vocabulary; REST reports the public one,
330
+ * and the translators are written against REST. Measured by the Tier-2
331
+ * differential on the fixtures — the unit tests could not have found this,
332
+ * because both sides of an internal-vocabulary mismatch look perfectly valid.
333
+ *
334
+ * A GROUP is internally a FRAME with `resizeToFit` (shrink-wrap to children);
335
+ * that flag is the discriminator, 5/5 on the design fixture and 1/1 on the
336
+ * board. `ROUNDED_RECTANGLE` collapses to `RECTANGLE` (the radius survives in
337
+ * `cornerRadius`), and `SYMBOL` is Figma's internal name for a `COMPONENT`.
338
+ */
339
+ function restType(change: Record<string, unknown>): string | undefined {
340
+ const type = str(change.type);
341
+ if (type === 'FRAME' && change.resizeToFit === true) return 'GROUP';
342
+ if (type === 'ROUNDED_RECTANGLE') return 'RECTANGLE';
343
+ if (type === 'SYMBOL') return 'COMPONENT';
344
+ return type;
345
+ }
346
+
347
+ /**
348
+ * Pull a FigJam node's text out of its template overrides. Reads only the
349
+ * hardcoded `nodeGenerationData.overrides[].textData.characters` path (A8/F5) —
350
+ * the override list is attacker-controlled, so it is walked, never spread — and
351
+ * takes the first entry that carries characters, which is what REST reports as
352
+ * the node's own `characters`.
353
+ */
354
+ /**
355
+ * `.fig` identifies an image fill by a 20-byte `image.hash`; REST calls the same
356
+ * thing `imageRef` and states it as hex. Measured on a real export: the hex IS
357
+ * the archive entry name (`images/<hex>`), which is what makes D6's "images
358
+ * travel inside the file" resolvable with no network at all.
359
+ *
360
+ * Only that one field is added — the paint is otherwise passed through for
361
+ * `normalizeDocument` to sanitize, exactly like the REST door's paints.
362
+ */
363
+ function toRestPaint(paint: unknown): unknown {
364
+ const p = obj(paint);
365
+ if (!p || p.imageRef !== undefined) return paint;
366
+ const hash = obj(p.image)?.hash;
367
+ if (!Array.isArray(hash) || hash.length === 0 || hash.length > 64) return paint;
368
+ let hex = '';
369
+ for (const byte of hash) {
370
+ const n = num(byte);
371
+ if (n === undefined || n < 0 || n > 255 || !Number.isInteger(n)) return paint;
372
+ hex += n.toString(16).padStart(2, '0');
373
+ }
374
+ return { ...p, imageRef: hex };
375
+ }
376
+
377
+ function overrides(change: Record<string, unknown>): unknown[] {
378
+ const list = obj(change.nodeGenerationData)?.overrides;
379
+ return Array.isArray(list) ? list : [];
380
+ }
381
+
382
+ /**
383
+ * First override carrying `key`. The template's own root comes first, so this
384
+ * resolves to the node's own paint/text rather than a sub-part's.
385
+ *
386
+ * The ordering is an OBSERVED property, not a documented one — which is exactly
387
+ * why the Tier-3 comparison is the guard: if Figma ever reorders these, the
388
+ * translator diff against REST fails loudly instead of quietly picking the
389
+ * wrong colour. Only hardcoded keys are read (A8/F5).
390
+ */
391
+ function fromOverrides(change: Record<string, unknown>, key: string): unknown {
392
+ for (const entry of overrides(change)) {
393
+ const value = obj(entry)?.[key];
394
+ if (value !== undefined) return value;
395
+ }
396
+ return undefined;
397
+ }
398
+
399
+ function overrideText(change: Record<string, unknown>): string | undefined {
400
+ for (const entry of overrides(change)) {
401
+ const characters = str(obj(obj(entry)?.textData)?.characters);
402
+ if (characters !== undefined) return characters;
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ function toRestNode(
408
+ change: Record<string, unknown>,
409
+ absolute: Matrix,
410
+ lossy: { lineHeights: number }
411
+ ): Record<string, unknown> {
299
412
  const size = obj(change.size);
300
413
  const w = num(size?.x) ?? 0;
301
414
  const h = num(size?.y) ?? 0;
302
415
 
303
416
  const raw: Record<string, unknown> = {
304
417
  id: guidOf(change.guid),
305
- type: str(change.type),
418
+ type: restType(change),
306
419
  name: str(change.name) ?? '',
307
420
  visible: change.visible !== false,
308
421
  absoluteBoundingBox: absoluteBox(absolute, w, h),
@@ -316,8 +429,13 @@ function toRestNode(change: Record<string, unknown>, absolute: Matrix): Record<s
316
429
  if (cornerRadius !== undefined) raw.cornerRadius = cornerRadius;
317
430
  const strokeWeight = num(change.strokeWeight);
318
431
  if (strokeWeight !== undefined) raw.strokeWeight = strokeWeight;
319
- if (Array.isArray(change.fillPaints)) raw.fills = change.fillPaints;
320
- if (Array.isArray(change.strokePaints)) raw.strokes = change.strokePaints;
432
+ // A FigJam STICKY / SHAPE_WITH_TEXT is a template instance: its own paint is
433
+ // an override, not a top-level field. Reading only the top level left every
434
+ // board node on the translator's default colour (found by Tier 3).
435
+ const fills = change.fillPaints ?? fromOverrides(change, 'fillPaints');
436
+ if (Array.isArray(fills)) raw.fills = fills.map(toRestPaint);
437
+ const strokes = change.strokePaints ?? fromOverrides(change, 'strokePaints');
438
+ if (Array.isArray(strokes)) raw.strokes = strokes;
321
439
  if (Array.isArray(change.effects)) raw.effects = change.effects;
322
440
 
323
441
  // Auto-layout. `.fig` calls it `stack*`; REST calls it `layout*`.
@@ -342,19 +460,39 @@ function toRestNode(change: Record<string, unknown>, absolute: Matrix): Record<s
342
460
  if (counter) raw.counterAxisAlignItems = counter;
343
461
  }
344
462
 
345
- // Text.
346
- const textData = obj(change.textData);
347
- const characters = str(textData?.characters);
463
+ // Text. Two storage locations, and the second one is not optional:
464
+ // - a design-file TEXT node carries `textData.characters` directly;
465
+ // - a FigJam STICKY / SHAPE_WITH_TEXT is an instance of an internal
466
+ // template, and its text is an OVERRIDE on a sub-node, under
467
+ // `nodeGenerationData.overrides[].textData.characters`.
468
+ // Missing the second path lost the text of every sticky and every shape on
469
+ // the board while the tree still looked perfect — found by the Tier-2
470
+ // differential (20 nodes), invisible to every unit test.
471
+ const characters = str(obj(change.textData)?.characters) ?? overrideText(change);
348
472
  if (characters !== undefined) raw.characters = characters;
349
473
  const fontSize = num(change.fontSize);
350
474
  const fontName = obj(change.fontName);
351
- const align = str(change.textAlignHorizontal);
352
- if (fontSize !== undefined || fontName || align) {
475
+ if (fontSize !== undefined || fontName || characters !== undefined) {
476
+ // `.fig` encodes typography sparsely and semantically; REST reports it
477
+ // resolved. Three deltas, all found by the Tier-3 translator comparison:
478
+ // - weight lives in the font STYLE name ("Bold"), not a number — reuse the
479
+ // codegen lane's map rather than keeping a second one;
480
+ // - a default alignment is OMITTED, where REST always states it;
481
+ // - lineHeight is authored ({value, units}), and REST reports the RESOLVED
482
+ // pixel value. A PERCENT line-height cannot be resolved to px without
483
+ // font metrics we do not have offline, so it is carried only when the
484
+ // file already states pixels. This is the local door's one genuinely
485
+ // lossy typography field and it is asserted as lossy in the Tier-3 test.
486
+ const lineHeight = obj(change.lineHeight);
487
+ const lineHeightPx = str(lineHeight?.units) === 'PIXELS' ? num(lineHeight?.value) : undefined;
488
+ if (lineHeight && lineHeightPx === undefined) lossy.lineHeights++;
353
489
  raw.style = {
354
490
  fontSize,
355
491
  fontFamily: str(fontName?.family),
356
492
  fontPostScriptName: str(fontName?.postscript),
357
- textAlignHorizontal: align,
493
+ fontWeight: styleToWeight(str(fontName?.style) ?? null) ?? undefined,
494
+ textAlignHorizontal: str(change.textAlignHorizontal) ?? 'LEFT',
495
+ ...(lineHeightPx !== undefined ? { lineHeightPx } : {}),
358
496
  };
359
497
  }
360
498
 
@@ -379,11 +517,76 @@ function toRestNode(change: Record<string, unknown>, absolute: Matrix): Record<s
379
517
  return raw;
380
518
  }
381
519
 
520
+ /**
521
+ * A PERCENT line-height cannot be resolved to pixels without font metrics, and
522
+ * REST reports the resolved value. Counted per node so the import summary can
523
+ * say so out loud (the only lossy typography field — Tier 3 asserts it is).
524
+ */
525
+ const LOSSY_LINE_HEIGHT = {
526
+ field: 'style.lineHeightPx',
527
+ why: 'a percent line-height needs font metrics the local file does not carry',
528
+ };
529
+
530
+ /**
531
+ * Read one node's own path geometry, if it has any.
532
+ *
533
+ * A decode failure DEGRADES to "no vector" rather than refusing the document:
534
+ * a single unreadable icon should not cost the whole import, and the caller
535
+ * reports the absence. That is the one place the fail-loud rule bends, and it
536
+ * bends toward reporting rather than toward guessing at the shape.
537
+ */
538
+ function vectorOf(change: Record<string, unknown>, blobs: readonly unknown[]): VectorPath | null {
539
+ const geometry = change.fillGeometry;
540
+ if (!Array.isArray(geometry) || geometry.length === 0) return null;
541
+ const first = obj(geometry[0]);
542
+ const index = num(first?.commandsBlob);
543
+ if (index === undefined || index < 0 || index >= blobs.length) return null;
544
+
545
+ const raw = obj(blobs[index])?.bytes;
546
+ const bytes =
547
+ raw instanceof Uint8Array ? raw : Array.isArray(raw) ? Uint8Array.from(raw as number[]) : null;
548
+ if (!bytes) return null;
549
+
550
+ let d: string;
551
+ try {
552
+ d = pathFromBlob(bytes);
553
+ } catch {
554
+ return null;
555
+ }
556
+
557
+ const paint = (Array.isArray(change.fillPaints) ? change.fillPaints : []).find(
558
+ (p) => obj(p)?.type === 'SOLID' && obj(p)?.visible !== false
559
+ );
560
+ const colour = obj(obj(paint)?.color);
561
+ const hex =
562
+ colour === undefined
563
+ ? null
564
+ : `#${(['r', 'g', 'b'] as const)
565
+ .map((k) => {
566
+ const v = num(colour[k]) ?? 0;
567
+ return Math.max(0, Math.min(255, Math.round(v * 255)))
568
+ .toString(16)
569
+ .padStart(2, '0');
570
+ })
571
+ .join('')}`;
572
+
573
+ return {
574
+ d,
575
+ fill: hex,
576
+ fillOpacity: num(obj(paint)?.opacity) ?? 1,
577
+ fillRule: str(first?.windingRule) === 'EVENODD' ? 'evenodd' : 'nonzero',
578
+ x: 0,
579
+ y: 0,
580
+ };
581
+ }
582
+
382
583
  interface RebuildResult {
383
584
  root: Record<string, unknown>;
384
585
  unmapped: Map<string, number>;
385
586
  internalSkipped: number;
386
587
  nodeCount: number;
588
+ lossyLineHeights: number;
589
+ vectors: Map<string, VectorPath>;
387
590
  }
388
591
 
389
592
  /**
@@ -392,7 +595,7 @@ interface RebuildResult {
392
595
  * that the Kiwi decode-depth cap does not bound (A8/F4) — hence the explicit
393
596
  * cycle, duplicate, orphan and single-root controls here.
394
597
  */
395
- function rebuildTree(changes: unknown[]): RebuildResult {
598
+ function rebuildTree(changes: unknown[], blobs: readonly unknown[]): RebuildResult {
396
599
  if (changes.length > MAX_NODE_COUNT) {
397
600
  throw new FigDecodeError(
398
601
  `file carries ${changes.length} nodes, over the ${MAX_NODE_COUNT} limit. Import a specific frame instead.`
@@ -454,6 +657,8 @@ function rebuildTree(changes: unknown[]): RebuildResult {
454
657
 
455
658
  const unmapped = new Map<string, number>();
456
659
  const onStack = new Set<string>();
660
+ const vectors = new Map<string, VectorPath>();
661
+ const lossy = { lineHeights: 0 };
457
662
  let nodeCount = 0;
458
663
  let internalSkipped = 0;
459
664
 
@@ -473,9 +678,12 @@ function rebuildTree(changes: unknown[]): RebuildResult {
473
678
 
474
679
  const change = byId.get(id) as Record<string, unknown>;
475
680
  const absolute = compose(parentAbsolute, matrixOf(change.transform));
476
- const node = toRestNode(change, absolute);
681
+ const node = toRestNode(change, absolute, lossy);
477
682
  nodeCount++;
478
683
 
684
+ const art = vectorOf(change, blobs);
685
+ if (art) vectors.set(String(node.id), art);
686
+
479
687
  const type = node.type;
480
688
  if (typeof type !== 'string' || !KNOWN_NODE_TYPES.has(type)) {
481
689
  // Vocabulary gap: degrade and report (D3). The raw string is left in
@@ -514,7 +722,14 @@ function rebuildTree(changes: unknown[]): RebuildResult {
514
722
  throw new FigDecodeError('the document root is marked internal-only');
515
723
  }
516
724
  const root = build(roots[0], IDENTITY, 0);
517
- return { root, unmapped, internalSkipped, nodeCount };
725
+ return {
726
+ root,
727
+ unmapped,
728
+ internalSkipped,
729
+ nodeCount,
730
+ lossyLineHeights: lossy.lineHeights,
731
+ vectors,
732
+ };
518
733
  }
519
734
 
520
735
  // ── The door ────────────────────────────────────────────────────────────────
@@ -561,7 +776,11 @@ export function decodeFigArchive(archive: Uint8Array, opts: DecodeFigOptions): F
561
776
  throw new FigDecodeError('canvas.fig carries no node changes');
562
777
  }
563
778
 
564
- const { root, unmapped, internalSkipped, nodeCount } = rebuildTree(changes);
779
+ const blobs = Array.isArray(message?.blobs) ? message.blobs : [];
780
+ const { root, unmapped, internalSkipped, nodeCount, lossyLineHeights, vectors } = rebuildTree(
781
+ changes,
782
+ blobs
783
+ );
565
784
 
566
785
  const document = normalizeDocument(root, {
567
786
  fileKey: opts.fileKey,
@@ -571,8 +790,10 @@ export function decodeFigArchive(archive: Uint8Array, opts: DecodeFigOptions): F
571
790
 
572
791
  return {
573
792
  document,
793
+ vectors,
574
794
  report: {
575
795
  containerVersion: container.version,
796
+ containerVersionKnown: OBSERVED_CONTAINER_VERSIONS.has(container.version),
576
797
  schemaSha256: container.schemaSha256,
577
798
  exportedAt: readExportedAt(safeEntry(zip, META_ENTRY)),
578
799
  nodeCount,
@@ -580,6 +801,7 @@ export function decodeFigArchive(archive: Uint8Array, opts: DecodeFigOptions): F
580
801
  .map(([type, count]) => ({ type, count }))
581
802
  .sort((a, b) => b.count - a.count),
582
803
  internalNodesSkipped: internalSkipped,
804
+ lossyFields: lossyLineHeights > 0 ? [{ ...LOSSY_LINE_HEIGHT, count: lossyLineHeights }] : [],
583
805
  },
584
806
  };
585
807
  }