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.
@@ -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
+ }