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,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-seed .deck construction.
|
|
3
|
+
*
|
|
4
|
+
* Builds a document from code with no bundled seed file. The base document
|
|
5
|
+
* (DOCUMENT + two CANVAS nodes) comes from openfig-core's
|
|
6
|
+
* `createEmptyFigDoc()`. On top we author the Slides scaffolding
|
|
7
|
+
* (SLIDE_GRID, SLIDE_ROW, SLIDE) and an OpenFig-authored neutral theme:
|
|
8
|
+
* - TEXT styles: "Heading", "Body", "Caption"
|
|
9
|
+
* - VARIABLE_SET "OpenFig default" with Ink / Paper / Accent variables
|
|
10
|
+
* - DOCUMENT theme wiring: themeID, slideThemeMap, sourceLibraryKey
|
|
11
|
+
*
|
|
12
|
+
* Every string and numeric value is authored by this project.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createEmptyFigDoc } from 'openfig-core';
|
|
16
|
+
import { deflateSync } from 'node:zlib';
|
|
17
|
+
|
|
18
|
+
const IDENTITY = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 };
|
|
19
|
+
const BASE = {
|
|
20
|
+
phase: 'CREATED',
|
|
21
|
+
visible: true,
|
|
22
|
+
opacity: 1,
|
|
23
|
+
strokeWeight: 0,
|
|
24
|
+
strokeAlign: 'CENTER',
|
|
25
|
+
strokeJoin: 'BEVEL',
|
|
26
|
+
transform: IDENTITY,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SLIDE_WIDTH = 1920;
|
|
30
|
+
const SLIDE_HEIGHT = 1080;
|
|
31
|
+
const GRID_PADDING = 240;
|
|
32
|
+
const GRID_WIDTH = SLIDE_WIDTH + GRID_PADDING * 2;
|
|
33
|
+
const GRID_HEIGHT = SLIDE_HEIGHT + GRID_PADDING * 2;
|
|
34
|
+
|
|
35
|
+
// Theme GUIDs live in sessionID=1 to keep them distinct from structural
|
|
36
|
+
// scaffolding (session 0) and from user-added content (future sessions).
|
|
37
|
+
const THEME_SESSION = 1;
|
|
38
|
+
const TEXT_HEADING_ID = 10;
|
|
39
|
+
const TEXT_BODY_ID = 11;
|
|
40
|
+
const TEXT_CAPTION_ID = 12;
|
|
41
|
+
const VSET_ID = 20;
|
|
42
|
+
const VAR_INK_ID = 21;
|
|
43
|
+
const VAR_PAPER_ID = 22;
|
|
44
|
+
const VAR_ACCENT_ID = 23;
|
|
45
|
+
const MODE_ID = 1;
|
|
46
|
+
|
|
47
|
+
const INTERNAL_CANVAS_PARENT = {
|
|
48
|
+
guid: { sessionID: 0, localID: 2 },
|
|
49
|
+
// Child sort positions use single ASCII-printable characters as fractional
|
|
50
|
+
// indices (the format orders siblings by lexical comparison of these strings).
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const THEME_VERSION = '1:0';
|
|
54
|
+
const THEME_LIBRARY_KEY = 'lk-openfig-default-v1';
|
|
55
|
+
|
|
56
|
+
function textStyleNode(localID, name, sortPos, { fontSize, fontFamily, fontStyle, postscript }) {
|
|
57
|
+
return {
|
|
58
|
+
...BASE,
|
|
59
|
+
guid: { sessionID: THEME_SESSION, localID },
|
|
60
|
+
parentIndex: { guid: INTERNAL_CANVAS_PARENT.guid, position: sortPos },
|
|
61
|
+
type: 'TEXT',
|
|
62
|
+
name,
|
|
63
|
+
isPublishable: true,
|
|
64
|
+
styleType: 'TEXT',
|
|
65
|
+
version: THEME_VERSION,
|
|
66
|
+
userFacingVersion: THEME_VERSION,
|
|
67
|
+
sortPosition: sortPos,
|
|
68
|
+
fontSize,
|
|
69
|
+
textAlignVertical: 'TOP',
|
|
70
|
+
lineHeight: { value: 1.2, units: 'RAW' },
|
|
71
|
+
fontName: { family: fontFamily, style: fontStyle, postscript },
|
|
72
|
+
textData: {
|
|
73
|
+
characters: 'Rag 123',
|
|
74
|
+
lines: [
|
|
75
|
+
{
|
|
76
|
+
lineType: 'PLAIN',
|
|
77
|
+
styleId: 0,
|
|
78
|
+
indentationLevel: 0,
|
|
79
|
+
sourceDirectionality: 'AUTO',
|
|
80
|
+
listStartOffset: 0,
|
|
81
|
+
isFirstLineOfList: false,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function variableSetNode() {
|
|
89
|
+
return {
|
|
90
|
+
...BASE,
|
|
91
|
+
guid: { sessionID: THEME_SESSION, localID: VSET_ID },
|
|
92
|
+
parentIndex: { guid: INTERNAL_CANVAS_PARENT.guid, position: '9' },
|
|
93
|
+
type: 'VARIABLE_SET',
|
|
94
|
+
name: 'OpenFig default',
|
|
95
|
+
isPublishable: true,
|
|
96
|
+
version: THEME_VERSION,
|
|
97
|
+
userFacingVersion: THEME_VERSION,
|
|
98
|
+
visible: false,
|
|
99
|
+
locked: true,
|
|
100
|
+
opacity: 0,
|
|
101
|
+
variableSetModes: [
|
|
102
|
+
{
|
|
103
|
+
id: { sessionID: THEME_SESSION, localID: MODE_ID },
|
|
104
|
+
name: 'Mode 1',
|
|
105
|
+
sortPosition: '!',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function colorVariableNode(localID, name, sortPos, { r, g, b }) {
|
|
112
|
+
return {
|
|
113
|
+
...BASE,
|
|
114
|
+
guid: { sessionID: THEME_SESSION, localID },
|
|
115
|
+
parentIndex: { guid: INTERNAL_CANVAS_PARENT.guid, position: sortPos },
|
|
116
|
+
type: 'VARIABLE',
|
|
117
|
+
name,
|
|
118
|
+
isPublishable: true,
|
|
119
|
+
version: THEME_VERSION,
|
|
120
|
+
sortPosition: sortPos,
|
|
121
|
+
visible: false,
|
|
122
|
+
locked: true,
|
|
123
|
+
opacity: 0,
|
|
124
|
+
variableSetID: { guid: { sessionID: THEME_SESSION, localID: VSET_ID } },
|
|
125
|
+
variableResolvedType: 'COLOR',
|
|
126
|
+
variableDataValues: {
|
|
127
|
+
entries: [
|
|
128
|
+
{
|
|
129
|
+
modeID: { sessionID: THEME_SESSION, localID: MODE_ID },
|
|
130
|
+
variableData: {
|
|
131
|
+
value: { colorValue: { r, g, b, a: 1 } },
|
|
132
|
+
dataType: 'COLOR',
|
|
133
|
+
resolvedDataType: 'COLOR',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate a minimal 400×260 solid-white PNG placeholder for the deck's
|
|
143
|
+
* thumbnail.png entry. The .deck format requires a thumbnail.png entry in
|
|
144
|
+
* the zip archive alongside canvas.fig and meta.json.
|
|
145
|
+
*
|
|
146
|
+
* @returns {Buffer} PNG bytes
|
|
147
|
+
*/
|
|
148
|
+
export function createPlaceholderThumbnail(width = 400, height = 260) {
|
|
149
|
+
const row = Buffer.alloc(1 + width * 3, 0xff); row[0] = 0;
|
|
150
|
+
const raw = Buffer.alloc(height * row.length);
|
|
151
|
+
for (let i = 0; i < height; i++) row.copy(raw, i * row.length);
|
|
152
|
+
const idat = deflateSync(raw);
|
|
153
|
+
|
|
154
|
+
const crcTable = new Uint32Array(256);
|
|
155
|
+
for (let n = 0; n < 256; n++) {
|
|
156
|
+
let c = n;
|
|
157
|
+
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
158
|
+
crcTable[n] = c >>> 0;
|
|
159
|
+
}
|
|
160
|
+
const crc32 = (buf) => {
|
|
161
|
+
let c = 0xFFFFFFFF;
|
|
162
|
+
for (const b of buf) c = (crcTable[(c ^ b) & 0xFF] ^ (c >>> 8)) >>> 0;
|
|
163
|
+
return (c ^ 0xFFFFFFFF) >>> 0;
|
|
164
|
+
};
|
|
165
|
+
const chunk = (type, data) => {
|
|
166
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
|
167
|
+
const t = Buffer.from(type, 'ascii');
|
|
168
|
+
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
|
|
169
|
+
return Buffer.concat([len, t, data, crc]);
|
|
170
|
+
};
|
|
171
|
+
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
|
172
|
+
const ihdr = Buffer.alloc(13);
|
|
173
|
+
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
|
174
|
+
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; // 8-bit RGB
|
|
175
|
+
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build an empty FigDocument for a Slides presentation.
|
|
180
|
+
*
|
|
181
|
+
* Returns a FigDocument (from openfig-core) with its message.nodeChanges
|
|
182
|
+
* populated to contain:
|
|
183
|
+
* - DOCUMENT (0:0) — with themeID, slideThemeMap, sourceLibraryKey
|
|
184
|
+
* - CANVAS "Page 1" (0:1)
|
|
185
|
+
* - CANVAS "Internal Only Canvas" (0:2)
|
|
186
|
+
* - SLIDE_GRID "Presentation" (0:3)
|
|
187
|
+
* - SLIDE_ROW "Row" (0:4)
|
|
188
|
+
* - SLIDE "1" (0:5)
|
|
189
|
+
* - TEXT styles "Heading" / "Body" / "Caption" (1:10..12)
|
|
190
|
+
* - VARIABLE_SET "OpenFig default" (1:20)
|
|
191
|
+
* - VARIABLE nodes Ink / Paper / Accent (1:21..23)
|
|
192
|
+
*
|
|
193
|
+
* @param {object} [opts]
|
|
194
|
+
* @param {string} [opts.name]
|
|
195
|
+
* @returns {import('openfig-core').FigDocument}
|
|
196
|
+
*/
|
|
197
|
+
export function createEmptyDeckDoc(_opts = {}) {
|
|
198
|
+
const doc = createEmptyFigDoc();
|
|
199
|
+
|
|
200
|
+
// Normalize CANVAS sort positions so Page 1 sorts first and the
|
|
201
|
+
// Internal Only Canvas sorts last.
|
|
202
|
+
for (const node of doc.message.nodeChanges) {
|
|
203
|
+
if (node.type !== 'CANVAS') continue;
|
|
204
|
+
if (node.name === 'Page 1') node.parentIndex.position = '!';
|
|
205
|
+
else if (node.name === 'Internal Only Canvas') node.parentIndex.position = '~';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Slides scaffolding (session 0).
|
|
209
|
+
const slideGrid = {
|
|
210
|
+
...BASE,
|
|
211
|
+
guid: { sessionID: 0, localID: 3 },
|
|
212
|
+
type: 'SLIDE_GRID',
|
|
213
|
+
name: 'Presentation',
|
|
214
|
+
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
|
|
215
|
+
size: { x: GRID_WIDTH, y: GRID_HEIGHT },
|
|
216
|
+
stackMode: 'VERTICAL',
|
|
217
|
+
stackSpacing: 600,
|
|
218
|
+
stackHorizontalPadding: GRID_PADDING,
|
|
219
|
+
stackVerticalPadding: GRID_PADDING,
|
|
220
|
+
stackPaddingRight: GRID_PADDING,
|
|
221
|
+
stackPaddingBottom: GRID_PADDING,
|
|
222
|
+
frameMaskDisabled: true,
|
|
223
|
+
stackCounterSizing: 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE',
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const slideRow = {
|
|
227
|
+
...BASE,
|
|
228
|
+
guid: { sessionID: 0, localID: 4 },
|
|
229
|
+
type: 'SLIDE_ROW',
|
|
230
|
+
name: 'Row',
|
|
231
|
+
parentIndex: { guid: { sessionID: 0, localID: 3 }, position: '!' },
|
|
232
|
+
size: { x: SLIDE_WIDTH, y: SLIDE_HEIGHT },
|
|
233
|
+
transform: { m00: 1, m01: 0, m02: GRID_PADDING, m10: 0, m11: 1, m12: GRID_PADDING },
|
|
234
|
+
strokeWeight: 1,
|
|
235
|
+
strokeAlign: 'INSIDE',
|
|
236
|
+
strokeJoin: 'MITER',
|
|
237
|
+
stackMode: 'HORIZONTAL',
|
|
238
|
+
stackSpacing: 240,
|
|
239
|
+
stackCounterSpacing: 240,
|
|
240
|
+
stackChildAlignSelf: 'STRETCH',
|
|
241
|
+
stackCounterSizing: 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE',
|
|
242
|
+
stackPrimarySizing: 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE',
|
|
243
|
+
stackWrap: 'WRAP',
|
|
244
|
+
frameMaskDisabled: true,
|
|
245
|
+
hasBeenManuallyRenamed: false,
|
|
246
|
+
fillPaints: [
|
|
247
|
+
{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 0, visible: true, blendMode: 'NORMAL' },
|
|
248
|
+
],
|
|
249
|
+
// `y: Infinity` represents an unconstrained vertical maximum for the
|
|
250
|
+
// stack container. JSON.stringify serializes it as `null`; the kiwi
|
|
251
|
+
// encoder accepts Infinity directly.
|
|
252
|
+
maxSize: { value: { x: 43440, y: Infinity } },
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const slide = {
|
|
256
|
+
...BASE,
|
|
257
|
+
guid: { sessionID: 0, localID: 5 },
|
|
258
|
+
type: 'SLIDE',
|
|
259
|
+
name: '1',
|
|
260
|
+
parentIndex: { guid: { sessionID: 0, localID: 4 }, position: '!' },
|
|
261
|
+
size: { x: SLIDE_WIDTH, y: SLIDE_HEIGHT },
|
|
262
|
+
strokeWeight: 1,
|
|
263
|
+
strokeAlign: 'INSIDE',
|
|
264
|
+
strokeJoin: 'MITER',
|
|
265
|
+
fillPaints: [
|
|
266
|
+
{
|
|
267
|
+
type: 'SOLID',
|
|
268
|
+
color: { r: 1, g: 1, b: 1, a: 1 },
|
|
269
|
+
opacity: 1,
|
|
270
|
+
visible: true,
|
|
271
|
+
blendMode: 'NORMAL',
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
stackHorizontalPadding: 168,
|
|
275
|
+
stackVerticalPadding: 128,
|
|
276
|
+
stackPaddingRight: 168,
|
|
277
|
+
stackPaddingBottom: 128,
|
|
278
|
+
frameMaskDisabled: false,
|
|
279
|
+
overrideKey: { sessionID: 4294967295, localID: 4294967295 },
|
|
280
|
+
sourceLibraryKey: THEME_LIBRARY_KEY,
|
|
281
|
+
themeID: { guid: { sessionID: THEME_SESSION, localID: VSET_ID } },
|
|
282
|
+
slideSpeakerNotes: '{"root":{"children":[{"children":[],"direction":null,"format":"","textFormat":null,"indent":0,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// OpenFig-authored neutral theme (session 1). All names and numeric values
|
|
286
|
+
// are defined in this file; none are derived from any other presentation.
|
|
287
|
+
const heading = textStyleNode(TEXT_HEADING_ID, 'Heading', '"', {
|
|
288
|
+
fontSize: 72, fontFamily: 'Inter', fontStyle: 'Bold', postscript: 'Inter-Bold',
|
|
289
|
+
});
|
|
290
|
+
const body = textStyleNode(TEXT_BODY_ID, 'Body', '#', {
|
|
291
|
+
fontSize: 36, fontFamily: 'Inter', fontStyle: 'Regular', postscript: 'Inter-Regular',
|
|
292
|
+
});
|
|
293
|
+
const caption = textStyleNode(TEXT_CAPTION_ID, 'Caption', '$', {
|
|
294
|
+
fontSize: 24, fontFamily: 'Inter', fontStyle: 'Regular', postscript: 'Inter-Regular',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const vset = variableSetNode();
|
|
298
|
+
const varInk = colorVariableNode(VAR_INK_ID, 'Ink', ':', { r: 0.1, g: 0.1, b: 0.1 });
|
|
299
|
+
const varPaper = colorVariableNode(VAR_PAPER_ID, 'Paper', ';', { r: 1, g: 1, b: 1 });
|
|
300
|
+
const varAccent = colorVariableNode(VAR_ACCENT_ID, 'Accent', '<', { r: 0.25, g: 0.5, b: 0.85 });
|
|
301
|
+
|
|
302
|
+
// Attach theme metadata to DOCUMENT.
|
|
303
|
+
const documentNode = doc.message.nodeChanges.find((n) => n.type === 'DOCUMENT');
|
|
304
|
+
documentNode.sourceLibraryKey = THEME_LIBRARY_KEY;
|
|
305
|
+
documentNode.themeID = { guid: { sessionID: THEME_SESSION, localID: VSET_ID } };
|
|
306
|
+
documentNode.slideThemeMap = {
|
|
307
|
+
entries: [
|
|
308
|
+
{
|
|
309
|
+
themeId: { guid: { sessionID: THEME_SESSION, localID: VSET_ID } },
|
|
310
|
+
themeProps: {
|
|
311
|
+
themeVersion: THEME_VERSION,
|
|
312
|
+
variableSetId: { guid: { sessionID: THEME_SESSION, localID: VSET_ID } },
|
|
313
|
+
textStyleIds: [
|
|
314
|
+
{ guid: { sessionID: THEME_SESSION, localID: TEXT_HEADING_ID } },
|
|
315
|
+
{ guid: { sessionID: THEME_SESSION, localID: TEXT_BODY_ID } },
|
|
316
|
+
{ guid: { sessionID: THEME_SESSION, localID: TEXT_CAPTION_ID } },
|
|
317
|
+
],
|
|
318
|
+
subscribedThemeRef: { key: '', version: '' },
|
|
319
|
+
schemaVersion: 1,
|
|
320
|
+
isGeneratedFromDesign: false,
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
doc.message.nodeChanges.push(
|
|
327
|
+
slideGrid,
|
|
328
|
+
slideRow,
|
|
329
|
+
slide,
|
|
330
|
+
heading,
|
|
331
|
+
body,
|
|
332
|
+
caption,
|
|
333
|
+
vset,
|
|
334
|
+
varInk,
|
|
335
|
+
varPaper,
|
|
336
|
+
varAccent,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Rebuild convenience maps.
|
|
340
|
+
doc.nodeMap = new Map();
|
|
341
|
+
doc.childrenMap = new Map();
|
|
342
|
+
for (const node of doc.message.nodeChanges) {
|
|
343
|
+
const id = `${node.guid.sessionID}:${node.guid.localID}`;
|
|
344
|
+
doc.nodeMap.set(id, node);
|
|
345
|
+
}
|
|
346
|
+
for (const node of doc.message.nodeChanges) {
|
|
347
|
+
if (!node.parentIndex?.guid) continue;
|
|
348
|
+
const pid = `${node.parentIndex.guid.sessionID}:${node.parentIndex.guid.localID}`;
|
|
349
|
+
if (!doc.childrenMap.has(pid)) doc.childrenMap.set(pid, []);
|
|
350
|
+
doc.childrenMap.get(pid).push(node);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return doc;
|
|
354
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync, mkdtempSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join, resolve, dirname, basename } from 'path';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
|
|
7
|
+
function isDir(p) {
|
|
8
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function findManifestRoot(dir) {
|
|
12
|
+
if (existsSync(join(dir, 'manifest.json'))) return dir;
|
|
13
|
+
const nested = join(dir, 'claude_code_handoff');
|
|
14
|
+
if (existsSync(join(nested, 'manifest.json'))) return nested;
|
|
15
|
+
for (const entry of readdirSync(dir)) {
|
|
16
|
+
const sub = join(dir, entry);
|
|
17
|
+
if (!isDir(sub)) continue;
|
|
18
|
+
const found = findManifestRoot(sub);
|
|
19
|
+
if (found) return found;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function unzipToTemp(zipPath) {
|
|
25
|
+
const dest = mkdtempSync(join(tmpdir(), 'openfig-handoff-'));
|
|
26
|
+
execFileSync('unzip', ['-q', '-o', zipPath, '-d', dest]);
|
|
27
|
+
return dest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadBundle(bundlePath) {
|
|
31
|
+
const abs = resolve(bundlePath);
|
|
32
|
+
if (!existsSync(abs)) throw new Error(`Bundle not found: ${abs}`);
|
|
33
|
+
|
|
34
|
+
let workDir = abs;
|
|
35
|
+
let tempRoot = null;
|
|
36
|
+
if (!isDir(abs)) {
|
|
37
|
+
if (!abs.endsWith('.zip')) {
|
|
38
|
+
throw new Error(`Bundle must be a directory or .zip: ${abs}`);
|
|
39
|
+
}
|
|
40
|
+
tempRoot = unzipToTemp(abs);
|
|
41
|
+
workDir = tempRoot;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const root = findManifestRoot(workDir);
|
|
45
|
+
if (!root) {
|
|
46
|
+
throw new Error(`No manifest.json found under ${workDir}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
|
50
|
+
|
|
51
|
+
let html = null;
|
|
52
|
+
const htmlFile = readdirSync(root).find(f => f.toLowerCase().endsWith('.html'));
|
|
53
|
+
if (htmlFile) {
|
|
54
|
+
html = readFileSync(join(root, htmlFile), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveMedia(src) {
|
|
58
|
+
if (!src || typeof src !== 'string') {
|
|
59
|
+
throw new Error(`resolveMedia: invalid src ${JSON.stringify(src)}`);
|
|
60
|
+
}
|
|
61
|
+
if (src.startsWith('data:')) {
|
|
62
|
+
const m = /^data:([^;,]+)(;base64)?,([\s\S]*)$/.exec(src);
|
|
63
|
+
if (!m) throw new Error(`resolveMedia: malformed data URL`);
|
|
64
|
+
const [, mime, b64, payload] = m;
|
|
65
|
+
const ext = ({
|
|
66
|
+
'image/svg+xml': 'svg',
|
|
67
|
+
'image/png': 'png',
|
|
68
|
+
'image/jpeg': 'jpg',
|
|
69
|
+
'image/gif': 'gif',
|
|
70
|
+
'image/webp': 'webp',
|
|
71
|
+
})[mime.toLowerCase()] ?? 'bin';
|
|
72
|
+
const buf = b64 ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload), 'utf8');
|
|
73
|
+
const mediaDir = join(root, 'media');
|
|
74
|
+
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true });
|
|
75
|
+
// Content-addressed filename so repeated references reuse the same file.
|
|
76
|
+
const hash = crypto.createHash('sha1').update(buf).digest('hex').slice(0, 16);
|
|
77
|
+
const outPath = join(mediaDir, `data-${hash}.${ext}`);
|
|
78
|
+
if (!existsSync(outPath)) writeFileSync(outPath, buf);
|
|
79
|
+
return outPath;
|
|
80
|
+
}
|
|
81
|
+
const candidates = [
|
|
82
|
+
join(root, src),
|
|
83
|
+
join(root, 'media', basename(src)),
|
|
84
|
+
join(dirname(root), src),
|
|
85
|
+
];
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
if (existsSync(c)) return c;
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Media asset not found: ${src} (searched ${candidates.join(', ')})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { rootDir: root, tempRoot, manifest, resolveMedia, html };
|
|
93
|
+
}
|