openfig-cli 0.3.42 → 0.4.0
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/README.md +4 -1
- package/bin/cli.mjs +5 -0
- package/bin/commands/convert-html.mjs +44 -0
- package/bin/commands/create-deck.mjs +34 -0
- package/lib/core/fig-deck.mjs +39 -0
- package/lib/rasterizer/svg-builder.mjs +181 -41
- package/lib/slides/api.mjs +435 -63
- package/lib/slides/browser-extract.mjs +1280 -0
- package/lib/slides/empty-deck.mjs +354 -0
- package/lib/slides/handoff/bundle-loader.mjs +93 -0
- package/lib/slides/handoff/element-dispatch.mjs +1685 -0
- package/lib/slides/handoff-converter.mjs +321 -0
- package/lib/slides/html-converter.mjs +395 -0
- package/lib/slides/playwright-layout.mjs +169 -0
- package/mcp-server.mjs +36 -0
- package/package.json +4 -1
- package/lib/slides/blank-template.deck +0 -0
package/README.md
CHANGED
|
@@ -33,6 +33,9 @@ openfig list-overrides deck.deck # editable override keys per symbol
|
|
|
33
33
|
openfig export deck.deck # export slides/frames as PNG
|
|
34
34
|
openfig pdf deck.deck # export as multi-page PDF
|
|
35
35
|
|
|
36
|
+
# Create (.deck only)
|
|
37
|
+
openfig create-deck -o new.deck [--title "Name"] [--layout cover --layout content ...]
|
|
38
|
+
|
|
36
39
|
# Modify (.deck only)
|
|
37
40
|
openfig update-text deck.deck -o out.deck --slide <id> --set "key=value"
|
|
38
41
|
openfig insert-image deck.deck -o out.deck --slide <id> --key <nodeId> --image <path>
|
|
@@ -61,7 +64,7 @@ Plug in Claude Cowork or any coding agent and you have an AI that can read and e
|
|
|
61
64
|
| High-level API | [docs/api-spec.md](docs/api-spec.md) |
|
|
62
65
|
| Low-level FigDeck API | [docs/library.md](docs/library.md) |
|
|
63
66
|
| Template workflows | [docs/template-workflows.md](docs/template-workflows.md) |
|
|
64
|
-
| File format internals | [
|
|
67
|
+
| File format internals | [openfig-core/docs](https://github.com/OpenFig-org/openfig-core/tree/main/docs) |
|
|
65
68
|
|
|
66
69
|
## License
|
|
67
70
|
|
package/bin/cli.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Usage: openfig <command> <file.deck | file.fig> [args...]
|
|
6
6
|
*
|
|
7
7
|
* Commands:
|
|
8
|
+
* create-deck Create a new .deck file from scratch
|
|
8
9
|
* inspect Show document structure (node hierarchy tree)
|
|
9
10
|
* list-text List all text content in the deck
|
|
10
11
|
* list-overrides List all editable override keys per symbol
|
|
@@ -21,6 +22,8 @@
|
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
const COMMANDS = {
|
|
25
|
+
'create-deck': './commands/create-deck.mjs',
|
|
26
|
+
'convert-html': './commands/convert-html.mjs',
|
|
24
27
|
'inspect': './commands/inspect.mjs',
|
|
25
28
|
'list-text': './commands/list-text.mjs',
|
|
26
29
|
'list-overrides': './commands/list-overrides.mjs',
|
|
@@ -40,6 +43,8 @@ if (!arg2 || arg2 === '--help' || arg2 === '-h') {
|
|
|
40
43
|
console.log(`OpenFig — Open-source tools for Figma file parsing and rendering\n`);
|
|
41
44
|
console.log('Usage: openfig <command> <file.deck | file.fig> [args...]\n');
|
|
42
45
|
console.log('Commands:');
|
|
46
|
+
console.log(' create-deck Create a new .deck file from scratch');
|
|
47
|
+
console.log(' convert-html Convert a Claude Design standalone HTML export into a .deck');
|
|
43
48
|
console.log(' export Export slides as images (PNG/JPG/WEBP)');
|
|
44
49
|
console.log(' pdf Export slides as a multi-page PDF');
|
|
45
50
|
console.log(' inspect Show document structure (node hierarchy tree)');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* convert-html — Convert a Claude Design standalone HTML export into a .deck file.
|
|
3
|
+
*
|
|
4
|
+
* Usage: openfig convert-html <input.html> -o <out.deck> [--title "Name"] [--dry-run]
|
|
5
|
+
*/
|
|
6
|
+
import { statSync } from 'fs';
|
|
7
|
+
import { resolve } from 'path';
|
|
8
|
+
import { convertStandaloneHtml } from '../../lib/slides/html-converter.mjs';
|
|
9
|
+
|
|
10
|
+
export async function run(args, flags) {
|
|
11
|
+
const inPath = args[0];
|
|
12
|
+
const outPath = flags.o || flags.out || flags.output;
|
|
13
|
+
const dryRun = !!(flags['dry-run'] || flags.dryRun);
|
|
14
|
+
if (!inPath || (!dryRun && (!outPath || outPath === true))) {
|
|
15
|
+
console.error('Usage: convert-html <input.html> -o <out.deck> [--title "Name"] [--dry-run]');
|
|
16
|
+
console.error(' <input.html> (required) Claude Design standalone HTML export');
|
|
17
|
+
console.error(' -o / --out (required unless --dry-run) output .deck path');
|
|
18
|
+
console.error(' --title (optional) presentation name (default: inferred from <title>)');
|
|
19
|
+
console.error(' --dry-run (optional) extract geometry only, skip .deck emission');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const title = typeof flags.title === 'string' ? flags.title : undefined;
|
|
24
|
+
const opts = {};
|
|
25
|
+
if (title) opts.title = title;
|
|
26
|
+
if (dryRun) opts.dryRun = true;
|
|
27
|
+
|
|
28
|
+
// In dry-run mode there's no .deck output path; pick a scratch-only target
|
|
29
|
+
// next to the input so the extractor's scratch directory lives somewhere
|
|
30
|
+
// predictable for inspection.
|
|
31
|
+
const effectiveOut = outPath && outPath !== true
|
|
32
|
+
? outPath
|
|
33
|
+
: inPath.replace(/\.html?$/i, '') + '.dryrun.deck';
|
|
34
|
+
const result = await convertStandaloneHtml(inPath, effectiveOut, opts);
|
|
35
|
+
|
|
36
|
+
if (dryRun) {
|
|
37
|
+
console.log(`Dry run: extracted ${result.manifest.slides.length} slide(s); manifest at ${result.scratchDir}/manifest.json`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const absolute = resolve(outPath);
|
|
42
|
+
const bytes = statSync(absolute).size;
|
|
43
|
+
console.log(`Saved: ${outPath} (${bytes} bytes)`);
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-deck — Create a new .deck file from scratch.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node cli.mjs create-deck -o <out.deck> [--title "Name"] [--layout cover --layout content ...]
|
|
5
|
+
*/
|
|
6
|
+
import { statSync } from 'fs';
|
|
7
|
+
import { resolve } from 'path';
|
|
8
|
+
import { createDraftTemplate } from '../../lib/slides/template-deck.mjs';
|
|
9
|
+
|
|
10
|
+
export async function run(args, flags) {
|
|
11
|
+
const outPath = flags.o || flags.out || flags.output;
|
|
12
|
+
if (!outPath || outPath === true) {
|
|
13
|
+
console.error('Usage: create-deck -o <out.deck> [--title "Name"] [--layout <name> ...]');
|
|
14
|
+
console.error(' -o / --out (required) output .deck path');
|
|
15
|
+
console.error(' --title (optional) presentation name (default: "Untitled")');
|
|
16
|
+
console.error(' --layout (optional, repeatable) layout name(s) for blank slides (default: cover)');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const title = typeof flags.title === 'string' ? flags.title : undefined;
|
|
21
|
+
const layouts = Array.isArray(flags.layout)
|
|
22
|
+
? flags.layout
|
|
23
|
+
: (flags.layout ? [flags.layout] : undefined);
|
|
24
|
+
|
|
25
|
+
await createDraftTemplate(outPath, {
|
|
26
|
+
...(title !== undefined ? { title } : {}),
|
|
27
|
+
...(layouts ? { layouts } : {}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const absolute = resolve(outPath);
|
|
31
|
+
const bytes = statSync(absolute).size;
|
|
32
|
+
const slideCount = layouts ? layouts.length : 1;
|
|
33
|
+
console.log(`Saved: ${outPath} (${bytes} bytes, ${slideCount} slide${slideCount === 1 ? '' : 's'})`);
|
|
34
|
+
}
|
package/lib/core/fig-deck.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - Chunk 2+ = optional additional data (pass through as-is)
|
|
11
11
|
*/
|
|
12
12
|
import { parseFigBinary } from 'openfig-core';
|
|
13
|
+
import { createEmptyDeckDoc, createPlaceholderThumbnail } from '../slides/empty-deck.mjs';
|
|
13
14
|
import { encodeBinarySchema } from 'kiwi-schema';
|
|
14
15
|
import { deflateRaw } from 'pako';
|
|
15
16
|
import { ZstdCodec } from 'zstd-codec';
|
|
@@ -77,6 +78,44 @@ export class FigDeck {
|
|
|
77
78
|
return deck;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Create an empty .deck in memory — no seed file, no bundled theme content.
|
|
83
|
+
* The returned FigDeck has a minimum-viable Slides hierarchy:
|
|
84
|
+
* DOCUMENT → CANVAS "Page 1" → SLIDE_GRID "Presentation" → SLIDE_ROW → SLIDE
|
|
85
|
+
* Plus the auxiliary CANVAS "Internal Only Canvas".
|
|
86
|
+
*/
|
|
87
|
+
static createEmpty(opts = {}) {
|
|
88
|
+
const deck = new FigDeck();
|
|
89
|
+
const doc = createEmptyDeckDoc(opts);
|
|
90
|
+
// .deck files use the "fig-deck" prelude; createEmptyFigDoc returns
|
|
91
|
+
// "fig-kiwi" (the .fig Design-file magic). Override for Slides.
|
|
92
|
+
deck.header = { ...doc.header, prelude: 'fig-deck' };
|
|
93
|
+
deck.schema = doc.schema;
|
|
94
|
+
deck.compiledSchema = doc.compiledSchema;
|
|
95
|
+
deck.message = {
|
|
96
|
+
...doc.message,
|
|
97
|
+
sessionID: doc.message.sessionID ?? 0,
|
|
98
|
+
ackID: doc.message.ackID ?? 0,
|
|
99
|
+
blobs: doc.message.blobs ?? [],
|
|
100
|
+
};
|
|
101
|
+
deck.rawFiles = doc.rawChunks ?? [];
|
|
102
|
+
deck.nodeMap = doc.nodeMap;
|
|
103
|
+
deck.childrenMap = doc.childrenMap;
|
|
104
|
+
const name = opts.name ?? 'Untitled';
|
|
105
|
+
deck.deckMeta = {
|
|
106
|
+
client_meta: {
|
|
107
|
+
background_color: { r: 0.11764705926179886, g: 0.11764705926179886, b: 0.11764705926179886, a: 1 },
|
|
108
|
+
thumbnail_size: { width: 400, height: 260 },
|
|
109
|
+
render_coordinates: { x: 0, y: 0, width: 2400, height: 1560 },
|
|
110
|
+
},
|
|
111
|
+
file_name: name,
|
|
112
|
+
developer_related_links: [],
|
|
113
|
+
exported_at: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
deck.deckThumbnail = createPlaceholderThumbnail();
|
|
116
|
+
return deck;
|
|
117
|
+
}
|
|
118
|
+
|
|
80
119
|
/**
|
|
81
120
|
* Load from a raw .fig file.
|
|
82
121
|
*/
|
|
@@ -247,6 +247,59 @@ function resolveLineHeight(lh, fontSize) {
|
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
// ── Text width approximation (Inter-like proportional metrics) ──────────────
|
|
251
|
+
//
|
|
252
|
+
// Used only when wrapping text in the fallback layout path (no derivedTextData,
|
|
253
|
+
// e.g. programmatically-generated text). Values are expressed as a fraction of
|
|
254
|
+
// fontSize and tuned for Inter Regular — close enough for word-wrap decisions.
|
|
255
|
+
const _NARROW = new Set('iIlt.,:;!|\'`"()[]{}/\\'.split(''));
|
|
256
|
+
const _WIDE = new Set('ABCDEFGHJKLNOPQRSTUVXYZmw'.split(''));
|
|
257
|
+
const _XWIDE = new Set('MW@%'.split(''));
|
|
258
|
+
function approxCharWidth(ch, fontSize) {
|
|
259
|
+
if (ch === ' ') return fontSize * 0.28;
|
|
260
|
+
if (_NARROW.has(ch)) return fontSize * 0.28;
|
|
261
|
+
if (_XWIDE.has(ch)) return fontSize * 0.85;
|
|
262
|
+
if (_WIDE.has(ch)) return fontSize * 0.66;
|
|
263
|
+
return fontSize * 0.52; // default for lowercase + digits + punctuation
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function approxTextWidth(str, fontSize, letterSpacingPx = 0) {
|
|
267
|
+
let w = 0;
|
|
268
|
+
for (let i = 0; i < str.length; i++) w += approxCharWidth(str[i], fontSize);
|
|
269
|
+
if (str.length > 1) w += letterSpacingPx * (str.length - 1);
|
|
270
|
+
return w;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Word-wrap a single line into multiple lines that each fit within maxWidth.
|
|
275
|
+
* Breaks on ASCII spaces only. If a single "word" overflows maxWidth it is
|
|
276
|
+
* placed on its own line (no mid-word break).
|
|
277
|
+
* @param {string} line
|
|
278
|
+
* @param {number} fontSize
|
|
279
|
+
* @param {number} maxWidth
|
|
280
|
+
* @param {number} letterSpacingPx
|
|
281
|
+
* @returns {string[]}
|
|
282
|
+
*/
|
|
283
|
+
function wrapLineByWidth(line, fontSize, maxWidth, letterSpacingPx = 0) {
|
|
284
|
+
if (!line) return [line];
|
|
285
|
+
if (!(maxWidth > 0)) return [line];
|
|
286
|
+
if (approxTextWidth(line, fontSize, letterSpacingPx) <= maxWidth) return [line];
|
|
287
|
+
const words = line.split(' ');
|
|
288
|
+
const out = [];
|
|
289
|
+
let cur = '';
|
|
290
|
+
for (const word of words) {
|
|
291
|
+
const candidate = cur ? cur + ' ' + word : word;
|
|
292
|
+
if (approxTextWidth(candidate, fontSize, letterSpacingPx) <= maxWidth || !cur) {
|
|
293
|
+
cur = candidate;
|
|
294
|
+
} else {
|
|
295
|
+
out.push(cur);
|
|
296
|
+
cur = word;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (cur) out.push(cur);
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
250
303
|
function esc(s) {
|
|
251
304
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
252
305
|
}
|
|
@@ -285,14 +338,19 @@ function isStaleLayout(chars, baselines, glyphs) {
|
|
|
285
338
|
* Auto-scales font size down when new text is longer than the original.
|
|
286
339
|
* Returns { tspans, fontSize } so the caller can use the adjusted size.
|
|
287
340
|
*/
|
|
288
|
-
function fallbackTextTspans(dispChars, fontSize, node) {
|
|
341
|
+
function fallbackTextTspans(dispChars, fontSize, node, letterSpacingPx = 0) {
|
|
289
342
|
const baselines = node.derivedTextData?.baselines;
|
|
290
|
-
const
|
|
343
|
+
const rawLines = dispChars.split('\n');
|
|
291
344
|
// Drop trailing empty line from trailing newline
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
// Use original baseline metrics for spacing (style property, still valid)
|
|
295
|
-
|
|
345
|
+
if (rawLines.length > 1 && !rawLines[rawLines.length - 1]) rawLines.pop();
|
|
346
|
+
|
|
347
|
+
// Use original baseline metrics for spacing (style property, still valid).
|
|
348
|
+
// When baselines are absent (programmatically-generated text), honour the
|
|
349
|
+
// node's own lineHeight (RAW/PERCENT/PIXELS) rather than falling back to
|
|
350
|
+
// a hard-coded 1.2× multiplier, so hand-authored lineHeight values render
|
|
351
|
+
// correctly. Ultimate default 1.25× per spec.
|
|
352
|
+
const lhFromNode = node.lineHeight ? resolveLineHeight(node.lineHeight, fontSize) : null;
|
|
353
|
+
let lineHeightPx = baselines?.[0]?.lineHeight ?? lhFromNode ?? fontSize * 1.25;
|
|
296
354
|
let lineAscent = baselines?.[0]?.lineAscent ?? fontSize * 0.8;
|
|
297
355
|
|
|
298
356
|
// Auto-fit: scale font size down when new text is longer than original.
|
|
@@ -303,7 +361,7 @@ function fallbackTextTspans(dispChars, fontSize, node) {
|
|
|
303
361
|
if (baselines?.length) {
|
|
304
362
|
const maxOrigCharsPerLine = Math.max(...baselines.map(b =>
|
|
305
363
|
Math.max(0, (b.endCharacter ?? 0) - (b.firstCharacter ?? 0))));
|
|
306
|
-
const maxNewCharsPerLine = Math.max(...
|
|
364
|
+
const maxNewCharsPerLine = Math.max(...rawLines.map(l => l.length));
|
|
307
365
|
|
|
308
366
|
// Scale down if widest new line has more chars than widest original line
|
|
309
367
|
if (maxNewCharsPerLine > maxOrigCharsPerLine && maxOrigCharsPerLine > 0) {
|
|
@@ -311,8 +369,8 @@ function fallbackTextTspans(dispChars, fontSize, node) {
|
|
|
311
369
|
}
|
|
312
370
|
|
|
313
371
|
// Also scale down if more lines than original (would overflow vertically)
|
|
314
|
-
if (
|
|
315
|
-
scale = Math.min(scale, baselines.length /
|
|
372
|
+
if (rawLines.length > baselines.length && nodeH > 0) {
|
|
373
|
+
scale = Math.min(scale, baselines.length / rawLines.length);
|
|
316
374
|
}
|
|
317
375
|
|
|
318
376
|
scale = Math.max(scale, 0.15); // don't shrink below 15%
|
|
@@ -321,6 +379,7 @@ function fallbackTextTspans(dispChars, fontSize, node) {
|
|
|
321
379
|
const adjFontSize = fontSize * scale;
|
|
322
380
|
const adjLineHeight = lineHeightPx * scale;
|
|
323
381
|
const adjLineAscent = lineAscent * scale;
|
|
382
|
+
const adjLetterSpacingPx = letterSpacingPx * scale;
|
|
324
383
|
|
|
325
384
|
// Horizontal alignment → text-anchor + x position
|
|
326
385
|
const align = node.textAlignHorizontal ?? 'LEFT';
|
|
@@ -336,35 +395,67 @@ function fallbackTextTspans(dispChars, fontSize, node) {
|
|
|
336
395
|
startY = baselines[0].position.y * scale;
|
|
337
396
|
} else {
|
|
338
397
|
startX = anchorX;
|
|
339
|
-
const totalH = lines.length * adjLineHeight;
|
|
340
|
-
const nodeH = node.size?.y ?? totalH;
|
|
341
398
|
const vAlign = node.textAlignVertical ?? 'TOP';
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
399
|
+
// Placeholder; recomputed below once we know the wrapped line count.
|
|
400
|
+
startY = adjLineAscent;
|
|
401
|
+
// Mark that we need to recompute after word-wrap based on final line count
|
|
402
|
+
// (kept here for readability — actual recompute below).
|
|
403
|
+
if (vAlign !== 'TOP') startY = null;
|
|
345
404
|
}
|
|
346
405
|
|
|
347
|
-
//
|
|
406
|
+
// Word-wrap each hard-newline-delimited line to fit within the node's width.
|
|
407
|
+
// Only wrap when the node has an explicit size.x and ≥1 hard line overflows.
|
|
408
|
+
// Preserves alignment via the outer text-anchor/startX logic.
|
|
409
|
+
const lineType0 = null;
|
|
348
410
|
const linesMeta = node.textData?.lines ?? [];
|
|
411
|
+
// Interpret each rawLine and wrap — track (text, metaIndex) for each output line
|
|
412
|
+
const wrapped = []; // array of { text, metaIndex }
|
|
413
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
414
|
+
const rawLine = rawLines[i];
|
|
415
|
+
// Reserve horizontal space consumed by list marker + indent on this meta line
|
|
416
|
+
const meta = linesMeta[i];
|
|
417
|
+
const indent = (meta?.indentationLevel ?? 0) * adjFontSize * 0.8;
|
|
418
|
+
const effectiveMaxW = Math.max(0, nodeW - indent);
|
|
419
|
+
const pieces = wrapLineByWidth(rawLine, adjFontSize, effectiveMaxW, adjLetterSpacingPx);
|
|
420
|
+
for (const piece of pieces) wrapped.push({ text: piece, metaIndex: i });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Recompute startY for non-TOP vertical alignment now that we know final line count
|
|
424
|
+
if (startY === null) {
|
|
425
|
+
const totalH = wrapped.length * adjLineHeight;
|
|
426
|
+
const nodeHH = node.size?.y ?? totalH;
|
|
427
|
+
const vAlign = node.textAlignVertical ?? 'TOP';
|
|
428
|
+
startY = vAlign === 'CENTER' ? (nodeHH - totalH) / 2 + adjLineAscent
|
|
429
|
+
: vAlign === 'BOTTOM' ? nodeHH - totalH + adjLineAscent
|
|
430
|
+
: adjLineAscent;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// List marker support: read lineType from textData.lines. Only apply marker
|
|
434
|
+
// to the first visual line of a wrapped paragraph.
|
|
349
435
|
let orderedCounter = 0;
|
|
436
|
+
let lastMetaIdx = -1;
|
|
350
437
|
|
|
351
|
-
const tspans =
|
|
438
|
+
const tspans = wrapped.map(({ text, metaIndex }, i) => {
|
|
352
439
|
const y = startY + i * adjLineHeight;
|
|
353
|
-
const meta = linesMeta[
|
|
440
|
+
const meta = linesMeta[metaIndex];
|
|
354
441
|
const lineType = meta?.lineType;
|
|
355
442
|
const indent = (meta?.indentationLevel ?? 0) * adjFontSize * 0.8;
|
|
356
443
|
let prefix = '';
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
444
|
+
const isFirstVisualOfMeta = metaIndex !== lastMetaIdx;
|
|
445
|
+
if (isFirstVisualOfMeta) {
|
|
446
|
+
if (lineType === 'UNORDERED_LIST') {
|
|
447
|
+
prefix = '\u2022 '; // bullet •
|
|
448
|
+
orderedCounter = 0;
|
|
449
|
+
} else if (lineType === 'ORDERED_LIST') {
|
|
450
|
+
orderedCounter++;
|
|
451
|
+
prefix = `${orderedCounter}. `;
|
|
452
|
+
} else {
|
|
453
|
+
orderedCounter = 0;
|
|
454
|
+
}
|
|
455
|
+
lastMetaIdx = metaIndex;
|
|
365
456
|
}
|
|
366
457
|
const x = startX + indent;
|
|
367
|
-
return `<tspan x="${x.toFixed(2)}" y="${y.toFixed(2)}" text-anchor="${anchor}">${esc(prefix +
|
|
458
|
+
return `<tspan x="${x.toFixed(2)}" y="${y.toFixed(2)}" text-anchor="${anchor}">${esc(prefix + text) || ' '}</tspan>`;
|
|
368
459
|
}).join('');
|
|
369
460
|
|
|
370
461
|
return { tspans, fontSize: adjFontSize };
|
|
@@ -464,7 +555,7 @@ function renderText(deck, node) {
|
|
|
464
555
|
// Fallback: derivedTextData is stale (text was edited outside Figma).
|
|
465
556
|
// Emit line-level tspans and let resvg handle per-glyph layout.
|
|
466
557
|
const origFontSize = fontSize;
|
|
467
|
-
const fb = fallbackTextTspans(dispChars, fontSize, node);
|
|
558
|
+
const fb = fallbackTextTspans(dispChars, fontSize, node, letterSpacingPx);
|
|
468
559
|
tspans = fb.tspans;
|
|
469
560
|
fontSize = fb.fontSize; // auto-fit may have scaled down
|
|
470
561
|
if (origFontSize > 0) letterSpacingPx *= fontSize / origFontSize;
|
|
@@ -851,11 +942,27 @@ function renderVector(deck, node) {
|
|
|
851
942
|
const strokeColor = resolveFill(node.strokePaints);
|
|
852
943
|
const sw = node.strokeWeight ?? 0;
|
|
853
944
|
const strokeAlign = node.strokeAlign ?? 'CENTER';
|
|
945
|
+
const dashAttr = Array.isArray(node.dashPattern) && node.dashPattern.length
|
|
946
|
+
? ` stroke-dasharray="${node.dashPattern.join(' ')}"`
|
|
947
|
+
: '';
|
|
948
|
+
const lineCap = node.strokeCap === 'ROUND'
|
|
949
|
+
? 'round'
|
|
950
|
+
: node.strokeCap === 'SQUARE'
|
|
951
|
+
? 'square'
|
|
952
|
+
: 'butt';
|
|
953
|
+
const lineJoin = node.strokeJoin === 'ROUND'
|
|
954
|
+
? 'round'
|
|
955
|
+
: node.strokeJoin === 'BEVEL'
|
|
956
|
+
? 'bevel'
|
|
957
|
+
: 'miter';
|
|
854
958
|
|
|
855
959
|
if (strokeAlign === 'OUTSIDE' && strokeColor && sw > 0 && fillEntries.length) {
|
|
856
960
|
// Stroke layer underneath (2× width, centered = sw outside + sw inside)
|
|
857
961
|
for (const { d, rule } of fillEntries) {
|
|
858
|
-
parts.push(
|
|
962
|
+
parts.push(
|
|
963
|
+
`<path d="${d}" fill="none" stroke="${strokeColor}" stroke-width="${sw * 2}" ` +
|
|
964
|
+
`stroke-linecap="${lineCap}" stroke-linejoin="${lineJoin}"${dashAttr}${rule}/>`
|
|
965
|
+
);
|
|
859
966
|
}
|
|
860
967
|
// Fill layer on top — covers inner half of stroke
|
|
861
968
|
for (const { d, color, rule } of fillEntries) {
|
|
@@ -886,8 +993,15 @@ function renderVector(deck, node) {
|
|
|
886
993
|
if (!parts.length && node.vectorData?.vectorNetworkBlob != null && blobs) {
|
|
887
994
|
const vnbD = decodeVnb(blobs, node.vectorData.vectorNetworkBlob, node.vectorData.normalizedSize, node.size);
|
|
888
995
|
if (vnbD) {
|
|
889
|
-
|
|
890
|
-
|
|
996
|
+
if (!fillColor && strokeColor && sw > 0) {
|
|
997
|
+
parts.push(
|
|
998
|
+
`<path d="${vnbD}" fill="none" stroke="${strokeColor}" stroke-width="${sw}" ` +
|
|
999
|
+
`stroke-linecap="${lineCap}" stroke-linejoin="${lineJoin}"${dashAttr}/>`
|
|
1000
|
+
);
|
|
1001
|
+
} else {
|
|
1002
|
+
const color = fillColor ?? strokeColor ?? '#000000';
|
|
1003
|
+
parts.push(`<path d="${vnbD}" fill="${color}" fill-rule="evenodd"/>`);
|
|
1004
|
+
}
|
|
891
1005
|
}
|
|
892
1006
|
}
|
|
893
1007
|
|
|
@@ -1030,6 +1144,17 @@ function decodeVnb(blobs, blobIdx, normalizedSize, nodeSize) {
|
|
|
1030
1144
|
segs.push({ sv, tsx, tsy, ev, tex, tey, type });
|
|
1031
1145
|
}
|
|
1032
1146
|
|
|
1147
|
+
const segToPathCmd = (seg, start, end) => {
|
|
1148
|
+
if (seg.type === 0) {
|
|
1149
|
+
return `L${f(end.x)},${f(end.y)}`;
|
|
1150
|
+
}
|
|
1151
|
+
const c1x = start.x + seg.tsx;
|
|
1152
|
+
const c1y = start.y + seg.tsy;
|
|
1153
|
+
const c2x = end.x + seg.tex;
|
|
1154
|
+
const c2y = end.y + seg.tey;
|
|
1155
|
+
return `C${f(c1x)},${f(c1y)} ${f(c2x)},${f(c2y)} ${f(end.x)},${f(end.y)}`;
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1033
1158
|
// Parse regions → build SVG paths
|
|
1034
1159
|
const cmds = [];
|
|
1035
1160
|
for (let r = 0; r < numRegions; r++) {
|
|
@@ -1049,23 +1174,38 @@ function decodeVnb(blobs, blobIdx, normalizedSize, nodeSize) {
|
|
|
1049
1174
|
|
|
1050
1175
|
if (s === 0) cmds.push(`M${f(start.x)},${f(start.y)}`);
|
|
1051
1176
|
|
|
1052
|
-
|
|
1053
|
-
// Line
|
|
1054
|
-
cmds.push(`L${f(end.x)},${f(end.y)}`);
|
|
1055
|
-
} else {
|
|
1056
|
-
// Cubic bezier — tangents are relative to their vertex
|
|
1057
|
-
const c1x = start.x + seg.tsx;
|
|
1058
|
-
const c1y = start.y + seg.tsy;
|
|
1059
|
-
const c2x = end.x + seg.tex;
|
|
1060
|
-
const c2y = end.y + seg.tey;
|
|
1061
|
-
cmds.push(`C${f(c1x)},${f(c1y)} ${f(c2x)},${f(c2y)} ${f(end.x)},${f(end.y)}`);
|
|
1062
|
-
}
|
|
1177
|
+
cmds.push(segToPathCmd(seg, start, end));
|
|
1063
1178
|
}
|
|
1064
1179
|
cmds.push('Z');
|
|
1065
1180
|
}
|
|
1066
1181
|
off += 4; // windingRule
|
|
1067
1182
|
}
|
|
1068
1183
|
|
|
1184
|
+
// Stroke-only vectors authored with addPath() intentionally omit regions.
|
|
1185
|
+
// Recover their centerline by walking the stored segments in order so they
|
|
1186
|
+
// render as strokes instead of falling through to the magenta placeholder.
|
|
1187
|
+
if (!cmds.length && segs.length) {
|
|
1188
|
+
let currentEnd = null;
|
|
1189
|
+
let subpathStart = null;
|
|
1190
|
+
for (const seg of segs) {
|
|
1191
|
+
const start = verts[seg.sv];
|
|
1192
|
+
const end = verts[seg.ev];
|
|
1193
|
+
if (!start || !end) continue;
|
|
1194
|
+
if (currentEnd == null || seg.sv !== currentEnd) {
|
|
1195
|
+
cmds.push(`M${f(start.x)},${f(start.y)}`);
|
|
1196
|
+
subpathStart = seg.sv;
|
|
1197
|
+
}
|
|
1198
|
+
cmds.push(segToPathCmd(seg, start, end));
|
|
1199
|
+
if (subpathStart != null && seg.ev === subpathStart) {
|
|
1200
|
+
cmds.push('Z');
|
|
1201
|
+
currentEnd = null;
|
|
1202
|
+
subpathStart = null;
|
|
1203
|
+
} else {
|
|
1204
|
+
currentEnd = seg.ev;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1069
1209
|
return cmds.length ? cmds.join('') : null;
|
|
1070
1210
|
}
|
|
1071
1211
|
|