openfig-cli 0.3.42 → 0.4.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/README.md +38 -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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { gunzipSync } from 'zlib';
|
|
4
|
+
import { parse as parseHtml } from 'node-html-parser';
|
|
5
|
+
import { convertHandoffBundle } from './handoff-converter.mjs';
|
|
6
|
+
import { withChromiumPage } from './playwright-layout.mjs';
|
|
7
|
+
import { extractSlides } from './browser-extract.mjs';
|
|
8
|
+
|
|
9
|
+
const CANVAS_W = 1920;
|
|
10
|
+
const CANVAS_H = 1080;
|
|
11
|
+
|
|
12
|
+
const MIME_EXT = {
|
|
13
|
+
'image/png': 'png',
|
|
14
|
+
'image/jpeg': 'jpg',
|
|
15
|
+
'image/svg+xml': 'svg',
|
|
16
|
+
'image/webp': 'webp',
|
|
17
|
+
'image/gif': 'gif',
|
|
18
|
+
'font/woff2': 'woff2',
|
|
19
|
+
'font/woff': 'woff',
|
|
20
|
+
'text/javascript': 'js',
|
|
21
|
+
'application/javascript': 'js',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function extractScriptTag(src, type) {
|
|
25
|
+
const re = new RegExp(`<script type="${type.replace(/\//g, '\\/')}">([\\s\\S]*?)<\\/script>`);
|
|
26
|
+
const m = src.match(re);
|
|
27
|
+
return m ? m[1] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function decodeAssets(manifest, mediaDir) {
|
|
31
|
+
mkdirSync(mediaDir, { recursive: true });
|
|
32
|
+
const map = {};
|
|
33
|
+
for (const [uuid, a] of Object.entries(manifest)) {
|
|
34
|
+
const ext = MIME_EXT[a.mime] ?? 'bin';
|
|
35
|
+
const fname = `${uuid}.${ext}`;
|
|
36
|
+
const outPath = join(mediaDir, fname);
|
|
37
|
+
let buf = Buffer.from(a.data, 'base64');
|
|
38
|
+
if (a.compressed) buf = gunzipSync(buf);
|
|
39
|
+
writeFileSync(outPath, buf);
|
|
40
|
+
map[uuid] = { mime: a.mime, path: outPath, filename: fname };
|
|
41
|
+
}
|
|
42
|
+
return map;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rewriteTemplateForBrowser(template, mediaMap) {
|
|
46
|
+
const doc = parseHtml(template, { lowerCaseTagName: false, comment: true });
|
|
47
|
+
for (const img of doc.querySelectorAll('img')) {
|
|
48
|
+
const src = img.getAttribute('src');
|
|
49
|
+
if (!src) continue;
|
|
50
|
+
const asset = mediaMap[src];
|
|
51
|
+
if (asset) img.setAttribute('src', `media/${asset.filename}`);
|
|
52
|
+
}
|
|
53
|
+
return doc.toString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Replace every `var(--name)` or `var(--name, fallback)` reference in `src`
|
|
57
|
+
// with the resolved value from `vars`. Applies repeatedly so nested var()
|
|
58
|
+
// indirection (e.g. `--brand: var(--accent)`) fully expands.
|
|
59
|
+
function resolveCssVars(src, vars) {
|
|
60
|
+
const VAR_RE = /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)/g;
|
|
61
|
+
let out = src;
|
|
62
|
+
for (let i = 0; i < 8; i++) {
|
|
63
|
+
let changed = false;
|
|
64
|
+
out = out.replace(VAR_RE, (_, name, fallback) => {
|
|
65
|
+
const v = vars[name];
|
|
66
|
+
if (v != null && v !== '') { changed = true; return v; }
|
|
67
|
+
if (fallback != null) { changed = true; return fallback.trim(); }
|
|
68
|
+
return `var(${name})`;
|
|
69
|
+
});
|
|
70
|
+
if (!changed) break;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseColor(v) {
|
|
76
|
+
if (!v) return undefined;
|
|
77
|
+
const s = String(v).trim();
|
|
78
|
+
if (s === 'transparent' || s === 'none') return undefined;
|
|
79
|
+
if (s.startsWith('#')) {
|
|
80
|
+
return s.length === 4
|
|
81
|
+
? '#' + [...s.slice(1)].map((c) => c + c).join('').toUpperCase()
|
|
82
|
+
: s.toUpperCase();
|
|
83
|
+
}
|
|
84
|
+
const m = s.match(/rgba?\(([^)]+)\)/);
|
|
85
|
+
if (m) {
|
|
86
|
+
const parts = m[1].split(',').map((t) => parseFloat(t.trim()));
|
|
87
|
+
const [r, g, b, a] = parts;
|
|
88
|
+
if (parts.length === 4 && a === 0) return undefined;
|
|
89
|
+
return '#' + [r, g, b].map((n) => Math.round(n).toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
90
|
+
}
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Tokens in a CSS font stack that Figma cannot resolve to a real typeface.
|
|
95
|
+
// Walking past them lets us pick the first portable fallback — e.g. for
|
|
96
|
+
// `-apple-system, system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif`
|
|
97
|
+
// we land on `Helvetica Neue`, whose metrics match Mac browser rendering
|
|
98
|
+
// far better than Figma's `-apple-system` → Inter substitution.
|
|
99
|
+
const NON_PORTABLE_FONT_TOKENS = new Set([
|
|
100
|
+
'blinkmacsystemfont',
|
|
101
|
+
'system-ui',
|
|
102
|
+
'ui-sans-serif',
|
|
103
|
+
'ui-serif',
|
|
104
|
+
'ui-monospace',
|
|
105
|
+
'ui-rounded',
|
|
106
|
+
'sans-serif',
|
|
107
|
+
'serif',
|
|
108
|
+
'monospace',
|
|
109
|
+
'cursive',
|
|
110
|
+
'fantasy',
|
|
111
|
+
'emoji',
|
|
112
|
+
'math',
|
|
113
|
+
'fangsong',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
function stripFontToken(raw) {
|
|
117
|
+
return String(raw).trim().replace(/^['"]|['"]$/g, '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isPortableFontToken(token) {
|
|
121
|
+
if (!token) return false;
|
|
122
|
+
if (token.startsWith('-')) return false; // -apple-system and vendor prefixes
|
|
123
|
+
return !NON_PORTABLE_FONT_TOKENS.has(token.toLowerCase());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeFont(family) {
|
|
127
|
+
if (!family) return undefined;
|
|
128
|
+
const entries = String(family).split(',').map(stripFontToken).filter(Boolean);
|
|
129
|
+
if (entries.length === 0) return undefined;
|
|
130
|
+
const portable = entries.find(isPortableFontToken);
|
|
131
|
+
return portable ?? entries[0];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeElement(el) {
|
|
135
|
+
if (!el) return null;
|
|
136
|
+
const out = { ...el };
|
|
137
|
+
out.x = Math.round(el.x ?? 0);
|
|
138
|
+
out.y = Math.round(el.y ?? 0);
|
|
139
|
+
if (typeof el.width === 'number') out.width = Math.round(el.width);
|
|
140
|
+
if (typeof el.height === 'number') out.height = Math.round(el.height);
|
|
141
|
+
|
|
142
|
+
if (el.type === 'text') {
|
|
143
|
+
out.color = parseColor(el.color);
|
|
144
|
+
out.font = normalizeFont(el.font);
|
|
145
|
+
if (el.size != null) out.size = Math.round(el.size * 100) / 100;
|
|
146
|
+
if (el.lineHeight != null) out.lineHeight = Math.round(el.lineHeight * 100) / 100;
|
|
147
|
+
if (el.letterSpacing != null) out.letterSpacing = Math.round(el.letterSpacing * 100) / 100;
|
|
148
|
+
if (el.noWrap) out.noWrap = true;
|
|
149
|
+
if (el.verticalAlign) out.verticalAlign = el.verticalAlign;
|
|
150
|
+
}
|
|
151
|
+
if (el.type === 'richText') {
|
|
152
|
+
out.color = parseColor(el.color);
|
|
153
|
+
out.font = normalizeFont(el.font);
|
|
154
|
+
if (el.size != null) out.size = Math.round(el.size * 100) / 100;
|
|
155
|
+
if (el.lineHeight != null) out.lineHeight = Math.round(el.lineHeight * 100) / 100;
|
|
156
|
+
if (el.letterSpacing != null) out.letterSpacing = Math.round(el.letterSpacing * 100) / 100;
|
|
157
|
+
if (el.verticalAlign) out.verticalAlign = el.verticalAlign;
|
|
158
|
+
if (Array.isArray(el.runs)) {
|
|
159
|
+
out.runs = el.runs.map((r) => {
|
|
160
|
+
const rr = { text: r.text };
|
|
161
|
+
if (r.color) rr.color = parseColor(r.color);
|
|
162
|
+
if (r.weight) rr.weight = r.weight;
|
|
163
|
+
if (r.style) rr.style = r.style;
|
|
164
|
+
return rr;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (el.type === 'rect' || el.type === 'ellipse') {
|
|
169
|
+
if (el.fill) out.fill = parseColor(el.fill);
|
|
170
|
+
if (el.stroke) out.stroke = parseColor(el.stroke);
|
|
171
|
+
if (el.strokeWidth != null) out.strokeWeight = el.strokeWidth;
|
|
172
|
+
if (Array.isArray(el.backgroundLayers) && el.backgroundLayers.length) {
|
|
173
|
+
out.backgroundLayers = el.backgroundLayers;
|
|
174
|
+
}
|
|
175
|
+
if (!out.fill && !out.stroke && !out.backgroundLayers) return null;
|
|
176
|
+
}
|
|
177
|
+
if (el.type === 'image' || el.type === 'rect' || el.type === 'ellipse') {
|
|
178
|
+
if (el.opacity != null) {
|
|
179
|
+
const op = parseFloat(el.opacity);
|
|
180
|
+
if (!Number.isNaN(op) && op < 1) out.opacity = op;
|
|
181
|
+
else delete out.opacity;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (el.type === 'layoutContainer') {
|
|
185
|
+
out.children = normalizeElements(el.children ?? []);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeElements(elements) {
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const el of elements) {
|
|
193
|
+
const n = normalizeElement(el);
|
|
194
|
+
if (n) out.push(n);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Fonts Figma Slides is expected to resolve by name at render time without
|
|
200
|
+
// substitution. This is the committed allowlist used by the font-unavailability
|
|
201
|
+
// audit (§fix-html-converter-figma-fidelity Phase 2 task 2.6). Keep the list
|
|
202
|
+
// small and conservative; extend via PR when a new font has been verified to
|
|
203
|
+
// render identically in Figma and Chromium.
|
|
204
|
+
//
|
|
205
|
+
// Match is case-insensitive and compares the first (primary) family name, so
|
|
206
|
+
// `'EB Garamond', Georgia, serif` is checked against `eb garamond` alone.
|
|
207
|
+
const FIGMA_DEFAULT_FONTS = new Set([
|
|
208
|
+
// Figma's own ship-default UI face
|
|
209
|
+
'inter',
|
|
210
|
+
// Widely-available system faces Figma resolves on macOS/Windows
|
|
211
|
+
'arial', 'helvetica', 'helvetica neue',
|
|
212
|
+
'times', 'times new roman',
|
|
213
|
+
'courier', 'courier new',
|
|
214
|
+
'georgia', 'verdana', 'tahoma', 'trebuchet ms',
|
|
215
|
+
'sf pro', 'sf pro display', 'sf pro text',
|
|
216
|
+
'menlo', 'monaco', 'consolas',
|
|
217
|
+
// Google Fonts that Figma loads in the standard picker
|
|
218
|
+
'roboto', 'roboto mono', 'roboto condensed', 'roboto slab',
|
|
219
|
+
'open sans', 'noto sans', 'noto serif',
|
|
220
|
+
'lato', 'montserrat', 'poppins', 'nunito',
|
|
221
|
+
'source sans pro', 'source serif pro', 'source code pro',
|
|
222
|
+
'source sans 3', 'source serif 4', 'source code pro',
|
|
223
|
+
'work sans', 'fira sans', 'fira code', 'fira mono',
|
|
224
|
+
'ibm plex sans', 'ibm plex serif', 'ibm plex mono',
|
|
225
|
+
'jetbrains mono',
|
|
226
|
+
// CSS-generic families — Figma treats them as system fallbacks
|
|
227
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
// Inventory distinct font names referenced by any emitted text / richText
|
|
231
|
+
// element. Returns an array of { name, slideIdx, sample } records for fonts
|
|
232
|
+
// outside FIGMA_DEFAULT_FONTS, one entry per distinct name (keyed on the
|
|
233
|
+
// lowercased primary family).
|
|
234
|
+
function auditFonts(manifest) {
|
|
235
|
+
const seen = new Map(); // lower → { name, slideIdx, sample }
|
|
236
|
+
for (const slide of manifest.slides) {
|
|
237
|
+
for (const el of slide.elements ?? []) {
|
|
238
|
+
if (el.type !== 'text' && el.type !== 'richText') continue;
|
|
239
|
+
const raw = el.font;
|
|
240
|
+
if (!raw) continue;
|
|
241
|
+
const key = String(raw).toLowerCase();
|
|
242
|
+
if (FIGMA_DEFAULT_FONTS.has(key)) continue;
|
|
243
|
+
if (seen.has(key)) continue;
|
|
244
|
+
const sampleText = el.text
|
|
245
|
+
? el.text.slice(0, 40)
|
|
246
|
+
: (el.runs ? el.runs.map(r => r.text).join('').slice(0, 40) : '');
|
|
247
|
+
seen.set(key, {
|
|
248
|
+
name: raw,
|
|
249
|
+
slideIdx: slide.index - 1,
|
|
250
|
+
sample: sampleText || `<${el.type}>`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [...seen.values()];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function createWarnCollector() {
|
|
258
|
+
const entries = new Map();
|
|
259
|
+
function warn(slideIdx, msg, sample) {
|
|
260
|
+
const key = `${slideIdx}\u0000${msg}`;
|
|
261
|
+
let e = entries.get(key);
|
|
262
|
+
if (!e) {
|
|
263
|
+
e = { slideIdx, msg, count: 0, sample: null };
|
|
264
|
+
entries.set(key, e);
|
|
265
|
+
}
|
|
266
|
+
e.count++;
|
|
267
|
+
if (sample && !e.sample) e.sample = sample;
|
|
268
|
+
}
|
|
269
|
+
function report() {
|
|
270
|
+
return [...entries.values()].sort((a, b) => a.slideIdx - b.slideIdx || b.count - a.count);
|
|
271
|
+
}
|
|
272
|
+
return { warn, report };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function convertStandaloneHtml(htmlPath, outDeckPath, opts = {}) {
|
|
276
|
+
const src = readFileSync(htmlPath, 'utf8');
|
|
277
|
+
|
|
278
|
+
const manifestRaw = extractScriptTag(src, '__bundler/manifest');
|
|
279
|
+
const templateRaw = extractScriptTag(src, '__bundler/template');
|
|
280
|
+
if (!manifestRaw || !templateRaw) {
|
|
281
|
+
throw new Error('html-converter: input is not a Claude Design standalone HTML (missing __bundler/manifest or /template)');
|
|
282
|
+
}
|
|
283
|
+
const assets = JSON.parse(manifestRaw);
|
|
284
|
+
const template = JSON.parse(templateRaw);
|
|
285
|
+
|
|
286
|
+
const scratch = opts.scratchDir ?? (outDeckPath.replace(/\.deck$/, '') + '-html-build');
|
|
287
|
+
mkdirSync(scratch, { recursive: true });
|
|
288
|
+
const mediaDir = join(scratch, 'media');
|
|
289
|
+
const mediaMap = decodeAssets(assets, mediaDir);
|
|
290
|
+
|
|
291
|
+
const nodeDoc = parseHtml(template, { lowerCaseTagName: false, comment: false });
|
|
292
|
+
const titleTag = nodeDoc.querySelector('title');
|
|
293
|
+
const title = opts.title ?? titleTag?.textContent?.trim() ?? 'Untitled';
|
|
294
|
+
let speakerNotes = [];
|
|
295
|
+
const snTag = nodeDoc.querySelector('script#speaker-notes');
|
|
296
|
+
if (snTag) {
|
|
297
|
+
try { speakerNotes = JSON.parse(snTag.textContent); } catch {}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const browserTemplate = rewriteTemplateForBrowser(template, mediaMap);
|
|
301
|
+
const browserHtmlPath = join(scratch, 'template.html');
|
|
302
|
+
writeFileSync(browserHtmlPath, browserTemplate);
|
|
303
|
+
|
|
304
|
+
const collector = createWarnCollector();
|
|
305
|
+
const raw = await withChromiumPage(
|
|
306
|
+
browserHtmlPath,
|
|
307
|
+
{ width: CANVAS_W, height: CANVAS_H },
|
|
308
|
+
(page) => extractSlides(page),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Chromium doesn't resolve var(--foo) references inside inline SVG
|
|
312
|
+
// attributes like fill="var(--accent)" — it leaves the literal string
|
|
313
|
+
// intact. getComputedStyle on the documentElement gave us the resolved
|
|
314
|
+
// values for every declared :root --* property (collected in raw.cssVars).
|
|
315
|
+
// Substitute those references back into the saved template.html so that
|
|
316
|
+
// when the handoff stage re-reads this file to pull out SVG markup, every
|
|
317
|
+
// color attribute is a plain resolvable value.
|
|
318
|
+
if (raw.cssVars && Object.keys(raw.cssVars).length > 0) {
|
|
319
|
+
const resolvedTemplate = resolveCssVars(browserTemplate, raw.cssVars);
|
|
320
|
+
if (resolvedTemplate !== browserTemplate) {
|
|
321
|
+
writeFileSync(browserHtmlPath, resolvedTemplate);
|
|
322
|
+
}
|
|
323
|
+
// The browser extractor captured svg.inline (outerHTML) before var()
|
|
324
|
+
// resolution happens on the template file, so inline markup may still
|
|
325
|
+
// contain raw var(--foo) references in fill/stroke attributes. Resolve
|
|
326
|
+
// them here so downstream SVG shape parsing sees concrete colors.
|
|
327
|
+
const resolveInlineVars = (els) => {
|
|
328
|
+
for (const el of els ?? []) {
|
|
329
|
+
if (el && typeof el.inline === 'string' && el.inline.includes('var(')) {
|
|
330
|
+
el.inline = resolveCssVars(el.inline, raw.cssVars);
|
|
331
|
+
}
|
|
332
|
+
if (el && Array.isArray(el.children)) resolveInlineVars(el.children);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
for (const s of raw.slides) resolveInlineVars(s.elements);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const manifestOut = {
|
|
339
|
+
title,
|
|
340
|
+
dimensions: { width: CANVAS_W, height: CANVAS_H },
|
|
341
|
+
slides: [],
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
for (const s of raw.slides) {
|
|
345
|
+
for (const w of s.warnings ?? []) {
|
|
346
|
+
collector.warn(s.index, w.msg, w.sample);
|
|
347
|
+
}
|
|
348
|
+
const slide = {
|
|
349
|
+
index: s.index + 1,
|
|
350
|
+
label: s.dataLabel || `Slide ${s.index + 1}`,
|
|
351
|
+
elements: normalizeElements(s.elements),
|
|
352
|
+
};
|
|
353
|
+
const bg = parseColor(s.background);
|
|
354
|
+
if (bg) slide.background = bg;
|
|
355
|
+
if (speakerNotes[s.index]) slide.speakerNotes = speakerNotes[s.index];
|
|
356
|
+
manifestOut.slides.push(slide);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Phase-2 font-resolution audit: warn once per font name that Figma is
|
|
360
|
+
// unlikely to resolve without substitution. See FIGMA_DEFAULT_FONTS above.
|
|
361
|
+
for (const f of auditFonts(manifestOut)) {
|
|
362
|
+
collector.warn(
|
|
363
|
+
f.slideIdx,
|
|
364
|
+
`font "${f.name}" likely not available in Figma — output may use a substitute; install the font locally in Figma before opening`,
|
|
365
|
+
f.sample,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const warnings = collector.report();
|
|
370
|
+
if (warnings.length && !opts.silent) {
|
|
371
|
+
const logger = opts.warnLogger || ((s) => process.stderr.write(s + '\n'));
|
|
372
|
+
logger(`\nconvert-html: ${warnings.length} warning type(s) across ${manifestOut.slides.length} slides:`);
|
|
373
|
+
for (const w of warnings) {
|
|
374
|
+
const where = w.slideIdx < 0
|
|
375
|
+
? '(css)'
|
|
376
|
+
: `slide ${w.slideIdx + 1} "${manifestOut.slides[w.slideIdx]?.label ?? w.slideIdx + 1}"`;
|
|
377
|
+
const times = w.count > 1 ? ` ×${w.count}` : '';
|
|
378
|
+
const sample = w.sample ? `\n e.g. ${w.sample}` : '';
|
|
379
|
+
logger(` [${where}]${times} ${w.msg}${sample}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
writeFileSync(join(scratch, 'manifest.json'), JSON.stringify(manifestOut, null, 2));
|
|
384
|
+
writeFileSync(join(scratch, 'warnings.json'), JSON.stringify(warnings, null, 2));
|
|
385
|
+
|
|
386
|
+
// Dry-run: skip the .deck emission. Returns the intermediate geometry so
|
|
387
|
+
// callers can inspect Chromium's wrap points without paying the full
|
|
388
|
+
// handoff-bundle conversion cost. Used by Phase 2 font-metric experiments.
|
|
389
|
+
if (opts.dryRun) {
|
|
390
|
+
return { manifest: manifestOut, warnings, scratchDir: scratch };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const result = await convertHandoffBundle(scratch, outDeckPath, { scratchDir: scratch, ...opts });
|
|
394
|
+
return { ...result, warnings };
|
|
395
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { chromium } from 'playwright-core';
|
|
2
|
+
import { pathToFileURL } from 'url';
|
|
3
|
+
|
|
4
|
+
async function tryLaunch(opts, label, errors) {
|
|
5
|
+
try {
|
|
6
|
+
return await chromium.launch({ headless: true, ...opts });
|
|
7
|
+
} catch (e) {
|
|
8
|
+
errors.push(`${label}: ${e.message.split('\n')[0]}`);
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function resolveBrowser() {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const viaChannel = await tryLaunch({ channel: 'chrome' }, "channel:'chrome'", errors);
|
|
16
|
+
if (viaChannel) return viaChannel;
|
|
17
|
+
|
|
18
|
+
const envPath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
|
19
|
+
if (envPath) {
|
|
20
|
+
const viaEnv = await tryLaunch({ executablePath: envPath }, `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=${envPath}`, errors);
|
|
21
|
+
if (viaEnv) return viaEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const viaCache = await tryLaunch({}, 'playwright default cache', errors);
|
|
25
|
+
if (viaCache) return viaCache;
|
|
26
|
+
|
|
27
|
+
throw new Error(
|
|
28
|
+
[
|
|
29
|
+
'openfig convert-html: no Chromium/Chrome executable is available.',
|
|
30
|
+
'',
|
|
31
|
+
'Tried:',
|
|
32
|
+
...errors.map((e) => ` - ${e}`),
|
|
33
|
+
'',
|
|
34
|
+
'To fix, do one of:',
|
|
35
|
+
' 1. Install Google Chrome: https://www.google.com/chrome/',
|
|
36
|
+
' 2. Run: npx playwright install --only-shell chromium',
|
|
37
|
+
' 3. Set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a Chromium/Chrome binary',
|
|
38
|
+
].join('\n'),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Preload the fonts declared by the page from Google Fonts so Playwright's
|
|
43
|
+
// text-box metrics match what Figma will render. Without this, a page whose
|
|
44
|
+
// stack is `Inter, -apple-system, ...` may fall back to -apple-system in
|
|
45
|
+
// headless Chromium (the source's bare @font-face urls often fail to load),
|
|
46
|
+
// producing narrower boxes that overflow when Figma renders the same text in
|
|
47
|
+
// real Inter. Scanning declared families lets us preload whatever the page
|
|
48
|
+
// actually uses — Roboto, Poppins, EB Garamond, etc. — not just Inter.
|
|
49
|
+
//
|
|
50
|
+
// Set OPENFIG_NO_FONT_PRELOAD=1 to skip (offline or airgapped CI).
|
|
51
|
+
async function collectDeclaredFontFamilies(page) {
|
|
52
|
+
return page.evaluate(() => {
|
|
53
|
+
const NON_PORTABLE = new Set([
|
|
54
|
+
'blinkmacsystemfont', 'system-ui',
|
|
55
|
+
'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded',
|
|
56
|
+
'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy',
|
|
57
|
+
'emoji', 'math', 'fangsong',
|
|
58
|
+
]);
|
|
59
|
+
function pickPortable(stack) {
|
|
60
|
+
if (!stack) return null;
|
|
61
|
+
const tokens = stack.split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
62
|
+
for (const t of tokens) {
|
|
63
|
+
if (t.startsWith('-')) continue;
|
|
64
|
+
if (NON_PORTABLE.has(t.toLowerCase())) continue;
|
|
65
|
+
return t;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const found = new Set();
|
|
70
|
+
const all = document.querySelectorAll('*');
|
|
71
|
+
for (const el of all) {
|
|
72
|
+
const picked = pickPortable(getComputedStyle(el).fontFamily);
|
|
73
|
+
if (picked) found.add(picked);
|
|
74
|
+
}
|
|
75
|
+
return [...found];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function googleFontsCssUrl(family) {
|
|
80
|
+
const enc = encodeURIComponent(family).replace(/%20/g, '+');
|
|
81
|
+
return `https://fonts.googleapis.com/css2?family=${enc}:wght@300;400;500;600;700;800;900&display=swap`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// When the source CSS stack is `-apple-system, system-ui, ...` with no
|
|
85
|
+
// explicit Inter, Chromium resolves to SF Pro (narrow metrics) while our
|
|
86
|
+
// handoff emits `font: Inter` via the portable-stack walk. Figma then
|
|
87
|
+
// renders the wider Inter glyphs into a box Chromium measured against SF
|
|
88
|
+
// Pro, and the text overflows — visible as a big section-number "11" that
|
|
89
|
+
// wraps to two stacked "1"s instead of fitting on one line.
|
|
90
|
+
//
|
|
91
|
+
// The fix: for every element whose computed fontFamily starts with a
|
|
92
|
+
// non-portable token (a system keyword Figma can't resolve), prepend
|
|
93
|
+
// "Inter" to the inline style so Chromium re-lays out with Inter. The
|
|
94
|
+
// page's CSS is untouched; only elements that would have resolved to a
|
|
95
|
+
// system font get nudged onto Inter. Explicit non-Inter families (EB
|
|
96
|
+
// Garamond, Roboto, etc.) land on their first portable token via the
|
|
97
|
+
// stack walk and are left alone.
|
|
98
|
+
async function reresolveSystemFontsToInter(page) {
|
|
99
|
+
await page.evaluate(() => {
|
|
100
|
+
const NON_PORTABLE = new Set([
|
|
101
|
+
'blinkmacsystemfont', 'system-ui',
|
|
102
|
+
'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded',
|
|
103
|
+
'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy',
|
|
104
|
+
'emoji', 'math', 'fangsong',
|
|
105
|
+
]);
|
|
106
|
+
function firstTokenIsNonPortable(stack) {
|
|
107
|
+
if (!stack) return false;
|
|
108
|
+
const first = stack.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
|
|
109
|
+
if (!first) return false;
|
|
110
|
+
if (first.startsWith('-')) return true;
|
|
111
|
+
return NON_PORTABLE.has(first.toLowerCase());
|
|
112
|
+
}
|
|
113
|
+
for (const el of document.querySelectorAll('*')) {
|
|
114
|
+
const stack = getComputedStyle(el).fontFamily;
|
|
115
|
+
if (firstTokenIsNonPortable(stack)) {
|
|
116
|
+
// setProperty with 'important' applies inline !important, which
|
|
117
|
+
// beats any stylesheet rule no matter how specific. Plain
|
|
118
|
+
// el.style.fontFamily = ... would lose to a page rule like
|
|
119
|
+
// `.cls { font-family: -apple-system !important }`.
|
|
120
|
+
el.style.setProperty('font-family', 'Inter, ' + stack, 'important');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function preloadMeasurementFonts(page) {
|
|
127
|
+
if (process.env.OPENFIG_NO_FONT_PRELOAD === '1') return;
|
|
128
|
+
let families;
|
|
129
|
+
try {
|
|
130
|
+
families = await collectDeclaredFontFamilies(page);
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// One addStyleTag per family so that a single family failing (e.g. a
|
|
135
|
+
// name Google Fonts doesn't host, or a weight variant the family lacks)
|
|
136
|
+
// doesn't block the others from loading.
|
|
137
|
+
await Promise.all(
|
|
138
|
+
families.map((family) =>
|
|
139
|
+
page.addStyleTag({ url: googleFontsCssUrl(family) }).catch(() => {}),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function withChromiumPage(htmlPath, viewport, fn) {
|
|
145
|
+
const browser = await resolveBrowser();
|
|
146
|
+
try {
|
|
147
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
|
|
148
|
+
const page = await context.newPage();
|
|
149
|
+
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'load' });
|
|
150
|
+
await preloadMeasurementFonts(page);
|
|
151
|
+
await page.evaluate(async () => {
|
|
152
|
+
if (document.fonts?.ready) {
|
|
153
|
+
try {
|
|
154
|
+
await document.fonts.ready;
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
await reresolveSystemFontsToInter(page);
|
|
159
|
+
await page.evaluate(async () => {
|
|
160
|
+
// Give layout one extra turn after fonts resolve and after we nudge
|
|
161
|
+
// system-font stacks onto Inter, so text metrics and wrapping settle
|
|
162
|
+
// before we snapshot geometry.
|
|
163
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
164
|
+
});
|
|
165
|
+
return await fn(page);
|
|
166
|
+
} finally {
|
|
167
|
+
await browser.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
package/mcp-server.mjs
CHANGED
|
@@ -266,6 +266,42 @@ server.tool(
|
|
|
266
266
|
}
|
|
267
267
|
);
|
|
268
268
|
|
|
269
|
+
// ── convert-html ────────────────────────────────────────────────────────
|
|
270
|
+
import { run as convertHtmlRun } from './bin/commands/convert-html.mjs';
|
|
271
|
+
|
|
272
|
+
server.tool(
|
|
273
|
+
'openfig_convert_html',
|
|
274
|
+
'Convert a Claude Design standalone HTML export into a .deck file',
|
|
275
|
+
{
|
|
276
|
+
path: z.string().describe('Path to input .html file (Claude Design standalone export)'),
|
|
277
|
+
output: z.string().describe('Output .deck path'),
|
|
278
|
+
title: z.string().optional().describe('Presentation name (default: inferred from <title>)'),
|
|
279
|
+
dryRun: z.boolean().optional().describe('Extract geometry only; skip .deck emission'),
|
|
280
|
+
},
|
|
281
|
+
async ({ path, output, title, dryRun }) => {
|
|
282
|
+
const logs = [];
|
|
283
|
+
const origLog = console.log;
|
|
284
|
+
const origError = console.error;
|
|
285
|
+
const origExit = process.exit;
|
|
286
|
+
console.log = (...a) => logs.push(a.join(' '));
|
|
287
|
+
console.error = (...a) => logs.push(a.join(' '));
|
|
288
|
+
process.exit = (code) => { throw new Error(`exit:${code}`); };
|
|
289
|
+
try {
|
|
290
|
+
const flags = { o: output };
|
|
291
|
+
if (title) flags.title = title;
|
|
292
|
+
if (dryRun) flags['dry-run'] = true;
|
|
293
|
+
await convertHtmlRun([path], flags);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (!e.message?.startsWith('exit:')) logs.push(`Error: ${e.message}`);
|
|
296
|
+
} finally {
|
|
297
|
+
console.log = origLog;
|
|
298
|
+
console.error = origError;
|
|
299
|
+
process.exit = origExit;
|
|
300
|
+
}
|
|
301
|
+
return { content: [{ type: 'text', text: logs.join('\n') }] };
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
|
|
269
305
|
// ── remove-slide ────────────────────────────────────────────────────────
|
|
270
306
|
server.tool(
|
|
271
307
|
'openfig_remove_slide',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openfig-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "OpenFig — Open-source tools for Figma file parsing and rendering",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,10 +44,13 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
46
46
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
47
|
+
"css": "^3.0.0",
|
|
47
48
|
"kiwi-schema": "^0.5.0",
|
|
49
|
+
"node-html-parser": "^7.1.0",
|
|
48
50
|
"openfig-core": "^0.3.5",
|
|
49
51
|
"pako": "^2.1.0",
|
|
50
52
|
"pdf-lib": "^1.17.1",
|
|
53
|
+
"playwright-core": "^1.59.1",
|
|
51
54
|
"sharp": "^0.34.5",
|
|
52
55
|
"ssim.js": "^3.5.0",
|
|
53
56
|
"yazl": "^3.3.1",
|
|
Binary file
|