openfig-cli 0.3.11

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/bin/cli.mjs +111 -0
  4. package/bin/commands/clone-slide.mjs +153 -0
  5. package/bin/commands/export.mjs +83 -0
  6. package/bin/commands/insert-image.mjs +90 -0
  7. package/bin/commands/inspect.mjs +91 -0
  8. package/bin/commands/list-overrides.mjs +66 -0
  9. package/bin/commands/list-text.mjs +60 -0
  10. package/bin/commands/remove-slide.mjs +47 -0
  11. package/bin/commands/roundtrip.mjs +37 -0
  12. package/bin/commands/update-text.mjs +79 -0
  13. package/lib/core/deep-clone.mjs +16 -0
  14. package/lib/core/fig-deck.mjs +332 -0
  15. package/lib/core/image-helpers.mjs +56 -0
  16. package/lib/core/image-utils.mjs +29 -0
  17. package/lib/core/node-helpers.mjs +49 -0
  18. package/lib/rasterizer/deck-rasterizer.mjs +233 -0
  19. package/lib/rasterizer/download-font.mjs +57 -0
  20. package/lib/rasterizer/font-resolver.mjs +602 -0
  21. package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
  22. package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
  23. package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
  24. package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
  25. package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
  26. package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
  27. package/lib/rasterizer/fonts/avenir-next-bold-italic.ttf +0 -0
  28. package/lib/rasterizer/fonts/avenir-next-bold.ttf +0 -0
  29. package/lib/rasterizer/fonts/avenir-next-demibold-italic.ttf +0 -0
  30. package/lib/rasterizer/fonts/avenir-next-demibold.ttf +0 -0
  31. package/lib/rasterizer/fonts/avenir-next-italic.ttf +0 -0
  32. package/lib/rasterizer/fonts/avenir-next-medium-italic.ttf +0 -0
  33. package/lib/rasterizer/fonts/avenir-next-medium.ttf +0 -0
  34. package/lib/rasterizer/fonts/avenir-next-regular.ttf +0 -0
  35. package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
  36. package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
  37. package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
  38. package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
  39. package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
  40. package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
  41. package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
  42. package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
  43. package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
  44. package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
  45. package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
  46. package/lib/rasterizer/render-report-lib.mjs +239 -0
  47. package/lib/rasterizer/render-report.mjs +25 -0
  48. package/lib/rasterizer/svg-builder.mjs +1328 -0
  49. package/lib/rasterizer/test-render.mjs +57 -0
  50. package/lib/slides/api.mjs +2100 -0
  51. package/lib/slides/blank-template.deck +0 -0
  52. package/lib/slides/template-deck.mjs +671 -0
  53. package/manifest.json +21 -0
  54. package/mcp-server.mjs +541 -0
  55. package/package.json +74 -0
