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.
@@ -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> Output PDF path (default: ${DEFAULT_OUTPUT})`,
29
+ ` --output <path> Output PDF path (default: ${DEFAULT_OUTPUT})`,
21
30
  ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
22
- ' -h, --help Show this help message',
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
- async function getSlideSize(page) {
132
- const size = await page.evaluate(() => {
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 rect = body.getBoundingClientRect();
135
- const style = window.getComputedStyle(body);
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: Number.parseFloat(style.width) || rect.width || 0,
139
- height: Number.parseFloat(style.height) || rect.height || 0,
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
- width: normalizeDimension(size.width, FALLBACK_SLIDE_SIZE.width),
145
- height: normalizeDimension(size.height, FALLBACK_SLIDE_SIZE.height),
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.evaluate(async () => {
155
- if (document.fonts?.ready) {
156
- await document.fonts.ready;
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
- const size = await getSlideSize(page);
161
- return page.pdf(buildPdfOptions(size.width, size.height));
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 slidePdfs = [];
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
- const slidePdf = await renderSlideToPdf(page, slideFile, slidesDir);
199
- slidePdfs.push(slidePdf);
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 = await mergePdfBuffers(slidePdfs);
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
+ });