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 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 | [docs/format/](docs/format/) ([canonical source](https://github.com/OpenFig-org/openfig-core/tree/main/docs)) |
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
+ }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 lines = dispChars.split('\n');
343
+ const rawLines = dispChars.split('\n');
291
344
  // Drop trailing empty line from trailing newline
292
- if (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
293
-
294
- // Use original baseline metrics for spacing (style property, still valid)
295
- let lineHeightPx = baselines?.[0]?.lineHeight ?? fontSize * 1.2;
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(...lines.map(l => l.length));
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 (lines.length > baselines.length && nodeH > 0) {
315
- scale = Math.min(scale, baselines.length / lines.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
- startY = vAlign === 'CENTER' ? (nodeH - totalH) / 2 + adjLineAscent
343
- : vAlign === 'BOTTOM' ? nodeH - totalH + adjLineAscent
344
- : adjLineAscent;
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
- // List marker support: read lineType from textData.lines
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 = lines.map((line, i) => {
438
+ const tspans = wrapped.map(({ text, metaIndex }, i) => {
352
439
  const y = startY + i * adjLineHeight;
353
- const meta = linesMeta[i];
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
- if (lineType === 'UNORDERED_LIST') {
358
- prefix = '\u2022 '; // bullet •
359
- orderedCounter = 0;
360
- } else if (lineType === 'ORDERED_LIST') {
361
- orderedCounter++;
362
- prefix = `${orderedCounter}. `;
363
- } else {
364
- orderedCounter = 0;
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 + line) || ' '}</tspan>`;
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(`<path d="${d}" fill="none" stroke="${strokeColor}" stroke-width="${sw * 2}" stroke-linejoin="miter"${rule}/>`);
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
- const color = fillColor ?? resolveFill(node.strokePaints) ?? '#000000';
890
- parts.push(`<path d="${vnbD}" fill="${color}" fill-rule="evenodd"/>`);
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
- if (seg.type === 0) {
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