slides-grab 1.2.6 → 1.3.1

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.
Files changed (33) hide show
  1. package/README-ko.md +258 -0
  2. package/README.md +16 -12
  3. package/bin/ppt-agent.js +195 -1
  4. package/package.json +11 -6
  5. package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
  6. package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
  7. package/scripts/design-gate.js +241 -0
  8. package/scripts/editor-server.js +1 -0
  9. package/scripts/html2png.js +246 -0
  10. package/scripts/install-runtime.js +216 -0
  11. package/skills/slides-grab/SKILL.md +14 -12
  12. package/skills/slides-grab/references/presentation-workflow-reference.md +1 -1
  13. package/skills/slides-grab-card-news/SKILL.md +1 -1
  14. package/skills/slides-grab-design/SKILL.md +15 -7
  15. package/skills/slides-grab-design/references/design-gate.md +349 -0
  16. package/skills/slides-grab-design/references/design-rules.md +3 -3
  17. package/skills/slides-grab-design/references/design-system-full.md +4 -4
  18. package/skills/slides-grab-export/SKILL.md +5 -4
  19. package/skills/slides-grab-export/references/pptx-skill-reference.md +7 -42
  20. package/skills/slides-grab-plan/SKILL.md +20 -7
  21. package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
  22. package/skills/slides-grab-plan/references/plan-workflow-reference.md +14 -14
  23. package/src/design-diversity-data.js +6932 -0
  24. package/src/design-gate-report.js +244 -0
  25. package/src/design-gate-state.js +294 -0
  26. package/src/design-import.js +164 -0
  27. package/src/design-md-parser.js +415 -0
  28. package/src/design-styles.js +86 -4
  29. package/src/editor/codex-edit.js +61 -2
  30. package/src/editor/editor.html +1 -1
  31. package/src/editor/js/model-registry.js +1 -1
  32. package/templates/design-styles/README.md +2 -1
  33. package/templates/design-styles/preview.html +1088 -6
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: design-critic-agent
3
+ description: Run the slides-grab design gate before export.
4
+ tools: Read, Grep, Glob, Bash, Task
5
+ ---
6
+
7
+ Use the canonical gate in `skills/slides-grab-design/references/design-gate.md`.
8
+
9
+ Required workflow:
10
+
11
+ 1. Run `slides-grab validate --slides-dir <slides-dir>`.
12
+ 2. Render evidence with `slides-grab png --slides-dir <slides-dir> --output-dir <slides-dir>/.slides-grab/gate-preview`.
13
+ 3. Produce two read-only review reports:
14
+ - Pass A: System Contract / Constraint Integrity.
15
+ - Pass B: Audience Impact / Expressive Readability.
16
+ Each `Proceed` report must use the CLI-enforced structure from `skills/slides-grab-design/references/design-gate.md`: role title, `VERDICT: PASS`, confidence, rendered PNG evidence filenames, current `slide-*.html: <sha256>` fingerprints, `Unresolved Critical: 0`, `Blocking findings: None`, findings table, and all required checks marked `PASS`.
17
+ 4. If both passes conclude Proceed, record the gate with:
18
+
19
+ ```bash
20
+ slides-grab design-gate --slides-dir <slides-dir> --verdict proceed --pass-a-report <pass-a.md> --pass-b-report <pass-b.md>
21
+ ```
22
+
23
+ If either pass finds blocking issues, or if `slides-grab design-gate` rejects the reports, fix the slides/reports and repeat from validation and fresh rendered evidence. Do not run `slides-grab pdf`, `slides-grab convert`, or `slides-grab figma` until the CLI gate records `proceed`.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: slides-grab-design-critic
3
+ description: Run the slides-grab design gate before export.
4
+ ---
5
+
6
+ Use the canonical gate in `skills/slides-grab-design/references/design-gate.md`.
7
+
8
+ Required workflow:
9
+
10
+ 1. Run `slides-grab validate --slides-dir <slides-dir>`.
11
+ 2. Render evidence with `slides-grab png --slides-dir <slides-dir> --output-dir <slides-dir>/.slides-grab/gate-preview`.
12
+ 3. Produce two read-only review reports:
13
+ - Pass A: System Contract / Constraint Integrity.
14
+ - Pass B: Audience Impact / Expressive Readability.
15
+ Each `Proceed` report must use the CLI-enforced structure from `skills/slides-grab-design/references/design-gate.md`: role title, `VERDICT: PASS`, confidence, rendered PNG evidence filenames, current `slide-*.html: <sha256>` fingerprints, `Unresolved Critical: 0`, `Blocking findings: None`, findings table, and all required checks marked `PASS`.
16
+ 4. If both passes conclude Proceed, record the gate with:
17
+
18
+ ```bash
19
+ slides-grab design-gate --slides-dir <slides-dir> --verdict proceed --pass-a-report <pass-a.md> --pass-b-report <pass-b.md>
20
+ ```
21
+
22
+ If either pass finds blocking issues, or if `slides-grab design-gate` rejects the reports, fix the slides/reports and repeat from validation and fresh rendered evidence. Do not run `slides-grab pdf`, `slides-grab convert`, or `slides-grab figma` until the CLI gate records `proceed`.
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdir, readFile } from 'node:fs/promises';
6
+ import { dirname, join, relative, resolve } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ import { ensureSlidesPassValidation } from './validate-slides.js';
10
+ import { assertProceedReportsComplete } from '../src/design-gate-report.js';
11
+ import {
12
+ buildDesignGatePaths,
13
+ buildDesignGateReport,
14
+ collectFileFingerprints,
15
+ collectSlideFingerprints,
16
+ createDesignGateState,
17
+ findGateSlideFiles,
18
+ normalizeGateVerdict,
19
+ writeDesignGateState,
20
+ } from '../src/design-gate-state.js';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const packageRoot = resolve(__dirname, '..');
24
+ const DEFAULT_SLIDES_DIR = 'slides';
25
+ const DEFAULT_SLIDE_MODE = 'presentation';
26
+ const DEFAULT_RESOLUTION = '2160p';
27
+
28
+ function printUsage() {
29
+ process.stdout.write(
30
+ [
31
+ 'Usage: slides-grab design-gate [options]',
32
+ '',
33
+ 'Options:',
34
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
35
+ ` --slide-mode <mode> Slide mode: presentation|card-news (default: ${DEFAULT_SLIDE_MODE})`,
36
+ ` --resolution <preset> PNG evidence resolution preset (default: ${DEFAULT_RESOLUTION})`,
37
+ ' --verdict <verdict> Gate verdict: proceed|revise|rethink',
38
+ ' --pass-a-report <path> Pass A review report file',
39
+ ' --pass-b-report <path> Pass B review report file',
40
+ ' --output-dir <path> PNG evidence directory (default: <slides-dir>/.slides-grab/gate-preview)',
41
+ ' -h, --help Show this help message',
42
+ ].join('\n'),
43
+ );
44
+ process.stdout.write('\n');
45
+ }
46
+
47
+ function parseArgs(args) {
48
+ const options = {
49
+ slidesDir: DEFAULT_SLIDES_DIR,
50
+ slideMode: DEFAULT_SLIDE_MODE,
51
+ resolution: DEFAULT_RESOLUTION,
52
+ verdict: '',
53
+ passAReport: '',
54
+ passBReport: '',
55
+ outputDir: '',
56
+ help: false,
57
+ };
58
+
59
+ for (let index = 0; index < args.length; index += 1) {
60
+ const arg = args[index];
61
+ if (arg === '-h' || arg === '--help') {
62
+ options.help = true;
63
+ continue;
64
+ }
65
+
66
+ if (ARGUMENT_READERS.has(arg)) {
67
+ options[ARGUMENT_READERS.get(arg)] = readOptionValue(args, index, arg);
68
+ index += 1;
69
+ continue;
70
+ }
71
+
72
+ const equalSignIndex = arg.indexOf('=');
73
+ if (equalSignIndex > -1) {
74
+ const name = arg.slice(0, equalSignIndex);
75
+ const value = arg.slice(equalSignIndex + 1);
76
+ if (ARGUMENT_READERS.has(name)) {
77
+ options[ARGUMENT_READERS.get(name)] = value;
78
+ continue;
79
+ }
80
+ }
81
+
82
+ throw new Error(`Unknown option: ${arg}`);
83
+ }
84
+
85
+ if (options.help) return options;
86
+
87
+ options.slidesDir = requireNonEmpty(options.slidesDir, '--slides-dir');
88
+ options.slideMode = requireNonEmpty(options.slideMode, '--slide-mode');
89
+ options.resolution = requireNonEmpty(options.resolution, '--resolution');
90
+ options.verdict = normalizeGateVerdict(requireNonEmpty(options.verdict, '--verdict'));
91
+ options.passAReport = requireNonEmpty(options.passAReport, '--pass-a-report');
92
+ options.passBReport = requireNonEmpty(options.passBReport, '--pass-b-report');
93
+ if (options.outputDir) options.outputDir = options.outputDir.trim();
94
+ return options;
95
+ }
96
+
97
+ const ARGUMENT_READERS = new Map([
98
+ ['--slides-dir', 'slidesDir'],
99
+ ['--slide-mode', 'slideMode'],
100
+ ['--resolution', 'resolution'],
101
+ ['--verdict', 'verdict'],
102
+ ['--pass-a-report', 'passAReport'],
103
+ ['--pass-b-report', 'passBReport'],
104
+ ['--output-dir', 'outputDir'],
105
+ ]);
106
+
107
+ function readOptionValue(args, index, optionName) {
108
+ const value = args[index + 1];
109
+ if (!value || value.startsWith('-')) {
110
+ throw new Error(`Missing value for ${optionName}.`);
111
+ }
112
+ return value;
113
+ }
114
+
115
+ function requireNonEmpty(value, optionName) {
116
+ const trimmed = String(value || '').trim();
117
+ if (!trimmed) throw new Error(`${optionName} must be a non-empty string.`);
118
+ return trimmed;
119
+ }
120
+
121
+ async function renderGateEvidence(options, outputDir) {
122
+ await mkdir(outputDir, { recursive: true });
123
+ const scriptPath = resolve(packageRoot, 'scripts/html2png.js');
124
+ const args = [
125
+ scriptPath,
126
+ '--slides-dir',
127
+ options.slidesDir,
128
+ '--output-dir',
129
+ outputDir,
130
+ '--slide-mode',
131
+ options.slideMode,
132
+ '--resolution',
133
+ options.resolution,
134
+ ];
135
+
136
+ await new Promise((resolvePromise, rejectPromise) => {
137
+ const child = spawn(process.execPath, args, {
138
+ cwd: process.cwd(),
139
+ stdio: 'inherit',
140
+ env: { ...process.env, PPT_AGENT_PACKAGE_ROOT: packageRoot },
141
+ });
142
+ child.on('error', rejectPromise);
143
+ child.on('close', (code, signal) => {
144
+ if (signal) {
145
+ rejectPromise(new Error(`PNG evidence render terminated by signal ${signal}.`));
146
+ return;
147
+ }
148
+ if (code !== 0) {
149
+ rejectPromise(new Error(`PNG evidence render failed with exit code ${code}.`));
150
+ return;
151
+ }
152
+ resolvePromise();
153
+ });
154
+ });
155
+ }
156
+
157
+ async function readEvidenceReport(filePath, label) {
158
+ const resolvedPath = resolve(process.cwd(), filePath);
159
+ if (!existsSync(resolvedPath)) {
160
+ throw new Error(`${label} report not found: ${resolvedPath}`);
161
+ }
162
+ const report = (await readFile(resolvedPath, 'utf-8')).trim();
163
+ if (!report) {
164
+ throw new Error(`${label} report is empty: ${resolvedPath}`);
165
+ }
166
+ return { resolvedPath, report };
167
+ }
168
+
169
+ export async function main(argv = process.argv.slice(2)) {
170
+ const options = parseArgs(argv);
171
+ if (options.help) {
172
+ printUsage();
173
+ return;
174
+ }
175
+
176
+ const paths = buildDesignGatePaths(options.slidesDir);
177
+ const previewDir = options.outputDir ? resolve(process.cwd(), options.outputDir) : paths.previewDir;
178
+ await ensureSlidesPassValidation(paths.slidesDir, {
179
+ exportLabel: 'design gate',
180
+ slideMode: options.slideMode,
181
+ shouldBlockIssue: () => true,
182
+ });
183
+ await renderGateEvidence(options, previewDir);
184
+
185
+ const passA = await readEvidenceReport(options.passAReport, 'Pass A');
186
+ const passB = await readEvidenceReport(options.passBReport, 'Pass B');
187
+ let gateValidation = { status: options.verdict === 'proceed' ? 'passed' : 'not-run' };
188
+ const slideFiles = await findGateSlideFiles(paths.slidesDir);
189
+ const slideFingerprints = await collectSlideFingerprints(paths.slidesDir);
190
+ const evidenceFiles = slideFiles.map((fileName) => fileName.replace(/\.html$/i, '.png'));
191
+ const previewRelativeDir = relative(paths.slidesDir, previewDir);
192
+ const previewFingerprintFiles = evidenceFiles.map((fileName) => join(previewRelativeDir, fileName));
193
+ const passReportFingerprintFiles = [
194
+ relative(paths.slidesDir, passA.resolvedPath),
195
+ relative(paths.slidesDir, passB.resolvedPath),
196
+ ];
197
+
198
+ if (options.verdict === 'proceed') {
199
+ gateValidation = assertProceedReportsComplete({
200
+ passAReport: passA.report,
201
+ passBReport: passB.report,
202
+ evidenceFiles,
203
+ slideFingerprints,
204
+ });
205
+ }
206
+ const state = await createDesignGateState({
207
+ slidesDir: paths.slidesDir,
208
+ slideMode: options.slideMode,
209
+ resolution: options.resolution,
210
+ verdict: options.verdict,
211
+ previewDir,
212
+ reportPath: paths.reportPath,
213
+ passA: { reportPath: passA.resolvedPath, summary: firstLine(passA.report), ...gateValidation.passA },
214
+ passB: { reportPath: passB.resolvedPath, summary: firstLine(passB.report), ...gateValidation.passB },
215
+ gateValidation,
216
+ passReportFingerprints: await collectFileFingerprints(paths.slidesDir, passReportFingerprintFiles),
217
+ previewFingerprints: await collectFileFingerprints(paths.slidesDir, previewFingerprintFiles),
218
+ });
219
+ const report = buildDesignGateReport(state, passA.report, passB.report);
220
+ await writeDesignGateState(paths.slidesDir, state, report);
221
+
222
+ process.stdout.write(`Design gate recorded: ${state.verdict}\n`);
223
+ process.stdout.write(`Evidence PNGs: ${previewDir}\n`);
224
+ process.stdout.write(`Gate report: ${paths.reportPath}\n`);
225
+ process.stdout.write(`Gate state: ${paths.statePath}\n`);
226
+
227
+ if (state.verdict !== 'proceed') {
228
+ process.exitCode = 1;
229
+ }
230
+ }
231
+
232
+ function firstLine(value) {
233
+ return value.split(/\r?\n/).find((line) => line.trim())?.trim() || '';
234
+ }
235
+
236
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
237
+ main().catch((error) => {
238
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
239
+ process.exit(1);
240
+ });
241
+ }
@@ -816,6 +816,7 @@ async function startServer(opts) {
816
816
  userPrompt: prompt,
817
817
  slideMode: opts.mode,
818
818
  selections: normalizedSelections,
819
+ designBaseDir: slidesDirectory,
819
820
  });