@@ -0,0 +1,239 @@
1
+ import { writeFileSync, existsSync } from 'fs';
2
+ import sharp from 'sharp';
3
+ import { ssim } from 'ssim.js';
4
+ import { FigDeck } from '../core/fig-deck.mjs';
5
+ import { slideToSvg } from './svg-builder.mjs';
6
+ import { svgToPng } from './deck-rasterizer.mjs';
7
+
8
+ export const RENDER_W = 1920;
9
+ export const RENDER_H = 1080;
10
+ const THUMB_W = 800;
11
+
12
+ export async function toRgbaBuffer(source, width = RENDER_W, height = RENDER_H) {
13
+ const buf = await sharp(source)
14
+ .resize(width, height, { fit: 'fill' })
15
+ .ensureAlpha()
16
+ .raw()
17
+ .toBuffer();
18
+ return { data: new Uint8ClampedArray(buf.buffer, buf.byteOffset, buf.byteLength), width, height };
19
+ }
20
+
21
+ export async function computeSsim(rendered, refPath, width, height) {
22
+ // Auto-detect from rendered image when no explicit size given —
23
+ // downscale reference to match render, never upscale render
24
+ if (width == null || height == null) {
25
+ const meta = await sharp(rendered).metadata();
26
+ width = meta.width;
27
+ height = meta.height;
28
+ }
29
+ const [a, b] = await Promise.all([
30
+ toRgbaBuffer(rendered, width, height),
31
+ toRgbaBuffer(refPath, width, height),
32
+ ]);
33
+ const { mssim } = ssim(a, b);
34
+ return mssim;
35
+ }
36
+
37
+ async function pngToDataUri(buf) {
38
+ const thumb = await sharp(buf).resize(THUMB_W, null, { fit: 'inside', withoutEnlargement: true }).png().toBuffer();
39
+ return `data:image/png;base64,${thumb.toString('base64')}`;
40
+ }
41
+
42
+ async function refToDataUri(refPath) {
43
+ const thumb = await sharp(refPath).resize(THUMB_W, null, { fit: 'inside', withoutEnlargement: true }).png().toBuffer();
44
+ return `data:image/png;base64,${thumb.toString('base64')}`;
45
+ }
46
+
47
+ const DIFF_THRESHOLD = 10; // max channel delta to count as "off"
48
+
49
+ /** Composite reference + inverted render at 50% to produce a diff overlay PNG buffer.
50
+ * Also counts pixels where any channel differs by more than DIFF_THRESHOLD.
51
+ * Comparison is done at the rendered image's native resolution — the reference is
52
+ * downscaled to match (consistent with SSIM). This avoids upscale-blur artefacts
53
+ * when the reference was exported at a higher DPI than the render. */
54
+ async function buildOverlayPng(renderedPng, refPath) {
55
+ const renMeta = await sharp(renderedPng).metadata();
56
+ const ow = renMeta.width, oh = renMeta.height;
57
+ const [refRaw, renRaw] = await Promise.all([
58
+ sharp(refPath).resize(ow, oh, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
59
+ sharp(renderedPng).resize(ow, oh, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
60
+ ]);
61
+ const { width, height } = refRaw.info;
62
+ const totalPixels = width * height;
63
+ const out = Buffer.alloc(totalPixels * 4);
64
+ let offCount = 0;
65
+ let deltaSum = 0;
66
+ let offDeltaSum = 0;
67
+ for (let p = 0; p < totalPixels; p++) {
68
+ const o = p * 4;
69
+ const dr = Math.abs(refRaw.data[o] - renRaw.data[o]);
70
+ const dg = Math.abs(refRaw.data[o+1] - renRaw.data[o+1]);
71
+ const db = Math.abs(refRaw.data[o+2] - renRaw.data[o+2]);
72
+ const maxD = Math.max(dr, dg, db);
73
+ if (maxD > DIFF_THRESHOLD) { offCount++; offDeltaSum += maxD; }
74
+ deltaSum += maxD;
75
+ out[o] = Math.round(refRaw.data[o] * 0.5 + (255 - renRaw.data[o]) * 0.5);
76
+ out[o+1] = Math.round(refRaw.data[o+1] * 0.5 + (255 - renRaw.data[o+1]) * 0.5);
77
+ out[o+2] = Math.round(refRaw.data[o+2] * 0.5 + (255 - renRaw.data[o+2]) * 0.5);
78
+ out[o+3] = 255;
79
+ }
80
+ const pngBuf = await sharp(out, { raw: { width, height, channels: 4 } }).png().toBuffer();
81
+ const offPct = (offCount / totalPixels * 100).toFixed(2);
82
+ // meanDelta: average max-channel deviation per pixel (0–255 scale).
83
+ const meanDelta = +(deltaSum / totalPixels).toFixed(2);
84
+ // offDelta: average severity among off pixels only (0–255 scale).
85
+ // Subpixel shift ≈ 12, missing content ≈ 150+.
86
+ const offDelta = offCount > 0 ? +(offDeltaSum / offCount).toFixed(1) : 0;
87
+ return { pngBuf, offCount, offPct, totalPixels, meanDelta, offDelta };
88
+ }
89
+
90
+ export async function buildReportRow({ slideNumber, renderedPng, refPath, score, scoreStr, notes }) {
91
+ const renderBuf = Buffer.from(renderedPng);
92
+ const renderUri = await pngToDataUri(renderBuf);
93
+ const renderMeta = await sharp(renderBuf).metadata();
94
+ const renderDims = `${renderMeta.width} × ${renderMeta.height}`;
95
+ let refUri = null;
96
+ let refDims = null;
97
+ let overlayUri = null;
98
+ let offCount = 0;
99
+ let offPct = '0.00';
100
+ let meanDelta = 0;
101
+ let offDelta = 0;
102
+ let resolvedScoreStr = scoreStr ?? '—';
103
+
104
+ if (refPath && existsSync(refPath)) {
105
+ refUri = await refToDataUri(refPath);
106
+ const refMeta = await sharp(refPath).metadata();
107
+ refDims = `${refMeta.width} × ${refMeta.height}`;
108
+ if (typeof score === 'number') {
109
+ resolvedScoreStr = score.toFixed(4);
110
+ } else if (scoreStr == null) {
111
+ const computedScore = await computeSsim(renderBuf, refPath);
112
+ resolvedScoreStr = computedScore.toFixed(4);
113
+ }
114
+ // Build flattened overlay + pixel diff count
115
+ const overlay = await buildOverlayPng(renderBuf, refPath);
116
+ overlayUri = await pngToDataUri(overlay.pngBuf);
117
+ offCount = overlay.offCount;
118
+ offPct = overlay.offPct;
119
+ meanDelta = overlay.meanDelta;
120
+ offDelta = overlay.offDelta;
121
+ }
122
+
123
+ return { n: slideNumber, scoreStr: resolvedScoreStr, renderUri, renderDims, refUri, refDims, overlayUri, offCount, offPct, meanDelta, offDelta, notes: notes ?? null };
124
+ }
125
+
126
+ export function writeRenderReport({ outHtml, rows, title = 'OpenFig Render Report' }) {
127
+ const html = `<!DOCTYPE html>
128
+ <html lang="en">
129
+ <head>
130
+ <meta charset="utf-8"/>
131
+ <title>${title}</title>
132
+ <style>
133
+ *{margin:0;padding:0;box-sizing:border-box}
134
+ body{font-family:system-ui,sans-serif;background:#111;color:#eee;padding:20px}
135
+ h1{font-size:1.2rem;margin:0 0 4px;color:#ccc}
136
+ .meta{color:#888;margin-bottom:24px;font-size:14px}
137
+ .slide-block{margin-bottom:40px;background:#1a1a1a;border-radius:8px;padding:16px}
138
+ .slide-block h2{font-size:16px;margin-bottom:12px;border-bottom:1px solid #333;padding-bottom:6px}
139
+ .slide-row{display:flex;gap:12px;align-items:flex-start}
140
+ .panel{min-width:0}
141
+ .panel label{display:block;font-size:12px;color:#888;margin-bottom:4px}
142
+ .panel img{max-width:100%;display:block;border:1px solid #333;border-radius:4px;cursor:zoom-in}
143
+ .dims{font-size:11px;color:#666;text-align:center;margin-top:4px;font-variant-numeric:tabular-nums}
144
+ .badge{float:right;font-size:14px;padding:2px 10px;border-radius:4px;font-weight:bold}
145
+ .badge.pass{background:#2d5;color:#000}
146
+ .badge.warn{background:#fa0;color:#000}
147
+ .badge.fail{background:#f44;color:#fff}
148
+ .metrics{margin-top:8px;font-size:12px;font-variant-numeric:tabular-nums}
149
+ .metrics td{padding:1px 8px 1px 0}
150
+ .metrics .label{color:#888}
151
+ .metrics .g{color:#6f6}
152
+ .metrics .y{color:#fa0}
153
+ .metrics .r{color:#f66}
154
+ .notes{margin-top:8px;font-size:12px;color:#999;line-height:1.4;border-left:2px solid #444;padding-left:8px}
155
+ .lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.92);z-index:999;cursor:zoom-out;justify-content:center;align-items:center}
156
+ .lightbox.active{display:flex}
157
+ .lightbox img{max-width:95vw;max-height:95vh;object-fit:contain}
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <div class="lightbox" id="lb" onclick="this.classList.remove('active')"><img id="lb-img"></div>
162
+ <script>
163
+ document.addEventListener("click",function(e){
164
+ var t=e.target;
165
+ if(t.tagName!=="IMG"||!t.closest(".panel"))return;
166
+ e.stopPropagation();
167
+ document.getElementById("lb-img").src=t.src;
168
+ document.getElementById("lb").classList.add("active");
169
+ });
170
+ </script>
171
+ <h1>${title}</h1>
172
+ <p class="meta">${new Date().toISOString().slice(0,16).replace('T',' ')}</p>
173
+ ${rows.map(({ n, scoreStr, renderUri, renderDims, refUri, refDims, overlayUri, offCount, offPct, meanDelta, offDelta, notes }) => {
174
+ const ssim = parseFloat(scoreStr);
175
+ const hasRef = !isNaN(ssim);
176
+ // Visual diff: mean delta as % of max — correlates with human perception
177
+ const visualDiff = (meanDelta / 255 * 100);
178
+ // Three-tier badge: PASS (≥0.99), WARN (≥0.90), FAIL (<0.90)
179
+ const tier = !hasRef ? '' : ssim >= 0.99 ? 'pass' : ssim >= 0.90 ? 'warn' : 'fail';
180
+ const tierLabel = tier === 'pass' ? 'PASS' : tier === 'warn' ? 'WARN' : 'FAIL';
181
+ const badgeHtml = !hasRef ? '' : `<span class="badge ${tier}">${tierLabel}</span>`;
182
+ const ssimCls = !hasRef ? '' : ssim >= 0.99 ? 'g' : ssim >= 0.90 ? 'y' : 'r';
183
+ const diffCls = visualDiff <= 0.5 ? 'g' : visualDiff <= 2.0 ? 'y' : 'r';
184
+ const metricsHtml = !hasRef ? '' : `<table class="metrics">
185
+ <tr><td class="label">SSIM</td><td class="${ssimCls}">${scoreStr}</td></tr>
186
+ <tr><td class="label">Pixels off</td><td>${offCount.toLocaleString()} (${offPct}%)</td></tr>
187
+ <tr><td class="label">Visual diff</td><td class="${diffCls}">${visualDiff.toFixed(2)}%</td></tr>
188
+ </table>`;
189
+ return `
190
+ <div class="slide-block">
191
+ <h2>Slide ${n} ${badgeHtml}</h2>
192
+ <div class="slide-row">
193
+ <div class="panel">
194
+ <label>Reference (Figma)</label>
195
+ ${refUri ? `<img src="${refUri}" alt="reference ${n}"/>` : '<em style="color:#555">no reference</em>'}
196
+ ${refDims ? `<div class="dims">${refDims} px</div>` : ''}
197
+ </div>
198
+ <div class="panel">
199
+ <label>OpenFig Render</label>
200
+ <img src="${renderUri}" alt="rendered ${n}"/>
201
+ <div class="dims">${renderDims} px</div>
202
+ </div>
203
+ <div class="panel">
204
+ <label>Overlay</label>
205
+ ${overlayUri ? `<img src="${overlayUri}" alt="overlay ${n}"/>` : '<em style="color:#555">no overlay</em>'}
206
+ ${metricsHtml}
207
+ ${notes ? `<div class="notes">${notes}</div>` : ''}
208
+ </div>
209
+ </div>
210
+ </div>`;
211
+ }).join('')}
212
+ </body>
213
+ </html>`;
214
+
215
+ writeFileSync(outHtml, html);
216
+ }
217
+
218
+ export async function generateRenderReportFromDeck({ deckPath, refDir, outHtml, title = 'OpenFig Render Report', log = console.log }) {
219
+ log('Loading deck…');
220
+ const deck = await FigDeck.fromDeckFile(deckPath);
221
+ const slides = deck.getActiveSlides();
222
+ log(`${slides.length} slides`);
223
+
224
+ const rows = [];
225
+ for (let n = 1; n <= slides.length; n++) {
226
+ const refPath = `${refDir}/page-${n}.png`;
227
+ const slide = deck.getSlide(n);
228
+
229
+ process.stdout.write(` Rendering slide ${n}… `);
230
+ const svg = slideToSvg(deck, slide);
231
+ const png = await svgToPng(svg, {});
232
+ const row = await buildReportRow({ slideNumber: n, renderedPng: Buffer.from(png), refPath });
233
+ rows.push(row);
234
+ process.stdout.write(row.scoreStr === '—' ? 'SSIM=—' : `SSIM=${row.scoreStr}`);
235
+ process.stdout.write('\n');
236
+ }
237
+
238
+ writeRenderReport({ outHtml, rows, title });
239
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate an HTML visual comparison report: reference vs rendered, side-by-side.
4
+ *
5
+ * Usage:
6
+ * node lib/rasterizer/render-report.mjs [file.deck] [ref-dir] [output.html]
7
+ *
8
+ * Defaults:
9
+ * deck = test/fixtures/decks/reference/oil-machinations.deck
10
+ * ref-dir = test/fixtures/decks/reference/oil-machinations/
11
+ * output = /tmp/openfig-render-report.html
12
+ */
13
+
14
+ import { join, dirname, resolve } from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import { generateRenderReportFromDeck } from './render-report-lib.mjs';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const [,, deckArg, refDirArg, outArg] = process.argv;
21
+ const DECK_PATH = resolve(deckArg ?? join(__dirname, '../../test/fixtures/decks/reference/oil-machinations.deck'));
22
+ const REF_DIR = resolve(refDirArg ?? join(__dirname, '../../test/fixtures/decks/reference/oil-machinations'));
23
+ const OUT_HTML = outArg ?? '/tmp/openfig-render-report.html';
24
+ await generateRenderReportFromDeck({ deckPath: DECK_PATH, refDir: REF_DIR, outHtml: OUT_HTML });
25
+ console.log(`\nReport → ${OUT_HTML}`);