slides-grab 1.0.0 → 1.1.2
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 +35 -5
- package/bin/ppt-agent.js +46 -2
- package/convert.cjs +47 -13
- package/package.json +21 -4
- package/scripts/build-viewer.js +349 -0
- package/scripts/editor-server.js +7 -1
- package/scripts/figma-export.js +148 -0
- package/scripts/html2pdf.js +419 -32
- package/scripts/html2pptx.js +135 -0
- package/scripts/install-codex-skills.js +119 -0
- package/scripts/validate-slides.js +159 -371
- package/skills/{ppt-presentation-skill → slides-grab}/SKILL.md +16 -13
- package/skills/{ppt-design-skill → slides-grab-design}/SKILL.md +12 -5
- package/skills/{ppt-pptx-skill → slides-grab-export}/SKILL.md +7 -6
- package/skills/{ppt-plan-skill → slides-grab-plan}/SKILL.md +2 -2
- package/src/editor/codex-edit.js +136 -1
- package/src/editor/js/editor-init.js +10 -3
- package/src/figma.js +63 -0
- package/src/html2pptx.cjs +1166 -0
- package/src/image-contract.js +222 -0
- package/src/validation/cli.js +97 -0
- package/src/validation/core.js +688 -0
- package/templates/split-layout.html +3 -1
- package/AGENTS.md +0 -80
- package/PROGRESS.md +0 -39
- package/SETUP.md +0 -51
- package/prd.json +0 -135
- package/prd.md +0 -104
package/scripts/html2pdf.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { readdir, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { join, resolve } from 'node:path';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { chromium } from 'playwright';
|
|
7
7
|
import { PDFDocument } from 'pdf-lib';
|
|
8
8
|
|
|
9
|
+
import { ensureSlidesPassValidation } from './validate-slides.js';
|
|
10
|
+
|
|
9
11
|
const DEFAULT_OUTPUT = 'slides.pdf';
|
|
10
12
|
const DEFAULT_SLIDES_DIR = 'slides';
|
|
13
|
+
const DEFAULT_MODE = 'capture';
|
|
14
|
+
const PDF_MODES = new Set(['capture', 'print']);
|
|
11
15
|
const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
|
|
12
16
|
const FALLBACK_SLIDE_SIZE = { width: 960, height: 540 };
|
|
17
|
+
const DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR = 2;
|
|
18
|
+
const TARGET_ASPECT_RATIO = 16 / 9;
|
|
19
|
+
const RENDER_SETTLE_MS = 120;
|
|
20
|
+
const CSS_PIXELS_PER_INCH = 96;
|
|
21
|
+
const PDF_POINTS_PER_INCH = 72;
|
|
13
22
|
|
|
14
23
|
function printUsage() {
|
|
15
24
|
process.stdout.write(
|
|
@@ -17,13 +26,15 @@ function printUsage() {
|
|
|
17
26
|
'Usage: node scripts/html2pdf.js [options]',
|
|
18
27
|
'',
|
|
19
28
|
'Options:',
|
|
20
|
-
` --output <path>
|
|
29
|
+
` --output <path> Output PDF path (default: ${DEFAULT_OUTPUT})`,
|
|
21
30
|
` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
|
|
22
|
-
|
|
31
|
+
` --mode <mode> PDF export mode: capture|print (default: ${DEFAULT_MODE})`,
|
|
32
|
+
' -h, --help Show this help message',
|
|
23
33
|
'',
|
|
24
34
|
'Examples:',
|
|
25
35
|
' node scripts/html2pdf.js',
|
|
26
36
|
' node scripts/html2pdf.js --output dist/deck.pdf',
|
|
37
|
+
' node scripts/html2pdf.js --mode print --output dist/searchable.pdf',
|
|
27
38
|
].join('\n'),
|
|
28
39
|
);
|
|
29
40
|
process.stdout.write('\n');
|
|
@@ -42,6 +53,49 @@ function toSlideOrder(fileName) {
|
|
|
42
53
|
return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
function normalizeDimension(value, fallback) {
|
|
57
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
return Math.max(1, Math.round(value));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeMode(value) {
|
|
64
|
+
if (typeof value !== 'string') {
|
|
65
|
+
throw new Error(`--mode must be one of: ${Array.from(PDF_MODES).join(', ')}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mode = value.trim().toLowerCase();
|
|
69
|
+
if (!PDF_MODES.has(mode)) {
|
|
70
|
+
throw new Error(`Unknown PDF mode "${value}". Expected one of: ${Array.from(PDF_MODES).join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
return mode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cssPixelsToPdfPoints(value) {
|
|
76
|
+
return Math.round((normalizeDimension(value, 0) * PDF_POINTS_PER_INCH) / CSS_PIXELS_PER_INCH);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatDiagnosticEntry(entry) {
|
|
80
|
+
const prefix = entry.slideFile ? `${entry.slideFile}: ` : '';
|
|
81
|
+
return `${prefix}${entry.message}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatDiagnostics(slideFile, diagnostics = []) {
|
|
85
|
+
const relevantDiagnostics = diagnostics.filter((entry) => entry.slideFile === slideFile);
|
|
86
|
+
if (relevantDiagnostics.length === 0) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return relevantDiagnostics.map((entry) => ` - ${formatDiagnosticEntry(entry)}`).join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function decorateError(error, slideFile, diagnostics = []) {
|
|
94
|
+
const baseMessage = error instanceof Error ? error.message : String(error);
|
|
95
|
+
const details = formatDiagnostics(slideFile, diagnostics);
|
|
96
|
+
return new Error(details ? `${slideFile}: ${baseMessage}\nDiagnostics:\n${details}` : `${slideFile}: ${baseMessage}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
45
99
|
export function sortSlideFiles(a, b) {
|
|
46
100
|
const orderA = toSlideOrder(a);
|
|
47
101
|
const orderB = toSlideOrder(b);
|
|
@@ -53,6 +107,7 @@ export function parseCliArgs(args) {
|
|
|
53
107
|
const options = {
|
|
54
108
|
output: DEFAULT_OUTPUT,
|
|
55
109
|
slidesDir: DEFAULT_SLIDES_DIR,
|
|
110
|
+
mode: DEFAULT_MODE,
|
|
56
111
|
help: false,
|
|
57
112
|
};
|
|
58
113
|
|
|
@@ -86,6 +141,17 @@ export function parseCliArgs(args) {
|
|
|
86
141
|
continue;
|
|
87
142
|
}
|
|
88
143
|
|
|
144
|
+
if (arg === '--mode') {
|
|
145
|
+
options.mode = normalizeMode(readOptionValue(args, i, '--mode'));
|
|
146
|
+
i += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (arg.startsWith('--mode=')) {
|
|
151
|
+
options.mode = normalizeMode(arg.slice('--mode='.length));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
89
155
|
throw new Error(`Unknown option: ${arg}`);
|
|
90
156
|
}
|
|
91
157
|
|
|
@@ -98,6 +164,7 @@ export function parseCliArgs(args) {
|
|
|
98
164
|
|
|
99
165
|
options.output = options.output.trim();
|
|
100
166
|
options.slidesDir = options.slidesDir.trim();
|
|
167
|
+
options.mode = normalizeMode(options.mode);
|
|
101
168
|
|
|
102
169
|
return options;
|
|
103
170
|
}
|
|
@@ -110,13 +177,6 @@ export async function findSlideFiles(slidesDir = resolve(process.cwd(), DEFAULT_
|
|
|
110
177
|
.sort(sortSlideFiles);
|
|
111
178
|
}
|
|
112
179
|
|
|
113
|
-
function normalizeDimension(value, fallback) {
|
|
114
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
115
|
-
return fallback;
|
|
116
|
-
}
|
|
117
|
-
return Math.max(1, Math.round(value));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
180
|
export function buildPdfOptions(widthPx, heightPx) {
|
|
121
181
|
return {
|
|
122
182
|
width: `${normalizeDimension(widthPx, FALLBACK_SLIDE_SIZE.width)}px`,
|
|
@@ -128,37 +188,327 @@ export function buildPdfOptions(widthPx, heightPx) {
|
|
|
128
188
|
};
|
|
129
189
|
}
|
|
130
190
|
|
|
131
|
-
|
|
132
|
-
|
|
191
|
+
export function buildPageOptions(mode = DEFAULT_MODE) {
|
|
192
|
+
return {
|
|
193
|
+
viewport: {
|
|
194
|
+
width: FALLBACK_SLIDE_SIZE.width,
|
|
195
|
+
height: FALLBACK_SLIDE_SIZE.height,
|
|
196
|
+
},
|
|
197
|
+
deviceScaleFactor: normalizeMode(mode) === 'capture' ? DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR : 1,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function chooseSlideFrame(metrics) {
|
|
202
|
+
const viewportArea = Math.max(1, metrics.viewport.width * metrics.viewport.height);
|
|
203
|
+
const bodyArea = Math.max(1, metrics.body.width * metrics.body.height);
|
|
204
|
+
const bodyScrollArea = Math.max(1, metrics.body.scrollWidth * metrics.body.scrollHeight);
|
|
205
|
+
const documentScrollArea = Math.max(1, metrics.document.scrollWidth * metrics.document.scrollHeight);
|
|
206
|
+
const bodyHasOverflowingContent =
|
|
207
|
+
metrics.body.scrollWidth > metrics.body.width * 1.05 ||
|
|
208
|
+
metrics.body.scrollHeight > metrics.body.height * 1.05 ||
|
|
209
|
+
metrics.document.scrollWidth > metrics.body.width * 1.05 ||
|
|
210
|
+
metrics.document.scrollHeight > metrics.body.height * 1.05;
|
|
211
|
+
const candidates = [
|
|
212
|
+
{ ...metrics.body, source: 'body' },
|
|
213
|
+
...metrics.candidates.map((candidate, index) => ({ ...candidate, candidateIndex: index, source: 'body-child' })),
|
|
214
|
+
]
|
|
215
|
+
.filter((candidate) => candidate.width > 0 && candidate.height > 0)
|
|
216
|
+
.map((candidate) => ({
|
|
217
|
+
...candidate,
|
|
218
|
+
area: candidate.width * candidate.height,
|
|
219
|
+
aspectDelta: Math.abs(candidate.width / candidate.height - TARGET_ASPECT_RATIO),
|
|
220
|
+
coverage: (candidate.width * candidate.height) / viewportArea,
|
|
221
|
+
}))
|
|
222
|
+
.sort((left, right) => right.area - left.area);
|
|
223
|
+
|
|
224
|
+
const preferredCandidate = candidates.find((candidate) => {
|
|
225
|
+
if (candidate.source !== 'body-child') return false;
|
|
226
|
+
if (candidate.coverage < 0.45) return false;
|
|
227
|
+
return candidate.aspectDelta < 0.2;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (preferredCandidate && (bodyHasOverflowingContent || bodyArea > preferredCandidate.area * 1.15 || bodyScrollArea > preferredCandidate.area * 1.15 || documentScrollArea > preferredCandidate.area * 1.15)) {
|
|
231
|
+
return preferredCandidate;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const bestAspectCandidate = candidates.find((candidate) => {
|
|
235
|
+
if (candidate.source === 'body' && bodyHasOverflowingContent) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return candidate.aspectDelta < 0.12;
|
|
239
|
+
});
|
|
240
|
+
return bestAspectCandidate || candidates[0] || { ...metrics.body, source: 'fallback' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function waitForSlideRenderReady(page, options = {}) {
|
|
244
|
+
const settleMs = normalizeDimension(options.settleMs ?? RENDER_SETTLE_MS, RENDER_SETTLE_MS);
|
|
245
|
+
const shouldRunReadySignal = options.runReadySignal !== false;
|
|
246
|
+
|
|
247
|
+
await page.waitForLoadState('load');
|
|
248
|
+
await page.evaluate(async ({ settleMs: settleDelay, runReadySignal }) => {
|
|
249
|
+
if (document.fonts?.ready) {
|
|
250
|
+
await document.fonts.ready.catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await Promise.all(
|
|
254
|
+
Array.from(document.images || [], async (image) => {
|
|
255
|
+
if (typeof image.decode === 'function') {
|
|
256
|
+
await image.decode().catch(() => {});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (image.complete) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await new Promise((resolve) => {
|
|
265
|
+
const done = () => resolve();
|
|
266
|
+
image.addEventListener('load', done, { once: true });
|
|
267
|
+
image.addEventListener('error', done, { once: true });
|
|
268
|
+
});
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
if (runReadySignal) {
|
|
273
|
+
const readySignal =
|
|
274
|
+
window.__slidesGrabReady ??
|
|
275
|
+
window.__SLIDES_GRAB_READY ??
|
|
276
|
+
window.slidesGrabReady ??
|
|
277
|
+
document.documentElement?.dataset?.slidesGrabReady ??
|
|
278
|
+
document.body?.dataset?.slidesGrabReady;
|
|
279
|
+
|
|
280
|
+
if (typeof readySignal === 'function') {
|
|
281
|
+
await readySignal();
|
|
282
|
+
} else if (readySignal && typeof readySignal.then === 'function') {
|
|
283
|
+
await readySignal.catch(() => {});
|
|
284
|
+
} else if (readySignal === 'pending') {
|
|
285
|
+
await new Promise((resolve) => {
|
|
286
|
+
const listener = () => resolve();
|
|
287
|
+
window.addEventListener('slides-grab-ready', listener, { once: true });
|
|
288
|
+
setTimeout(resolve, 5000);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
294
|
+
await new Promise((resolve) => setTimeout(resolve, settleDelay));
|
|
295
|
+
}, { settleMs, runReadySignal: shouldRunReadySignal });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function detectSlideFrame(page) {
|
|
299
|
+
const metrics = await page.evaluate(() => {
|
|
300
|
+
function toBox(element) {
|
|
301
|
+
const rect = element.getBoundingClientRect();
|
|
302
|
+
return {
|
|
303
|
+
x: Math.max(0, rect.x),
|
|
304
|
+
y: Math.max(0, rect.y),
|
|
305
|
+
width: rect.width,
|
|
306
|
+
height: rect.height,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
133
310
|
const body = document.body;
|
|
134
|
-
const
|
|
135
|
-
const
|
|
311
|
+
const bodyStyle = window.getComputedStyle(body);
|
|
312
|
+
const bodyBox = toBox(body);
|
|
313
|
+
const directChildren = Array.from(body.children)
|
|
314
|
+
.map((element) => ({
|
|
315
|
+
tagName: element.tagName.toLowerCase(),
|
|
316
|
+
...toBox(element),
|
|
317
|
+
}))
|
|
318
|
+
.filter((box) => box.width > 0 && box.height > 0);
|
|
136
319
|
|
|
137
320
|
return {
|
|
138
|
-
width:
|
|
139
|
-
|
|
321
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
322
|
+
document: {
|
|
323
|
+
scrollWidth: document.documentElement.scrollWidth || bodyBox.width || 0,
|
|
324
|
+
scrollHeight: document.documentElement.scrollHeight || bodyBox.height || 0,
|
|
325
|
+
},
|
|
326
|
+
body: {
|
|
327
|
+
...bodyBox,
|
|
328
|
+
width: Number.parseFloat(bodyStyle.width) || bodyBox.width || 0,
|
|
329
|
+
height: Number.parseFloat(bodyStyle.height) || bodyBox.height || 0,
|
|
330
|
+
scrollWidth: body.scrollWidth || bodyBox.width || 0,
|
|
331
|
+
scrollHeight: body.scrollHeight || bodyBox.height || 0,
|
|
332
|
+
},
|
|
333
|
+
candidates: directChildren,
|
|
140
334
|
};
|
|
141
335
|
});
|
|
142
336
|
|
|
337
|
+
const frame = chooseSlideFrame(metrics);
|
|
338
|
+
return {
|
|
339
|
+
x: normalizeDimension(frame.x, 0),
|
|
340
|
+
y: normalizeDimension(frame.y, 0),
|
|
341
|
+
width: normalizeDimension(frame.width, FALLBACK_SLIDE_SIZE.width),
|
|
342
|
+
height: normalizeDimension(frame.height, FALLBACK_SLIDE_SIZE.height),
|
|
343
|
+
candidateIndex: Number.isInteger(frame.candidateIndex) ? frame.candidateIndex : null,
|
|
344
|
+
source: frame.source,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function normalizeBodyToSlideFrame(page, slideFrame) {
|
|
349
|
+
return page.evaluate(({ width, height }) => {
|
|
350
|
+
const body = document.body;
|
|
351
|
+
const documentElement = document.documentElement;
|
|
352
|
+
|
|
353
|
+
body.style.margin = '0';
|
|
354
|
+
body.style.padding = '0';
|
|
355
|
+
body.style.width = `${width}px`;
|
|
356
|
+
body.style.height = `${height}px`;
|
|
357
|
+
body.style.minWidth = `${width}px`;
|
|
358
|
+
body.style.minHeight = `${height}px`;
|
|
359
|
+
body.style.overflow = 'hidden';
|
|
360
|
+
|
|
361
|
+
documentElement.style.margin = '0';
|
|
362
|
+
documentElement.style.padding = '0';
|
|
363
|
+
documentElement.style.width = `${width}px`;
|
|
364
|
+
documentElement.style.height = `${height}px`;
|
|
365
|
+
documentElement.style.minWidth = `${width}px`;
|
|
366
|
+
documentElement.style.minHeight = `${height}px`;
|
|
367
|
+
documentElement.style.overflow = 'hidden';
|
|
368
|
+
}, slideFrame);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export async function isolateSlideFrame(page, slideFrame) {
|
|
372
|
+
return page.evaluate(({ x, y, width, height, source, candidateIndex }) => {
|
|
373
|
+
const body = document.body;
|
|
374
|
+
if (body.querySelector(':scope > [data-slides-grab-print-frame="true"]')) {
|
|
375
|
+
return { x: 0, y: 0, width, height, source: 'body', candidateIndex: null };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const shouldWrapBodyChildren = source === 'body-child' || x !== 0 || y !== 0;
|
|
379
|
+
if (!shouldWrapBodyChildren) {
|
|
380
|
+
return { x, y, width, height, source, candidateIndex: candidateIndex ?? null };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const clipFrame = document.createElement('div');
|
|
384
|
+
clipFrame.setAttribute('data-slides-grab-print-frame', 'true');
|
|
385
|
+
clipFrame.style.position = 'relative';
|
|
386
|
+
clipFrame.style.width = `${width}px`;
|
|
387
|
+
clipFrame.style.height = `${height}px`;
|
|
388
|
+
clipFrame.style.margin = '0';
|
|
389
|
+
clipFrame.style.padding = '0';
|
|
390
|
+
clipFrame.style.overflow = 'hidden';
|
|
391
|
+
clipFrame.style.boxSizing = 'border-box';
|
|
392
|
+
|
|
393
|
+
const translatedContent = document.createElement('div');
|
|
394
|
+
translatedContent.setAttribute('data-slides-grab-print-content', 'true');
|
|
395
|
+
translatedContent.style.position = 'absolute';
|
|
396
|
+
translatedContent.style.left = `${-x}px`;
|
|
397
|
+
translatedContent.style.top = `${-y}px`;
|
|
398
|
+
translatedContent.style.width = `${Math.max(width + x, body.scrollWidth, document.documentElement.scrollWidth)}px`;
|
|
399
|
+
translatedContent.style.height = `${Math.max(height + y, body.scrollHeight, document.documentElement.scrollHeight)}px`;
|
|
400
|
+
|
|
401
|
+
// Preserve the original node order inside one translated subtree so overlap
|
|
402
|
+
// paint order and live DOM state survive both capture and print exports.
|
|
403
|
+
const childNodes = Array.from(body.childNodes);
|
|
404
|
+
body.replaceChildren(clipFrame);
|
|
405
|
+
clipFrame.append(translatedContent);
|
|
406
|
+
for (const node of childNodes) {
|
|
407
|
+
translatedContent.append(node);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { x: 0, y: 0, width, height, source: 'body', candidateIndex: null };
|
|
411
|
+
}, slideFrame);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function createSlideDiagnostics() {
|
|
415
|
+
const diagnostics = [];
|
|
416
|
+
let currentSlide = null;
|
|
417
|
+
|
|
418
|
+
function push(type, message) {
|
|
419
|
+
diagnostics.push({
|
|
420
|
+
type,
|
|
421
|
+
slideFile: currentSlide,
|
|
422
|
+
message,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
143
426
|
return {
|
|
144
|
-
|
|
145
|
-
|
|
427
|
+
attach(page) {
|
|
428
|
+
page.on('console', (message) => {
|
|
429
|
+
const type = message.type();
|
|
430
|
+
if (type !== 'error' && type !== 'warning') {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const location = message.location();
|
|
435
|
+
const locationLabel = location.url ? ` (${basename(location.url)}:${location.lineNumber ?? 0})` : '';
|
|
436
|
+
push(`console:${type}`, `${type}${locationLabel}: ${message.text()}`);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
page.on('pageerror', (error) => {
|
|
440
|
+
push('pageerror', error instanceof Error ? error.message : String(error));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
page.on('requestfailed', (request) => {
|
|
444
|
+
const failure = request.failure();
|
|
445
|
+
push(
|
|
446
|
+
'requestfailed',
|
|
447
|
+
`request failed: ${request.url()}${failure?.errorText ? ` (${failure.errorText})` : ''}`,
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
page.on('response', (response) => {
|
|
452
|
+
if (response.status() >= 400) {
|
|
453
|
+
push('response', `HTTP ${response.status()}: ${response.url()}`);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
beginSlide(slideFile) {
|
|
458
|
+
currentSlide = slideFile;
|
|
459
|
+
},
|
|
460
|
+
endSlide() {
|
|
461
|
+
currentSlide = null;
|
|
462
|
+
},
|
|
463
|
+
getSlideDiagnostics(slideFile) {
|
|
464
|
+
return diagnostics.filter((entry) => entry.slideFile === slideFile);
|
|
465
|
+
},
|
|
146
466
|
};
|
|
147
467
|
}
|
|
148
468
|
|
|
149
|
-
async function renderSlideToPdf(page, slideFile, slidesDir) {
|
|
469
|
+
export async function renderSlideToPdf(page, slideFile, slidesDir, options = {}) {
|
|
150
470
|
const slidePath = join(slidesDir, slideFile);
|
|
151
471
|
const slideUrl = pathToFileURL(slidePath).href;
|
|
472
|
+
const mode = normalizeMode(options.mode ?? DEFAULT_MODE);
|
|
152
473
|
|
|
153
474
|
await page.goto(slideUrl, { waitUntil: 'load' });
|
|
154
|
-
await page
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
475
|
+
await waitForSlideRenderReady(page, options);
|
|
476
|
+
|
|
477
|
+
const slideFrame = await detectSlideFrame(page);
|
|
478
|
+
const normalizedSlideFrame = await isolateSlideFrame(page, slideFrame);
|
|
479
|
+
await normalizeBodyToSlideFrame(page, normalizedSlideFrame);
|
|
480
|
+
await waitForSlideRenderReady(page, { ...options, runReadySignal: false });
|
|
481
|
+
|
|
482
|
+
if (mode === 'capture') {
|
|
483
|
+
const viewportSize = {
|
|
484
|
+
width: normalizeDimension(normalizedSlideFrame.width, FALLBACK_SLIDE_SIZE.width),
|
|
485
|
+
height: normalizeDimension(normalizedSlideFrame.height, FALLBACK_SLIDE_SIZE.height),
|
|
486
|
+
};
|
|
487
|
+
await page.setViewportSize(viewportSize);
|
|
488
|
+
await waitForSlideRenderReady(page, { ...options, runReadySignal: false });
|
|
489
|
+
const pngBytes = await page.screenshot({
|
|
490
|
+
type: 'png',
|
|
491
|
+
clip: {
|
|
492
|
+
x: 0,
|
|
493
|
+
y: 0,
|
|
494
|
+
width: viewportSize.width,
|
|
495
|
+
height: viewportSize.height,
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
mode,
|
|
500
|
+
width: normalizedSlideFrame.width,
|
|
501
|
+
height: normalizedSlideFrame.height,
|
|
502
|
+
pngBytes,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
159
505
|
|
|
160
|
-
|
|
161
|
-
|
|
506
|
+
return {
|
|
507
|
+
mode,
|
|
508
|
+
width: normalizedSlideFrame.width,
|
|
509
|
+
height: normalizedSlideFrame.height,
|
|
510
|
+
pdfBytes: await page.pdf(buildPdfOptions(normalizedSlideFrame.width, normalizedSlideFrame.height)),
|
|
511
|
+
};
|
|
162
512
|
}
|
|
163
513
|
|
|
164
514
|
export async function mergePdfBuffers(pdfBuffers) {
|
|
@@ -176,6 +526,25 @@ export async function mergePdfBuffers(pdfBuffers) {
|
|
|
176
526
|
return outputPdf.save();
|
|
177
527
|
}
|
|
178
528
|
|
|
529
|
+
export async function buildCapturePdf(slides) {
|
|
530
|
+
const outputPdf = await PDFDocument.create();
|
|
531
|
+
|
|
532
|
+
for (const slide of slides) {
|
|
533
|
+
const pageWidth = cssPixelsToPdfPoints(slide.width);
|
|
534
|
+
const pageHeight = cssPixelsToPdfPoints(slide.height);
|
|
535
|
+
const page = outputPdf.addPage([pageWidth, pageHeight]);
|
|
536
|
+
const image = await outputPdf.embedPng(slide.pngBytes);
|
|
537
|
+
page.drawImage(image, {
|
|
538
|
+
x: 0,
|
|
539
|
+
y: 0,
|
|
540
|
+
width: pageWidth,
|
|
541
|
+
height: pageHeight,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return outputPdf.save();
|
|
546
|
+
}
|
|
547
|
+
|
|
179
548
|
async function main() {
|
|
180
549
|
const options = parseCliArgs(process.argv.slice(2));
|
|
181
550
|
if (options.help) {
|
|
@@ -184,29 +553,47 @@ async function main() {
|
|
|
184
553
|
}
|
|
185
554
|
|
|
186
555
|
const slidesDir = resolve(process.cwd(), options.slidesDir);
|
|
556
|
+
await ensureSlidesPassValidation(slidesDir, { exportLabel: 'PDF export' });
|
|
187
557
|
const slideFiles = await findSlideFiles(slidesDir);
|
|
188
558
|
if (slideFiles.length === 0) {
|
|
189
559
|
throw new Error(`No slide-*.html files found in: ${slidesDir}`);
|
|
190
560
|
}
|
|
191
561
|
|
|
192
562
|
const browser = await chromium.launch({ headless: true });
|
|
193
|
-
const page = await browser.newPage();
|
|
194
|
-
const
|
|
563
|
+
const page = await browser.newPage(buildPageOptions(options.mode));
|
|
564
|
+
const diagnostics = createSlideDiagnostics();
|
|
565
|
+
diagnostics.attach(page);
|
|
566
|
+
const renderedSlides = [];
|
|
195
567
|
|
|
196
568
|
try {
|
|
197
569
|
for (const slideFile of slideFiles) {
|
|
198
|
-
|
|
199
|
-
|
|
570
|
+
diagnostics.beginSlide(slideFile);
|
|
571
|
+
try {
|
|
572
|
+
const slideResult = await renderSlideToPdf(page, slideFile, slidesDir, { mode: options.mode });
|
|
573
|
+
renderedSlides.push(slideResult);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
throw decorateError(error, slideFile, diagnostics.getSlideDiagnostics(slideFile));
|
|
576
|
+
} finally {
|
|
577
|
+
const slideDiagnostics = diagnostics.getSlideDiagnostics(slideFile);
|
|
578
|
+
if (slideDiagnostics.length > 0) {
|
|
579
|
+
process.stderr.write(`[slides-grab] Diagnostics for ${slideFile}:\n${formatDiagnostics(slideFile, slideDiagnostics)}\n`);
|
|
580
|
+
}
|
|
581
|
+
diagnostics.endSlide();
|
|
582
|
+
}
|
|
200
583
|
}
|
|
201
584
|
} finally {
|
|
202
585
|
await browser.close();
|
|
203
586
|
}
|
|
204
587
|
|
|
205
|
-
const mergedPdf =
|
|
588
|
+
const mergedPdf =
|
|
589
|
+
options.mode === 'capture'
|
|
590
|
+
? await buildCapturePdf(renderedSlides)
|
|
591
|
+
: await mergePdfBuffers(renderedSlides.map((slide) => slide.pdfBytes));
|
|
592
|
+
|
|
206
593
|
const outputPath = resolve(process.cwd(), options.output);
|
|
207
594
|
await writeFile(outputPath, mergedPdf);
|
|
208
595
|
|
|
209
|
-
process.stdout.write(`Generated PDF: ${outputPath}\n`);
|
|
596
|
+
process.stdout.write(`Generated PDF (${options.mode} mode): ${outputPath}\n`);
|
|
210
597
|
}
|
|
211
598
|
|
|
212
599
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import PptxGenJS from 'pptxgenjs';
|
|
8
|
+
|
|
9
|
+
import { ensureOutputDirectory, SLIDE_FILE_PATTERN, sortFigmaSlideFiles } from '../src/figma.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const html2pptx = require('../src/html2pptx.cjs');
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SLIDES_DIR = 'slides';
|
|
15
|
+
const DEFAULT_OUTPUT = 'output.pptx';
|
|
16
|
+
|
|
17
|
+
function printUsage() {
|
|
18
|
+
process.stdout.write(
|
|
19
|
+
[
|
|
20
|
+
'Usage: node scripts/html2pptx.js [options]',
|
|
21
|
+
'',
|
|
22
|
+
'Options:',
|
|
23
|
+
` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
|
|
24
|
+
` --output <path> Output PPTX file (default: ${DEFAULT_OUTPUT})`,
|
|
25
|
+
' -h, --help Show this help message',
|
|
26
|
+
'',
|
|
27
|
+
'Experimental / unstable PPTX export. Treat output as best-effort only.',
|
|
28
|
+
].join('\n'),
|
|
29
|
+
);
|
|
30
|
+
process.stdout.write('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readOptionValue(args, index, optionName) {
|
|
34
|
+
const next = args[index + 1];
|
|
35
|
+
if (!next || next.startsWith('-')) {
|
|
36
|
+
throw new Error(`Missing value for ${optionName}.`);
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(args) {
|
|
42
|
+
const options = {
|
|
43
|
+
slidesDir: DEFAULT_SLIDES_DIR,
|
|
44
|
+
output: DEFAULT_OUTPUT,
|
|
45
|
+
help: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
49
|
+
const arg = args[i];
|
|
50
|
+
if (arg === '-h' || arg === '--help') {
|
|
51
|
+
options.help = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (arg === '--slides-dir') {
|
|
56
|
+
options.slidesDir = readOptionValue(args, i, '--slides-dir');
|
|
57
|
+
i += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (arg.startsWith('--slides-dir=')) {
|
|
62
|
+
options.slidesDir = arg.slice('--slides-dir='.length);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (arg === '--output') {
|
|
67
|
+
options.output = readOptionValue(args, i, '--output');
|
|
68
|
+
i += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (arg.startsWith('--output=')) {
|
|
73
|
+
options.output = arg.slice('--output='.length);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof options.slidesDir !== 'string' || options.slidesDir.trim() === '') {
|
|
81
|
+
throw new Error('--slides-dir must be a non-empty string.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof options.output !== 'string' || options.output.trim() === '') {
|
|
85
|
+
throw new Error('--output must be a non-empty string.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
options.slidesDir = options.slidesDir.trim();
|
|
89
|
+
options.output = options.output.trim();
|
|
90
|
+
return options;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getHtmlSlides(slidesDir) {
|
|
94
|
+
if (!existsSync(slidesDir)) {
|
|
95
|
+
throw new Error(`Slides directory not found: ${slidesDir}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const files = readdirSync(slidesDir)
|
|
99
|
+
.filter((fileName) => SLIDE_FILE_PATTERN.test(fileName))
|
|
100
|
+
.sort(sortFigmaSlideFiles);
|
|
101
|
+
|
|
102
|
+
if (files.length === 0) {
|
|
103
|
+
throw new Error(`No slide-*.html files found in ${slidesDir}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
const options = parseArgs(process.argv.slice(2));
|
|
111
|
+
if (options.help) {
|
|
112
|
+
printUsage();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const slidesDir = resolve(process.cwd(), options.slidesDir);
|
|
117
|
+
const outputFile = resolve(process.cwd(), options.output);
|
|
118
|
+
const files = getHtmlSlides(slidesDir);
|
|
119
|
+
|
|
120
|
+
const pres = new PptxGenJS();
|
|
121
|
+
pres.layout = 'LAYOUT_WIDE';
|
|
122
|
+
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
await html2pptx(resolve(slidesDir, file), pres);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await ensureOutputDirectory(outputFile);
|
|
128
|
+
await pres.writeFile({ fileName: outputFile });
|
|
129
|
+
process.stdout.write(`Generated PPTX: ${outputFile}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main().catch((error) => {
|
|
133
|
+
console.error(`[slides-grab] ${error.message}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
});
|