openfig-cli 0.3.12 → 0.3.14

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
@@ -1,4 +1,6 @@
1
- <img src="assets/logo.webp" alt="OpenFig" width="320" />
1
+ <img src="assets/logo.jpg" alt="OpenFig" width="320" />
2
+
3
+ <a href="https://www.buymeacoffee.com/coenenrob9"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="40" /></a>
2
4
 
3
5
  Open tools for Figma files.
4
6
 
package/bin/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * OpenFig — Open-source tools for Figma .deck / .fig files.
3
+ * OpenFig — Open-source tools for Figma file parsing and rendering.
4
4
  *
5
5
  * Usage: openfig <command> [args...]
6
6
  *
@@ -36,7 +36,7 @@ const arg2 = process.argv[2];
36
36
  let command, rawArgs;
37
37
 
38
38
  if (!arg2 || arg2 === '--help' || arg2 === '-h') {
39
- console.log(`OpenFig — Open-source tools for Figma .deck / .fig files\n`);
39
+ console.log(`OpenFig — Open-source tools for Figma file parsing and rendering\n`);
40
40
  console.log('Usage: openfig <command> [args...]\n');
41
41
  console.log('Commands:');
42
42
  console.log(' export Export slides as images (PNG/JPG/WEBP)');
@@ -5,14 +5,14 @@
5
5
  * --template <slideId|name> --name <newName>
6
6
  * [--after <slideId>] [--set key=value ...] [--set-image key=path ...]
7
7
  */
8
- import { FigDeck } from '../lib/core/fig-deck.mjs';
9
- import { nid, parseId, positionChar } from '../lib/core/node-helpers.mjs';
10
- import { imageOv } from '../lib/core/image-helpers.mjs';
11
- import { deepClone } from '../lib/core/deep-clone.mjs';
8
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
9
+ import { nid, parseId, positionChar } from '../../lib/core/node-helpers.mjs';
10
+ import { imageOv } from '../../lib/core/image-helpers.mjs';
11
+ import { deepClone } from '../../lib/core/deep-clone.mjs';
12
12
  import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
13
13
  import { createHash } from 'crypto';
14
14
  import { join, resolve } from 'path';
15
- import { getImageDimensions, generateThumbnail } from '../lib/core/image-utils.mjs';
15
+ import { getImageDimensions, generateThumbnail } from '../../lib/core/image-utils.mjs';
16
16
 
17
17
  function sha1Hex(buf) {
18
18
  return createHash('sha1').update(buf).digest('hex');
@@ -16,9 +16,9 @@
16
16
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
17
17
  import { join, parse, resolve } from 'path';
18
18
  import { createInterface } from 'readline';
19
- import { FigDeck } from '../lib/core/fig-deck.mjs';
20
- import { renderDeck, registerFontDir } from '../lib/rasterizer/deck-rasterizer.mjs';
21
- import { resolveFonts } from '../lib/rasterizer/font-resolver.mjs';
19
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
20
+ import { renderDeck, registerFontDir } from '../../lib/rasterizer/deck-rasterizer.mjs';
21
+ import { resolveFonts } from '../../lib/rasterizer/font-resolver.mjs';
22
22
 
23
23
  async function confirmOverwrite(dir) {
24
24
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * Usage: node cli.mjs insert-image <file.deck> -o <output.deck> --slide <id|name> --key <overrideKey> --image <path.png> [--thumb <thumb.png>]
5
5
  */
6
- import { FigDeck } from '../lib/core/fig-deck.mjs';
7
- import { nid, parseId } from '../lib/core/node-helpers.mjs';
8
- import { imageOv } from '../lib/core/image-helpers.mjs';
6
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
7
+ import { nid, parseId } from '../../lib/core/node-helpers.mjs';
8
+ import { imageOv } from '../../lib/core/image-helpers.mjs';
9
9
  import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
10
10
  import { createHash } from 'crypto';
11
11
  import { join, resolve } from 'path';
12
- import { getImageDimensions, generateThumbnail } from '../lib/core/image-utils.mjs';
12
+ import { getImageDimensions, generateThumbnail } from '../../lib/core/image-utils.mjs';
13
13
 
14
14
  function sha1Hex(buf) {
15
15
  return createHash('sha1').update(buf).digest('hex');
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Usage: node cli.mjs inspect <file.deck|file.fig> [--depth N] [--type TYPE] [--json]
5
5
  */
6
- import { FigDeck } from '../lib/core/fig-deck.mjs';
7
- import { nid } from '../lib/core/node-helpers.mjs';
6
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
7
+ import { nid } from '../../lib/core/node-helpers.mjs';
8
8
 
9
9
  export async function run(args, flags) {
10
10
  const file = args[0];
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Usage: node cli.mjs list-overrides <file.deck> [--symbol NAME|ID]
5
5
  */
6
- import { FigDeck } from '../lib/core/fig-deck.mjs';
7
- import { nid } from '../lib/core/node-helpers.mjs';
6
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
7
+ import { nid } from '../../lib/core/node-helpers.mjs';
8
8
 
9
9
  export async function run(args, flags) {
10
10
  const file = args[0];
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Usage: node cli.mjs list-text <file.deck>
5
5
  */
6
- import { FigDeck } from '../lib/core/fig-deck.mjs';
7
- import { nid } from '../lib/core/node-helpers.mjs';
8
- import { hashToHex } from '../lib/core/image-helpers.mjs';
6
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
7
+ import { nid } from '../../lib/core/node-helpers.mjs';
8
+ import { hashToHex } from '../../lib/core/image-helpers.mjs';
9
9
 
10
10
  export async function run(args) {
11
11
  const file = args[0];
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Usage: node cli.mjs remove-slide <file.deck> -o <output.deck> --slide <id|name> [--slide ...]
5
5
  */
6
- import { FigDeck } from '../lib/core/fig-deck.mjs';
7
- import { nid, removeNode } from '../lib/core/node-helpers.mjs';
6
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
7
+ import { nid, removeNode } from '../../lib/core/node-helpers.mjs';
8
8
 
9
9
  export async function run(args, flags) {
10
10
  const file = args[0];
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Usage: node cli.mjs roundtrip <file.deck> -o <output.deck>
5
5
  */
6
- import { FigDeck } from '../lib/core/fig-deck.mjs';
6
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
7
7
 
8
8
  export async function run(args, flags) {
9
9
  const file = args[0];
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Usage: node cli.mjs update-text <file.deck> -o <output.deck> --slide <id|name> --set key=value [--set key=value ...]
5
5
  */
6
- import { FigDeck } from '../lib/core/fig-deck.mjs';
7
- import { nid, parseId } from '../lib/core/node-helpers.mjs';
6
+ import { FigDeck } from '../../lib/core/fig-deck.mjs';
7
+ import { nid, parseId } from '../../lib/core/node-helpers.mjs';
8
8
 
9
9
  export async function run(args, flags) {
10
10
  const file = args[0];
@@ -265,6 +265,105 @@ function esc(s) {
265
265
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
266
266
  }
267
267
 
268
+ /**
269
+ * Detect stale derivedTextData: text was edited outside Figma but glyph layout
270
+ * wasn't recomputed. Returns true when the layout clearly doesn't match the text.
271
+ */
272
+ function isStaleLayout(chars, baselines, glyphs) {
273
+ if (!chars) return false;
274
+ const len = chars.length;
275
+
276
+ if (baselines?.length) {
277
+ const lastEnd = baselines[baselines.length - 1]?.endCharacter;
278
+ // baselines endCharacter should match chars length (±1 for trailing newline)
279
+ if (lastEnd != null && Math.abs(lastEnd - len) > 1) return true;
280
+ }
281
+
282
+ if (glyphs?.length) {
283
+ const maxFirstChar = glyphs.reduce((max, g) =>
284
+ Math.max(max, g.firstCharacter ?? 0), 0);
285
+ if (maxFirstChar >= len) return true; // glyphs point beyond new text
286
+ if (len > maxFirstChar + 3) return true; // new text much longer than glyph coverage
287
+ }
288
+
289
+ return false;
290
+ }
291
+
292
+ /**
293
+ * Fallback text layout for stale derivedTextData.
294
+ * Emits line-level <tspan> elements and lets resvg handle per-glyph layout.
295
+ * Uses original baseline metrics (line height, ascent, position) where available
296
+ * since those are style-dependent, not content-dependent.
297
+ * Auto-scales font size down when new text is longer than the original.
298
+ * Returns { tspans, fontSize } so the caller can use the adjusted size.
299
+ */
300
+ function fallbackTextTspans(dispChars, fontSize, node) {
301
+ const baselines = node.derivedTextData?.baselines;
302
+ const lines = dispChars.split('\n');
303
+ // Drop trailing empty line from trailing newline
304
+ if (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
305
+
306
+ // Use original baseline metrics for spacing (style property, still valid)
307
+ let lineHeightPx = baselines?.[0]?.lineHeight ?? fontSize * 1.2;
308
+ let lineAscent = baselines?.[0]?.lineAscent ?? fontSize * 0.8;
309
+
310
+ // Auto-fit: scale font size down when new text is longer than original.
311
+ // Uses character count ratio per line as a proxy for width ratio.
312
+ const nodeW = node.size?.x ?? 1920;
313
+ const nodeH = node.size?.y ?? 0;
314
+ let scale = 1;
315
+ if (baselines?.length) {
316
+ const maxOrigCharsPerLine = Math.max(...baselines.map(b =>
317
+ Math.max(0, (b.endCharacter ?? 0) - (b.firstCharacter ?? 0))));
318
+ const maxNewCharsPerLine = Math.max(...lines.map(l => l.length));
319
+
320
+ // Scale down if widest new line has more chars than widest original line
321
+ if (maxNewCharsPerLine > maxOrigCharsPerLine && maxOrigCharsPerLine > 0) {
322
+ scale = Math.min(scale, maxOrigCharsPerLine / maxNewCharsPerLine);
323
+ }
324
+
325
+ // Also scale down if more lines than original (would overflow vertically)
326
+ if (lines.length > baselines.length && nodeH > 0) {
327
+ scale = Math.min(scale, baselines.length / lines.length);
328
+ }
329
+
330
+ scale = Math.max(scale, 0.15); // don't shrink below 15%
331
+ }
332
+
333
+ const adjFontSize = fontSize * scale;
334
+ const adjLineHeight = lineHeightPx * scale;
335
+ const adjLineAscent = lineAscent * scale;
336
+
337
+ // Horizontal alignment → text-anchor + x position
338
+ const align = node.textAlignHorizontal ?? 'LEFT';
339
+ const anchor = align === 'CENTER' ? 'middle' : align === 'RIGHT' ? 'end' : 'start';
340
+ const anchorX = align === 'CENTER' ? nodeW / 2 : align === 'RIGHT' ? nodeW : 0;
341
+
342
+ // Use first baseline position if available (preserves Figma's padding/offset),
343
+ // otherwise compute from vertical alignment
344
+ let startX, startY;
345
+ if (baselines?.[0]?.position) {
346
+ startX = align === 'LEFT' ? baselines[0].position.x : anchorX;
347
+ // Scale startY relative to the baseline position for auto-fit
348
+ startY = baselines[0].position.y * scale;
349
+ } else {
350
+ startX = anchorX;
351
+ const totalH = lines.length * adjLineHeight;
352
+ const nodeH = node.size?.y ?? totalH;
353
+ const vAlign = node.textAlignVertical ?? 'TOP';
354
+ startY = vAlign === 'CENTER' ? (nodeH - totalH) / 2 + adjLineAscent
355
+ : vAlign === 'BOTTOM' ? nodeH - totalH + adjLineAscent
356
+ : adjLineAscent;
357
+ }
358
+
359
+ const tspans = lines.map((line, i) => {
360
+ const y = startY + i * adjLineHeight;
361
+ return `<tspan x="${startX}" y="${y.toFixed(2)}" text-anchor="${anchor}">${esc(line) || ' '}</tspan>`;
362
+ }).join('');
363
+
364
+ return { tspans, fontSize: adjFontSize };
365
+ }
366
+
268
367
  function styleAttrsFromFontName(fontName, derivedFontWeight) {
269
368
  const style = fontName?.style ?? 'Regular';
270
369
  const weight = derivedFontWeight
@@ -300,12 +399,12 @@ function renderText(deck, node) {
300
399
  : node.textCase === 'LOWER' ? chars.toLowerCase()
301
400
  : chars;
302
401
 
303
- const fontSize = node.derivedTextData?.glyphs?.[0]?.fontSize ?? node.fontSize ?? 24;
402
+ let fontSize = node.derivedTextData?.glyphs?.[0]?.fontSize ?? node.fontSize ?? 24;
304
403
  const fontFamily = node.fontName?.family ?? 'Inter';
305
404
  const fill = resolveFill(getFillPaints(node)) ?? '#000000';
306
405
  // Letter spacing: PERCENT is % of fontSize, PIXELS is absolute
307
406
  const ls = node.letterSpacing;
308
- const letterSpacingPx = !ls ? 0
407
+ let letterSpacingPx = !ls ? 0
309
408
  : ls.units === 'PERCENT' ? (ls.value / 100) * fontSize
310
409
  : ls.units === 'PIXELS' ? ls.value
311
410
  : 0;
@@ -313,7 +412,8 @@ function renderText(deck, node) {
313
412
  const glyphs = node.derivedTextData?.glyphs;
314
413
  const styleIds = node.textData?.characterStyleIDs;
315
414
  const styleTable = node.textData?.styleOverrideTable;
316
- if (!glyphs?.length) {
415
+ const stale = isStaleLayout(chars, baselines, glyphs);
416
+ if (!glyphs?.length && !stale) {
317
417
  throw new Error(`TEXT ${node.name ?? nid(node)} is missing derived glyph layout`);
318
418
  }
319
419
 
@@ -354,7 +454,15 @@ function renderText(deck, node) {
354
454
  }
355
455
 
356
456
  let tspans = '';
357
- if (baselines?.length && glyphs?.length && styleIds?.length) {
457
+ if (stale || !glyphs?.length) {
458
+ // Fallback: derivedTextData is stale (text was edited outside Figma).
459
+ // Emit line-level tspans and let resvg handle per-glyph layout.
460
+ const origFontSize = fontSize;
461
+ const fb = fallbackTextTspans(dispChars, fontSize, node);
462
+ tspans = fb.tspans;
463
+ fontSize = fb.fontSize; // auto-fit may have scaled down
464
+ if (origFontSize > 0) letterSpacingPx *= fontSize / origFontSize;
465
+ } else if (baselines?.length && glyphs?.length && styleIds?.length) {
358
466
  // Mixed-style: group consecutive glyphs by styleID, emit per-glyph with ligature merge
359
467
  for (const b of baselines) {
360
468
  const lineGlyphs = glyphs.filter(g => g.firstCharacter >= b.firstCharacter && g.firstCharacter < b.endCharacter);
@@ -587,7 +695,8 @@ function renderShapeWithText(deck, node) {
587
695
  const truncationStartIndex = derivedText.truncationStartIndex >= 0
588
696
  ? derivedText.truncationStartIndex
589
697
  : null;
590
- if (!glyphs?.length) {
698
+ const swtStale = isStaleLayout(chars, derivedText.baselines, glyphs);
699
+ if (!glyphs?.length && !swtStale) {
591
700
  throw new Error(`SHAPE_WITH_TEXT ${node.name ?? nid(node)} is missing derived glyph layout`);
592
701
  }
593
702
 
@@ -602,36 +711,53 @@ function renderShapeWithText(deck, node) {
602
711
  const textFill = resolveFill(textOv.fillPaints) ?? '#000000';
603
712
 
604
713
  let tspan;
605
- function esc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
606
-
607
- const spans = [];
608
- for (let i = 0; i < glyphs.length; i++) {
609
- const g = glyphs[i];
610
- if (truncationStartIndex != null && g.firstCharacter != null && g.firstCharacter >= truncationStartIndex) continue;
611
-
612
- let slice = '';
613
- let stopAfter = false;
614
- if (g.firstCharacter == null) {
615
- if (truncationStartIndex == null) continue;
616
- slice = '…';
617
- stopAfter = true;
618
- } else {
619
- let nextChar = null;
620
- for (let j = i + 1; j < glyphs.length; j++) {
621
- const fc = glyphs[j].firstCharacter;
622
- if (fc != null && fc > g.firstCharacter) {
623
- nextChar = fc;
624
- break;
714
+
715
+ if (swtStale || !glyphs?.length) {
716
+ // Fallback: stale layout — center text in shape using textBox offset
717
+ const swtBaselines = derivedText.baselines;
718
+ const lineHeightPx = swtBaselines?.[0]?.lineHeight ?? fontSize * 1.2;
719
+ const lineAscent = swtBaselines?.[0]?.lineAscent ?? fontSize * 0.8;
720
+ const lines = dispChars.split('\n').filter((l, i, a) => i < a.length - 1 || l);
721
+ const totalH = lines.length * lineHeightPx;
722
+ // Center vertically in the text box area
723
+ const textBoxH = textDerived.size?.y ?? h;
724
+ const startY = textBoxY + (textBoxH - totalH) / 2 + lineAscent;
725
+ const textBoxW = textDerived.size?.x ?? w;
726
+ const cx = textBoxX + textBoxW / 2;
727
+ tspan = lines.map((line, i) => {
728
+ const y = startY + i * lineHeightPx;
729
+ return `<tspan x="${cx}" y="${y.toFixed(2)}" text-anchor="middle">${esc(line) || ' '}</tspan>`;
730
+ }).join('');
731
+ } else {
732
+ const spans = [];
733
+ for (let i = 0; i < glyphs.length; i++) {
734
+ const g = glyphs[i];
735
+ if (truncationStartIndex != null && g.firstCharacter != null && g.firstCharacter >= truncationStartIndex) continue;
736
+
737
+ let slice = '';
738
+ let stopAfter = false;
739
+ if (g.firstCharacter == null) {
740
+ if (truncationStartIndex == null) continue;
741
+ slice = '…';
742
+ stopAfter = true;
743
+ } else {
744
+ let nextChar = null;
745
+ for (let j = i + 1; j < glyphs.length; j++) {
746
+ const fc = glyphs[j].firstCharacter;
747
+ if (fc != null && fc > g.firstCharacter) {
748
+ nextChar = fc;
749
+ break;
750
+ }
625
751
  }
752
+ slice = dispChars.slice(g.firstCharacter, nextChar ?? (g.firstCharacter + 1));
626
753
  }
627
- slice = dispChars.slice(g.firstCharacter, nextChar ?? (g.firstCharacter + 1));
628
- }
629
754
 
630
- if (!slice) continue;
631
- spans.push(`<tspan x="${textBoxX + g.position.x}" y="${textBoxY + g.position.y}">${esc(slice)}</tspan>`);
632
- if (stopAfter) break;
755
+ if (!slice) continue;
756
+ spans.push(`<tspan x="${textBoxX + g.position.x}" y="${textBoxY + g.position.y}">${esc(slice)}</tspan>`);
757
+ if (stopAfter) break;
758
+ }
759
+ tspan = spans.join('');
633
760
  }
634
- tspan = spans.join('');
635
761
 
636
762
  const textSvg = [
637
763
  `<text font-size="${fontSize}" font-family="${fontFamily}, sans-serif"`,
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.2",
3
3
  "name": "openfig",
4
4
  "version": "0.3.11",
5
- "description": "Open-source tools for parsing and rendering Figma design files",
5
+ "description": "Open-source tools for Figma file parsing and rendering",
6
6
  "author": {
7
7
  "name": "OpenFig Contributors"
8
8
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openfig-cli",
3
- "version": "0.3.12",
4
- "description": "OpenFig — Open-source tools for parsing and rendering Figma design files",
3
+ "version": "0.3.14",
4
+ "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "openfig": "bin/cli.mjs",