@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.
- package/apps/studio/bin/_import-figma.mjs +314 -30
- package/apps/studio/client/panels/SyncPanel.jsx +93 -2
- package/apps/studio/client/styles/3-shell-maude.css +10 -0
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +547 -547
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/figma/fig-decode.test.ts +100 -14
- package/apps/studio/figma/fig-decode.ts +247 -25
- package/apps/studio/figma/fig-differential.test.ts +182 -0
- package/apps/studio/figma/fig-translator.test.ts +192 -0
- package/apps/studio/figma/fig-vector.test.ts +113 -0
- package/apps/studio/figma/fig-vector.ts +145 -0
- package/apps/studio/figma/sanitize.ts +7 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/http.ts +47 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +101 -7
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/index.ts +29 -5
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +157 -8
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-panel-surface.test.ts +34 -1
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/whats-new.json +16 -0
- 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
|
-
|
|
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
|
|
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(
|
|
230
|
-
expect(
|
|
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('
|
|
366
|
-
const { report } = decodeFigArchive(await fixture('design.fig'), { fileKey: KEY });
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
}
|