820
821
 
821
822
  const usesClaude = isClaudeModel(selectedModel);
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readdir } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { chromium } from 'playwright';
8
+
9
+ import { ensureSlidesPassValidation } from './validate-slides.js';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const {
13
+ getResolutionChoices,
14
+ getResolutionSize,
15
+ normalizeResolutionPreset,
16
+ } = require('../src/export-resolution.cjs');
17
+ const {
18
+ DEFAULT_SLIDE_MODE,
19
+ getSlideModeChoices,
20
+ getSlideModeConfig,
21
+ normalizeSlideMode,
22
+ } = require('../src/slide-mode.cjs');
23
+
24
+ const DEFAULT_SLIDES_DIR = 'slides';
25
+ const DEFAULT_RESOLUTION = '2160p';
26
+ const DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR = 2;
27
+ const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
28
+ const RENDER_SETTLE_MS = 120;
29
+
30
+ function printUsage() {
31
+ process.stdout.write(
32
+ [
33
+ 'Usage: node scripts/html2png.js [options]',
34
+ '',
35
+ 'Options:',
36
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
37
+ ' --output-dir <path> Output directory for PNG files (default: <slides-dir>/out-png)',
38
+ ` --slide-mode <mode> Slide mode: ${getSlideModeChoices().join('|')} (default: ${DEFAULT_SLIDE_MODE})`,
39
+ ` --resolution <preset> Raster size preset: ${getResolutionChoices().join('|')}|4k (default: ${DEFAULT_RESOLUTION})`,
40
+ ' -h, --help Show this help message',
41
+ '',
42
+ 'Examples:',
43
+ ' node scripts/html2png.js --slides-dir slides',
44
+ ' node scripts/html2png.js --slides-dir cards --slide-mode card-news',
45
+ ' node scripts/html2png.js --slides-dir slides --resolution 1440p',
46
+ ].join('\n'),
47
+ );
48
+ process.stdout.write('\n');
49
+ }
50
+
51
+ function readOptionValue(args, index, optionName) {
52
+ const next = args[index + 1];
53
+ if (!next || next.startsWith('-')) {
54
+ throw new Error(`Missing value for ${optionName}.`);
55
+ }
56
+ return next;
57
+ }
58
+
59
+ function toSlideOrder(fileName) {
60
+ const match = fileName.match(/\d+/);
61
+ return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
62
+ }
63
+
64
+ function sortSlideFiles(a, b) {
65
+ const orderA = toSlideOrder(a);
66
+ const orderB = toSlideOrder(b);
67
+ if (orderA !== orderB) return orderA - orderB;
68
+ return a.localeCompare(b);
69
+ }
70
+
71
+ function parseCliArgs(args) {
72
+ const options = {
73
+ slidesDir: DEFAULT_SLIDES_DIR,
74
+ outputDir: '',
75
+ slideMode: DEFAULT_SLIDE_MODE,
76
+ resolution: DEFAULT_RESOLUTION,
77
+ help: false,
78
+ };
79
+
80
+ for (let i = 0; i < args.length; i += 1) {
81
+ const arg = args[i];
82
+
83
+ if (arg === '-h' || arg === '--help') {
84
+ options.help = true;
85
+ continue;
86
+ }
87
+
88
+ if (arg === '--slides-dir') {
89
+ options.slidesDir = readOptionValue(args, i, '--slides-dir');
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (arg.startsWith('--slides-dir=')) {
94
+ options.slidesDir = arg.slice('--slides-dir='.length);
95
+ continue;
96
+ }
97
+
98
+ if (arg === '--output-dir') {
99
+ options.outputDir = readOptionValue(args, i, '--output-dir');
100
+ i += 1;
101
+ continue;
102
+ }
103
+ if (arg.startsWith('--output-dir=')) {
104
+ options.outputDir = arg.slice('--output-dir='.length);
105
+ continue;
106
+ }
107
+
108
+ if (arg === '--slide-mode') {
109
+ options.slideMode = normalizeSlideMode(
110
+ readOptionValue(args, i, '--slide-mode'),
111
+ { optionName: '--slide-mode' },
112
+ );
113
+ i += 1;
114
+ continue;
115
+ }
116
+ if (arg.startsWith('--slide-mode=')) {
117
+ options.slideMode = normalizeSlideMode(
118
+ arg.slice('--slide-mode='.length),
119
+ { optionName: '--slide-mode' },
120
+ );
121
+ continue;
122
+ }
123
+
124
+ if (arg === '--resolution') {
125
+ options.resolution = normalizeResolutionPreset(
126
+ readOptionValue(args, i, '--resolution'),
127
+ { allowEmpty: false },
128
+ );
129
+ i += 1;
130
+ continue;
131
+ }
132
+ if (arg.startsWith('--resolution=')) {
133
+ options.resolution = normalizeResolutionPreset(
134
+ arg.slice('--resolution='.length),
135
+ { allowEmpty: false },
136
+ );
137
+ continue;
138
+ }
139
+
140
+ throw new Error(`Unknown option: ${arg}`);
141
+ }
142
+
143
+ return options;
144
+ }
145
+
146
+ function computeDeviceScaleFactor(resolution, slideMode) {
147
+ const { framePx } = getSlideModeConfig(slideMode);
148
+ const target = getResolutionSize(resolution, slideMode);
149
+ if (!target) return DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR;
150
+ return target.height / framePx.height;
151
+ }
152
+
153
+ async function discoverSlideFiles(slidesDir) {
154
+ const entries = await readdir(slidesDir);
155
+ return entries
156
+ .filter((name) => SLIDE_FILE_PATTERN.test(name))
157
+ .sort(sortSlideFiles);
158
+ }
159
+
160
+ async function renderSlideToPng(page, slidesDir, slideFile, outputPath) {
161
+ const url = pathToFileURL(join(slidesDir, slideFile)).href;
162
+ await page.goto(url, { waitUntil: 'load' });
163
+ await page.evaluate(async () => {
164
+ if (document.fonts?.ready) {
165
+ await document.fonts.ready;
166
+ }
167
+ });
168
+ await page.waitForTimeout(RENDER_SETTLE_MS);
169
+ await page.screenshot({ path: outputPath, fullPage: false });
170
+ }
171
+
172
+ async function main() {
173
+ let options;
174
+ try {
175
+ options = parseCliArgs(process.argv.slice(2));
176
+ } catch (error) {
177
+ process.stderr.write(`${error.message}\n`);
178
+ printUsage();
179
+ process.exit(2);
180
+ }
181
+
182
+ if (options.help) {
183
+ printUsage();
184
+ return;
185
+ }
186
+
187
+ const slidesDir = resolve(process.cwd(), options.slidesDir);
188
+ const outputDir = resolve(
189
+ process.cwd(),
190
+ options.outputDir || join(options.slidesDir, 'out-png'),
191
+ );
192
+
193
+ await ensureSlidesPassValidation(slidesDir, {
194
+ exportLabel: 'PNG export',
195
+ slideMode: options.slideMode,
196
+ });
197
+
198
+ const slideFiles = await discoverSlideFiles(slidesDir);
199
+ if (slideFiles.length === 0) {
200
+ process.stderr.write(`No slide-*.html files found in ${slidesDir}\n`);
201
+ process.exit(1);
202
+ }
203
+
204
+ await mkdir(outputDir, { recursive: true });
205
+
206
+ const { framePx } = getSlideModeConfig(options.slideMode);
207
+ const deviceScaleFactor = computeDeviceScaleFactor(
208
+ options.resolution,
209
+ options.slideMode,
210
+ );
211
+ const outputSize = getResolutionSize(options.resolution, options.slideMode) || {
212
+ width: framePx.width * deviceScaleFactor,
213
+ height: framePx.height * deviceScaleFactor,
214
+ };
215
+
216
+ process.stdout.write(
217
+ `Rendering ${slideFiles.length} slide(s) at ${outputSize.width}x${outputSize.height} (${options.slideMode})\n`,
218
+ );
219
+
220
+ const browser = await chromium.launch({ headless: true });
221
+ try {
222
+ const context = await browser.newContext({
223
+ viewport: { width: framePx.width, height: framePx.height },
224
+ deviceScaleFactor,
225
+ });
226
+ const page = await context.newPage();
227
+
228
+ for (const slideFile of slideFiles) {
229
+ const outputName = slideFile.replace(/\.html$/i, '.png');
230
+ const outputPath = join(outputDir, outputName);
231
+ await renderSlideToPng(page, slidesDir, slideFile, outputPath);
232
+ process.stdout.write(` ${outputName}\n`);
233
+ }
234
+
235
+ await context.close();
236
+ } finally {
237
+ await browser.close();
238
+ }
239
+
240
+ process.stdout.write(`PNG export complete: ${outputDir}\n`);
241
+ }
242
+
243
+ main().catch((error) => {
244
+ process.stderr.write(`${error?.stack || error?.message || error}\n`);
245
+ process.exit(1);
246
+ });