openfig-cli 0.3.13 → 0.3.15
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 +1 -1
- package/lib/rasterizer/svg-builder.mjs +157 -31
- package/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<img src="assets/logo.
|
|
1
|
+
<img src="assets/logo.jpg" alt="OpenFig" width="320" />
|
|
2
2
|
|
|
3
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>
|
|
4
4
|
|
|
@@ -265,6 +265,105 @@ function esc(s) {
|
|
|
265
265
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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