openfig-cli 0.3.41 → 0.4.0
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 +4 -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,321 @@
|
|
|
1
|
+
import { mkdirSync } from 'fs';
|
|
2
|
+
import { Deck } from './api.mjs';
|
|
3
|
+
import { loadBundle } from './handoff/bundle-loader.mjs';
|
|
4
|
+
import { applyElement } from './handoff/element-dispatch.mjs';
|
|
5
|
+
|
|
6
|
+
function scopeScratchDir(outPath) {
|
|
7
|
+
const scratch = outPath.replace(/\.deck$/, '') + '-build';
|
|
8
|
+
mkdirSync(scratch, { recursive: true });
|
|
9
|
+
process.env.TMPDIR = scratch;
|
|
10
|
+
return scratch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isParagraphText(el) {
|
|
14
|
+
return !!el
|
|
15
|
+
&& (el.type === 'text' || el.type === 'richText')
|
|
16
|
+
&& !el.noWrap
|
|
17
|
+
&& typeof el.height === 'number'
|
|
18
|
+
&& el.height >= 80
|
|
19
|
+
&& typeof el.width === 'number'
|
|
20
|
+
&& el.width >= 240;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPillRect(el) {
|
|
24
|
+
return !!el
|
|
25
|
+
&& el.type === 'rect'
|
|
26
|
+
&& typeof el.width === 'number'
|
|
27
|
+
&& typeof el.height === 'number'
|
|
28
|
+
&& el.height >= 20
|
|
29
|
+
&& el.height <= 48
|
|
30
|
+
&& typeof el.cornerRadius === 'number'
|
|
31
|
+
&& el.cornerRadius >= el.height / 2 - 2
|
|
32
|
+
&& !!el.stroke
|
|
33
|
+
&& !!el.fill;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPillText(el, rect) {
|
|
37
|
+
return !!el
|
|
38
|
+
&& el.type === 'text'
|
|
39
|
+
&& el.noWrap
|
|
40
|
+
&& typeof el.x === 'number'
|
|
41
|
+
&& typeof el.y === 'number'
|
|
42
|
+
&& typeof el.width === 'number'
|
|
43
|
+
&& typeof el.height === 'number'
|
|
44
|
+
&& el.x >= rect.x - 2
|
|
45
|
+
&& el.y >= rect.y - 2
|
|
46
|
+
&& el.x + el.width <= rect.x + rect.width + 2
|
|
47
|
+
&& el.y + el.height <= rect.y + rect.height + 4;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isStatDivider(el) {
|
|
51
|
+
return !!el
|
|
52
|
+
&& el.type === 'rect'
|
|
53
|
+
&& typeof el.width === 'number'
|
|
54
|
+
&& el.width <= 2
|
|
55
|
+
&& typeof el.height === 'number'
|
|
56
|
+
&& el.height >= 240
|
|
57
|
+
&& typeof el.fill === 'string';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isStatNumber(el) {
|
|
61
|
+
return !!el
|
|
62
|
+
&& el.type === 'richText'
|
|
63
|
+
&& el.noWrap
|
|
64
|
+
&& typeof el.size === 'number'
|
|
65
|
+
&& el.size >= 72
|
|
66
|
+
&& typeof el.height === 'number'
|
|
67
|
+
&& el.height >= 80;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isStatLabel(el, number) {
|
|
71
|
+
return !!el
|
|
72
|
+
&& el.type === 'text'
|
|
73
|
+
&& !el.noWrap
|
|
74
|
+
&& typeof el.width === 'number'
|
|
75
|
+
&& el.width >= 240
|
|
76
|
+
&& el.width <= 360
|
|
77
|
+
&& typeof el.lineHeight === 'number'
|
|
78
|
+
&& el.lineHeight >= 28
|
|
79
|
+
&& Math.abs(el.x - number.x) <= 4
|
|
80
|
+
&& el.y > number.y;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRingCaption(el, svg) {
|
|
84
|
+
return !!el
|
|
85
|
+
&& el.type === 'text'
|
|
86
|
+
&& typeof el.size === 'number'
|
|
87
|
+
&& el.size >= 20
|
|
88
|
+
&& el.size <= 28
|
|
89
|
+
&& typeof el.x === 'number'
|
|
90
|
+
&& typeof el.y === 'number'
|
|
91
|
+
&& el.x >= svg.x + svg.width - 4
|
|
92
|
+
&& el.y >= svg.y - 4
|
|
93
|
+
&& el.y + el.height <= svg.y + svg.height + 4;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function horizontalOverlap(a0, a1, b0, b1) {
|
|
97
|
+
return Math.max(0, Math.min(a1, b1) - Math.max(a0, b0));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function maybeStatWithRing(elements, startIdx) {
|
|
101
|
+
let i = startIdx;
|
|
102
|
+
let divider = null;
|
|
103
|
+
if (isStatDivider(elements[i])) {
|
|
104
|
+
divider = elements[i];
|
|
105
|
+
i += 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const number = elements[i];
|
|
109
|
+
const label = elements[i + 1];
|
|
110
|
+
const ring = elements[i + 2];
|
|
111
|
+
const caption = elements[i + 3];
|
|
112
|
+
if (!isStatNumber(number) || !isStatLabel(label, number) || ring?.type !== 'svg' || !isRingCaption(caption, ring)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const gap = ring.y - (label.y + label.height);
|
|
117
|
+
if (gap < 8 || gap > 32) return null;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
consumedUntil: i + 4,
|
|
121
|
+
element: {
|
|
122
|
+
type: 'statWithRing',
|
|
123
|
+
number,
|
|
124
|
+
label,
|
|
125
|
+
ring,
|
|
126
|
+
caption,
|
|
127
|
+
divider,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function maybeTextWithPillRow(elements, startIdx) {
|
|
133
|
+
const textBlock = elements[startIdx];
|
|
134
|
+
if (!isParagraphText(textBlock)) return null;
|
|
135
|
+
|
|
136
|
+
const items = [];
|
|
137
|
+
let i = startIdx + 1;
|
|
138
|
+
while (i + 1 < elements.length) {
|
|
139
|
+
const rect = elements[i];
|
|
140
|
+
const text = elements[i + 1];
|
|
141
|
+
if (!isPillRect(rect) || !isPillText(text, rect)) break;
|
|
142
|
+
if (items.length > 0) {
|
|
143
|
+
const prev = items[items.length - 1].rect;
|
|
144
|
+
const gap = rect.x - (prev.x + prev.width);
|
|
145
|
+
if (Math.abs(rect.y - prev.y) > 4 || gap < 0 || gap > 32) break;
|
|
146
|
+
}
|
|
147
|
+
items.push({ rect, text });
|
|
148
|
+
i += 2;
|
|
149
|
+
}
|
|
150
|
+
if (items.length < 2) return null;
|
|
151
|
+
|
|
152
|
+
const rowX = items[0].rect.x;
|
|
153
|
+
const rowY = Math.min(...items.map((item) => item.rect.y));
|
|
154
|
+
const rowH = Math.max(...items.map((item) => item.rect.height));
|
|
155
|
+
const last = items[items.length - 1].rect;
|
|
156
|
+
const rowW = last.x + last.width - rowX;
|
|
157
|
+
const gap = rowY - (textBlock.y + textBlock.height);
|
|
158
|
+
if (gap < 8 || gap > 48) return null;
|
|
159
|
+
|
|
160
|
+
const overlap = horizontalOverlap(
|
|
161
|
+
textBlock.x,
|
|
162
|
+
textBlock.x + textBlock.width,
|
|
163
|
+
rowX,
|
|
164
|
+
rowX + rowW,
|
|
165
|
+
);
|
|
166
|
+
if (overlap < Math.min(textBlock.width, rowW) * 0.3) return null;
|
|
167
|
+
|
|
168
|
+
const itemGaps = [];
|
|
169
|
+
for (let idx = 1; idx < items.length; idx++) {
|
|
170
|
+
const prev = items[idx - 1].rect;
|
|
171
|
+
const cur = items[idx].rect;
|
|
172
|
+
itemGaps.push(cur.x - (prev.x + prev.width));
|
|
173
|
+
}
|
|
174
|
+
const rowGap = itemGaps.length
|
|
175
|
+
? Math.round(itemGaps.reduce((sum, n) => sum + n, 0) / itemGaps.length)
|
|
176
|
+
: 0;
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
consumedUntil: i,
|
|
180
|
+
element: {
|
|
181
|
+
type: 'textWithPillRow',
|
|
182
|
+
x: textBlock.x,
|
|
183
|
+
y: textBlock.y,
|
|
184
|
+
width: Math.max(textBlock.width, rowW),
|
|
185
|
+
height: textBlock.height + gap + rowH,
|
|
186
|
+
gap,
|
|
187
|
+
textBlock,
|
|
188
|
+
row: {
|
|
189
|
+
width: rowW,
|
|
190
|
+
height: rowH,
|
|
191
|
+
gap: rowGap,
|
|
192
|
+
items: items.map(({ rect, text }) => ({ rect, text })),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Match a standalone pill row (no paragraph anchor) inside a flex container.
|
|
199
|
+
// This handles cases like Carbon slide 1's bottom-right "ENERGY / DATA CENTERS
|
|
200
|
+
// / CLIMATE" chips, which sit in their own flex column and aren't preceded by
|
|
201
|
+
// a paragraph — so `maybeTextWithPillRow` can't anchor. Without this, the
|
|
202
|
+
// pill rect and its text label become two separate Auto Layout siblings and
|
|
203
|
+
// the pill loses its shape.
|
|
204
|
+
function maybePillRow(elements, startIdx) {
|
|
205
|
+
const items = [];
|
|
206
|
+
let i = startIdx;
|
|
207
|
+
while (i + 1 < elements.length) {
|
|
208
|
+
const rect = elements[i];
|
|
209
|
+
const text = elements[i + 1];
|
|
210
|
+
if (!isPillRect(rect) || !isPillText(text, rect)) break;
|
|
211
|
+
if (items.length > 0) {
|
|
212
|
+
const prev = items[items.length - 1].rect;
|
|
213
|
+
const gap = rect.x - (prev.x + prev.width);
|
|
214
|
+
if (Math.abs(rect.y - prev.y) > 4 || gap < 0 || gap > 32) break;
|
|
215
|
+
}
|
|
216
|
+
items.push({ rect, text });
|
|
217
|
+
i += 2;
|
|
218
|
+
}
|
|
219
|
+
if (items.length < 2) return null;
|
|
220
|
+
|
|
221
|
+
const rowX = items[0].rect.x;
|
|
222
|
+
const rowY = Math.min(...items.map((item) => item.rect.y));
|
|
223
|
+
const rowH = Math.max(...items.map((item) => item.rect.height));
|
|
224
|
+
const last = items[items.length - 1].rect;
|
|
225
|
+
const rowW = last.x + last.width - rowX;
|
|
226
|
+
|
|
227
|
+
const itemGaps = [];
|
|
228
|
+
for (let idx = 1; idx < items.length; idx++) {
|
|
229
|
+
const prev = items[idx - 1].rect;
|
|
230
|
+
const cur = items[idx].rect;
|
|
231
|
+
itemGaps.push(cur.x - (prev.x + prev.width));
|
|
232
|
+
}
|
|
233
|
+
const rowGap = itemGaps.length
|
|
234
|
+
? Math.round(itemGaps.reduce((sum, n) => sum + n, 0) / itemGaps.length)
|
|
235
|
+
: 0;
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
consumedUntil: i,
|
|
239
|
+
element: {
|
|
240
|
+
type: 'pillRow',
|
|
241
|
+
x: rowX,
|
|
242
|
+
y: rowY,
|
|
243
|
+
width: rowW,
|
|
244
|
+
height: rowH,
|
|
245
|
+
gap: rowGap,
|
|
246
|
+
items: items.map(({ rect, text }) => ({ rect, text })),
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function coalesceAutoLayoutStructures(elements = []) {
|
|
252
|
+
const out = [];
|
|
253
|
+
for (let i = 0; i < elements.length; i++) {
|
|
254
|
+
const stat = maybeStatWithRing(elements, i);
|
|
255
|
+
if (stat) {
|
|
256
|
+
out.push(stat.element);
|
|
257
|
+
i = stat.consumedUntil - 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const grouped = maybeTextWithPillRow(elements, i);
|
|
261
|
+
if (grouped) {
|
|
262
|
+
out.push(grouped.element);
|
|
263
|
+
i = grouped.consumedUntil - 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const pills = maybePillRow(elements, i);
|
|
267
|
+
if (pills) {
|
|
268
|
+
out.push(pills.element);
|
|
269
|
+
i = pills.consumedUntil - 1;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const el = elements[i];
|
|
273
|
+
if (el && el.type === 'layoutContainer' && Array.isArray(el.children)) {
|
|
274
|
+
// Recurse so specialized pill/stat coalescers fire inside nested flex
|
|
275
|
+
// containers too.
|
|
276
|
+
out.push({ ...el, children: coalesceAutoLayoutStructures(el.children) });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
out.push(el);
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function convertHandoffBundle(bundlePath, outDeckPath, opts = {}) {
|
|
285
|
+
const bundle = loadBundle(bundlePath);
|
|
286
|
+
const manifest = bundle.manifest;
|
|
287
|
+
const scratch = opts.scratchDir ?? scopeScratchDir(outDeckPath);
|
|
288
|
+
|
|
289
|
+
const deck = await Deck.create({ name: opts.title ?? manifest.title ?? 'Untitled' });
|
|
290
|
+
|
|
291
|
+
// Collector for noWrap text-shift heuristic decisions, written as a
|
|
292
|
+
// sidecar JSON to scratch dir. Lets regressions in the textOpts path
|
|
293
|
+
// be diagnosed without visual QA on a real deck.
|
|
294
|
+
const noWrapDiagnostics = [];
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i < manifest.slides.length; i++) {
|
|
297
|
+
const def = manifest.slides[i];
|
|
298
|
+
const slide = deck.addBlankSlide();
|
|
299
|
+
if (def.background) slide.setBackground(def.background);
|
|
300
|
+
const ctx = {
|
|
301
|
+
...bundle,
|
|
302
|
+
slideIndex: i + 1,
|
|
303
|
+
slideDef: def,
|
|
304
|
+
slideWidth: manifest.dimensions?.width ?? 1920,
|
|
305
|
+
noWrapDiagnostics,
|
|
306
|
+
};
|
|
307
|
+
for (const el of coalesceAutoLayoutStructures(def.elements ?? [])) {
|
|
308
|
+
await applyElement(slide, el, ctx);
|
|
309
|
+
}
|
|
310
|
+
if (def.speakerNotes) slide.setSpeakerNotes(def.speakerNotes);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (noWrapDiagnostics.length) {
|
|
314
|
+
const { writeFileSync } = await import('node:fs');
|
|
315
|
+
const { join } = await import('node:path');
|
|
316
|
+
writeFileSync(join(scratch, 'nowrap-diagnostics.json'), JSON.stringify(noWrapDiagnostics, null, 2));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await deck.save(outDeckPath);
|
|
320
|
+
return { deck, scratchDir: scratch, bundle };
|
|
321
|
+
}
|