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.
@@ -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.42",
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