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 CHANGED
@@ -9,7 +9,7 @@ Simple things like text, size, or bold can still be edited manually, just like i
9
9
 
10
10
  <p align="center">
11
11
  The whole slides are HTML & CSS, the programming langauge (which is not) that outperformed by AI agents.<br>
12
- So the slides are beautiful, easily editable by AI agents, and can be converted to pdf and pptx.
12
+ So the slides are beautiful, easily editable by AI agents, and can be converted to PDF or to experimental / unstable PPTX formats.
13
13
  </p>
14
14
 
15
15
  <p align="center">
@@ -56,31 +56,62 @@ There are many AI tools that generate slide HTML. Almost none let you **visually
56
56
  - **Plan** — Agent creates a structured slide outline from your topic/files
57
57
  - **Design** — Agent generates each slide as a self-contained HTML file
58
58
  - **Edit** — Browser-based editor with bbox selection, direct text editing, and agent-powered rewrites
59
- - **Export** — One command to PPTX or PDF
59
+ - **Export** — One command to PDF, plus experimental / unstable PPTX or Figma-export flows
60
60
 
61
61
  ## CLI Commands
62
62
 
63
63
  All commands support `--slides-dir <path>` (default: `slides`).
64
64
 
65
+ On a fresh clone, only `--help`, `list-templates`, and `list-themes` work without a deck. `edit`, `build-viewer`, `validate`, `convert`, and `pdf` require an existing slides workspace containing `slide-*.html`.
66
+
65
67
  ```bash
66
68
  slides-grab edit # Launch visual slide editor
67
69
  slides-grab build-viewer # Build single-file viewer.html
68
70
  slides-grab validate # Validate slide HTML (Playwright-based)
69
- slides-grab convert # Export to PPTX
70
- slides-grab pdf # Export to PDF
71
+ slides-grab convert # Export to experimental / unstable PPTX
72
+ slides-grab figma # Export an experimental / unstable Figma Slides importable PPTX
73
+ slides-grab pdf # Export PDF in capture mode (default)
74
+ slides-grab pdf --mode print # Export searchable/selectable text PDF
71
75
  slides-grab list-templates # Show available slide templates
72
76
  slides-grab list-themes # Show available color themes
73
77
  ```
74
78
 
79
+ ## Image Contract
80
+
81
+ Slides should store local image files in `<slides-dir>/assets/` and reference them as `./assets/<file>` from each `slide-XX.html`.
82
+
83
+ - Preferred: `<img src="./assets/example.png" alt="...">`
84
+ - Allowed: `data:` URLs for fully self-contained slides
85
+ - Allowed with warnings: remote `https://` images
86
+ - Unsupported: absolute filesystem paths such as `/Users/...` or `C:\\...`
87
+
88
+ Run `slides-grab validate --slides-dir <path>` before export to catch missing local assets and discouraged path forms.
89
+
90
+ `slides-grab pdf` now defaults to `--mode capture`, which rasterizes each rendered slide into the PDF for better visual fidelity. Use `--mode print` when searchable/selectable browser text matters more than pixel-perfect parity.
91
+
75
92
  ### Multi-Deck Workflow
76
93
 
94
+ Prerequisite: create or generate a deck in `decks/my-deck/` first.
95
+
77
96
  ```bash
78
97
  slides-grab edit --slides-dir decks/my-deck
79
98
  slides-grab validate --slides-dir decks/my-deck
80
99
  slides-grab pdf --slides-dir decks/my-deck --output decks/my-deck.pdf
100
+ slides-grab pdf --slides-dir decks/my-deck --mode print --output decks/my-deck-searchable.pdf
81
101
  slides-grab convert --slides-dir decks/my-deck --output decks/my-deck.pptx
102
+ slides-grab figma --slides-dir decks/my-deck --output decks/my-deck-figma.pptx
82
103
  ```
83
104
 
105
+ > **Warning:** `slides-grab convert` and `slides-grab figma` are currently **experimental / unstable**. Expect best-effort output, layout shifts, and manual cleanup in PowerPoint or Figma.
106
+
107
+ ### Figma Workflow
108
+
109
+ ```bash
110
+ slides-grab figma --slides-dir decks/my-deck --output decks/my-deck-figma.pptx
111
+ ```
112
+
113
+ This command reuses the HTML to PPTX pipeline and emits a `.pptx` deck intended for manual import into Figma Slides via `Import`. It does not upload to Figma directly. The Figma export path is **experimental / unstable** and should be treated as best-effort only.
114
+
84
115
  ## Installation Guides
85
116
 
86
117
  - [Claude Code setup](docs/prompts/setup-claude.md)
@@ -117,4 +148,3 @@ docs/ Installation & usage guides
117
148
  ## Acknowledgment
118
149
 
119
150
  This project is built based on the [ppt_team_agent](https://github.com/uxjoseph/ppt_team_agent) by Builder Josh. Huge thanks to him!
120
-
package/bin/ppt-agent.js CHANGED
@@ -5,10 +5,25 @@ import { readFileSync } from 'node:fs';
5
5
  import { dirname, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { Command } from 'commander';
8
+ import {
9
+ getFigmaImportCaveats,
10
+ getFigmaManualImportInstructions,
11
+ } from '../src/figma.js';
8
12
 
9
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
14
  const packageRoot = resolve(__dirname, '..');
11
15
  const packageJson = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf-8'));
16
+ const figmaHelpText = [
17
+ '',
18
+ 'Creates an experimental / unstable PowerPoint file tuned for Figma Slides manual import.',
19
+ 'Treat both PPTX and Figma export as best-effort only.',
20
+ '',
21
+ 'Manual import:',
22
+ ` ${getFigmaManualImportInstructions()}`,
23
+ '',
24
+ 'Figma import caveats:',
25
+ ...getFigmaImportCaveats().map((caveat) => ` - ${caveat}`),
26
+ ].join('\n');
12
27
 
13
28
  /**
14
29
  * Run a Node.js script from the package, with CWD set to the user's directory.
@@ -49,6 +64,10 @@ async function runCommand(relativePath, args = []) {
49
64
  }
50
65
  }
51
66
 
67
+ function collectRepeatedOption(value, previous = []) {
68
+ return [...previous, value];
69
+ }
70
+
52
71
  const program = new Command();
53
72
 
54
73
  program
@@ -69,16 +88,22 @@ program
69
88
 
70
89
  program
71
90
  .command('validate')
91
+ .alias('lint')
72
92
  .description('Run structured validation on slide HTML files (Playwright-based)')
73
93
  .option('--slides-dir <path>', 'Slide directory', 'slides')
94
+ .option('--format <format>', 'Output format: concise, json, json-full', 'concise')
95
+ .option('--slide <file>', 'Validate only the named slide file (repeatable)', collectRepeatedOption, [])
74
96
  .action(async (options = {}) => {
75
- const args = ['--slides-dir', options.slidesDir];
97
+ const args = ['--slides-dir', options.slidesDir, '--format', options.format];
98
+ for (const slide of options.slide || []) {
99
+ args.push('--slide', String(slide));
100
+ }
76
101
  await runCommand('scripts/validate-slides.js', args);
77
102
  });
78
103
 
79
104
  program
80
105
  .command('convert')
81
- .description('Convert slide HTML files to PPTX')
106
+ .description('Convert slide HTML files to experimental / unstable PPTX')
82
107
  .option('--slides-dir <path>', 'Slide directory', 'slides')
83
108
  .option('--output <path>', 'Output PPTX file')
84
109
  .action(async (options = {}) => {
@@ -94,14 +119,33 @@ program
94
119
  .description('Convert slide HTML files to PDF')
95
120
  .option('--slides-dir <path>', 'Slide directory', 'slides')
96
121
  .option('--output <path>', 'Output PDF file')
122
+ .option('--mode <mode>', 'PDF export mode: capture for visual fidelity, print for searchable text', 'capture')
97
123
  .action(async (options = {}) => {
98
124
  const args = ['--slides-dir', options.slidesDir];
99
125
  if (options.output) {
100
126
  args.push('--output', String(options.output));
101
127
  }
128
+ if (options.mode) {
129
+ args.push('--mode', String(options.mode));
130
+ }
102
131
  await runCommand('scripts/html2pdf.js', args);
103
132
  });
104
133
 
134
+ program
135
+ .command('figma')
136
+ .description('Export an experimental / unstable Figma Slides importable PPTX')
137
+ .helpOption('-h, --help', 'Show this help message')
138
+ .option('--slides-dir <path>', 'Slide directory', 'slides')
139
+ .option('--output <path>', 'Output PPTX file (default: <slides-dir>-figma.pptx)')
140
+ .addHelpText('after', figmaHelpText)
141
+ .action(async (options = {}) => {
142
+ const args = ['--slides-dir', options.slidesDir];
143
+ if (options.output) {
144
+ args.push('--output', String(options.output));
145
+ }
146
+ await runCommand('scripts/figma-export.js', args);
147
+ });
148
+
105
149
  program
106
150
  .command('edit')
107
151
  .description('Start interactive slide editor with Codex image-based edit flow')
package/convert.cjs CHANGED
@@ -5,11 +5,36 @@ const fs = require('fs');
5
5
  const sharp = require('sharp');
6
6
 
7
7
  // Inline a simplified version that uses Playwright Chromium (not Chrome)
8
- const PT_PER_PX = 0.75;
9
- const PX_PER_IN = 96;
10
- const EMU_PER_IN = 914400;
11
8
  const DEFAULT_SLIDES_DIR = 'slides';
12
9
  const DEFAULT_OUTPUT = 'output.pptx';
10
+ const DEFAULT_CAPTURE_VIEWPORT = { width: 960, height: 540 };
11
+ const DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR = 2;
12
+ const TARGET_RASTER_DPI = 150;
13
+ const TARGET_SLIDE_SIZE_IN = { width: 13.33, height: 7.5 };
14
+
15
+ function normalizeDimension(value, fallback) {
16
+ if (!Number.isFinite(value) || value <= 0) {
17
+ return fallback;
18
+ }
19
+ return Math.max(1, Math.round(value));
20
+ }
21
+
22
+ function buildPageOptions() {
23
+ return {
24
+ viewport: {
25
+ width: DEFAULT_CAPTURE_VIEWPORT.width,
26
+ height: DEFAULT_CAPTURE_VIEWPORT.height,
27
+ },
28
+ deviceScaleFactor: DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR,
29
+ };
30
+ }
31
+
32
+ function getTargetRasterSize() {
33
+ return {
34
+ width: Math.round(TARGET_SLIDE_SIZE_IN.width * TARGET_RASTER_DPI),
35
+ height: Math.round(TARGET_SLIDE_SIZE_IN.height * TARGET_RASTER_DPI),
36
+ };
37
+ }
13
38
 
14
39
  function printUsage() {
15
40
  process.stdout.write(
@@ -88,7 +113,7 @@ function parseArgs(args) {
88
113
  async function convertSlide(htmlFile, pres, browser) {
89
114
  const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
90
115
 
91
- const page = await browser.newPage();
116
+ const page = await browser.newPage(buildPageOptions());
92
117
  await page.goto(`file://${filePath}`);
93
118
 
94
119
  const bodyDimensions = await page.evaluate(() => {
@@ -101,8 +126,8 @@ async function convertSlide(htmlFile, pres, browser) {
101
126
  });
102
127
 
103
128
  await page.setViewportSize({
104
- width: Math.round(bodyDimensions.width),
105
- height: Math.round(bodyDimensions.height)
129
+ width: normalizeDimension(bodyDimensions.width, DEFAULT_CAPTURE_VIEWPORT.width),
130
+ height: normalizeDimension(bodyDimensions.height, DEFAULT_CAPTURE_VIEWPORT.height)
106
131
  });
107
132
 
108
133
  // Take screenshot and add as full-slide image
@@ -110,11 +135,10 @@ async function convertSlide(htmlFile, pres, browser) {
110
135
  await page.close();
111
136
 
112
137
  // Resize to exact slide dimensions (13.33" x 7.5" at 150 DPI)
113
- const targetWidth = Math.round(13.33 * 150);
114
- const targetHeight = Math.round(7.5 * 150);
138
+ const targetSize = getTargetRasterSize();
115
139
 
116
140
  const resized = await sharp(screenshot)
117
- .resize(targetWidth, targetHeight, { fit: 'fill' })
141
+ .resize(targetSize.width, targetSize.height, { fit: 'fill' })
118
142
  .png()
119
143
  .toBuffer();
120
144
 
@@ -144,6 +168,8 @@ async function main() {
144
168
  pres.layout = 'LAYOUT_WIDE'; // 16:9
145
169
 
146
170
  const slidesDir = path.resolve(process.cwd(), options.slidesDir);
171
+ const { ensureSlidesPassValidation } = await import('./scripts/validate-slides.js');
172
+ await ensureSlidesPassValidation(slidesDir, { exportLabel: 'PPTX export' });
147
173
  const files = fs.readdirSync(slidesDir)
148
174
  .filter(f => f.endsWith('.html'))
149
175
  .sort();
@@ -178,7 +204,15 @@ async function main() {
178
204
  }
179
205
  }
180
206
 
181
- main().catch(err => {
182
- console.error('Fatal error:', err);
183
- process.exit(1);
184
- });
207
+ if (require.main === module) {
208
+ main().catch(err => {
209
+ console.error('Fatal error:', err);
210
+ process.exit(1);
211
+ });
212
+ }
213
+
214
+ module.exports = {
215
+ buildPageOptions,
216
+ getTargetRasterSize,
217
+ parseArgs,
218
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "slides-grab",
3
- "version": "1.0.0",
4
- "description": "Agent-first presentation framework — plan, design, and visually edit HTML slides with Claude Code or Codex, then export to PPTX/PDF",
3
+ "version": "1.1.2",
4
+ "description": "Agent-first presentation framework — plan, design, and visually edit HTML slides with Claude Code or Codex, then export to PDF or experimental/unstable PPTX/Figma formats",
5
5
  "license": "MIT",
6
6
  "author": "vkehfdl1",
7
7
  "repository": {
@@ -30,13 +30,30 @@
30
30
  "bin": {
31
31
  "slides-grab": "./bin/ppt-agent.js"
32
32
  },
33
+ "files": [
34
+ "bin/",
35
+ "convert.cjs",
36
+ "scripts/build-viewer.js",
37
+ "scripts/editor-server.js",
38
+ "scripts/figma-export.js",
39
+ "scripts/html2pdf.js",
40
+ "scripts/html2pptx.js",
41
+ "scripts/install-codex-skills.js",
42
+ "scripts/validate-slides.js",
43
+ "skills/",
44
+ "src/",
45
+ "templates/",
46
+ "themes/",
47
+ "LICENSE",
48
+ "README.md"
49
+ ],
33
50
  "scripts": {
34
- "html2pptx": "node .claude/skills/pptx-skill/scripts/html2pptx.js",
51
+ "html2pptx": "node scripts/html2pptx.js",
35
52
  "build-viewer": "node scripts/build-viewer.js",
36
53
  "validate": "node scripts/validate-slides.js",
37
54
  "convert": "node convert.cjs",
38
55
  "codex:install-skills": "node scripts/install-codex-skills.js --force",
39
- "test": "node --test tests/editor/editor-codex-edit.test.js tests/pdf/html2pdf.test.js",
56
+ "test": "node --test tests/editor/editor-codex-edit.test.js tests/pdf/html2pdf.test.js tests/pdf/html2pdf.e2e.test.js tests/figma/figma-export.test.js tests/image-contract/image-contract.test.js tests/validation/validate-slides.test.js",
40
57
  "test:e2e": "node --test tests/editor/editor-ui.e2e.test.js tests/editor/editor-concurrency.e2e.test.js"
41
58
  },
42
59
  "dependencies": {
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * build-viewer.js
5
+ *
6
+ * Builds a single viewer.html from slide-*.html files in selected --slides-dir.
7
+ * Works with file:// protocol — each slide is embedded via <iframe srcdoc="...">
8
+ * for perfect CSS isolation (no regex scoping needed).
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, readdirSync } from 'fs';
12
+ import { fileURLToPath } from 'url';
13
+ import { join, resolve } from 'path';
14
+
15
+ import { buildSlideRuntimeHtml } from '../src/image-contract.js';
16
+
17
+ const DEFAULT_SLIDES_DIR = 'slides';
18
+
19
+ function printUsage() {
20
+ process.stdout.write(
21
+ [
22
+ 'Usage: node scripts/build-viewer.js [options]',
23
+ '',
24
+ 'Options:',
25
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
26
+ ' -h, --help Show this help message',
27
+ ].join('\n'),
28
+ );
29
+ process.stdout.write('\n');
30
+ }
31
+
32
+ function readOptionValue(args, index, optionName) {
33
+ const next = args[index + 1];
34
+ if (!next || next.startsWith('-')) {
35
+ throw new Error(`Missing value for ${optionName}.`);
36
+ }
37
+ return next;
38
+ }
39
+
40
+ export function parseCliArgs(args) {
41
+ const options = {
42
+ slidesDir: DEFAULT_SLIDES_DIR,
43
+ help: false,
44
+ };
45
+
46
+ for (let i = 0; i < args.length; i += 1) {
47
+ const arg = args[i];
48
+
49
+ if (arg === '-h' || arg === '--help') {
50
+ options.help = true;
51
+ continue;
52
+ }
53
+
54
+ if (arg === '--slides-dir') {
55
+ options.slidesDir = readOptionValue(args, i, '--slides-dir');
56
+ i += 1;
57
+ continue;
58
+ }
59
+
60
+ if (arg.startsWith('--slides-dir=')) {
61
+ options.slidesDir = arg.slice('--slides-dir='.length);
62
+ continue;
63
+ }
64
+
65
+ throw new Error(`Unknown option: ${arg}`);
66
+ }
67
+
68
+ if (typeof options.slidesDir !== 'string' || options.slidesDir.trim() === '') {
69
+ throw new Error('--slides-dir must be a non-empty string.');
70
+ }
71
+
72
+ options.slidesDir = options.slidesDir.trim();
73
+ return options;
74
+ }
75
+
76
+ export function findSlideFiles(slidesDir) {
77
+ return readdirSync(slidesDir)
78
+ .filter((file) => /^slide-\d+\.html$/i.test(file))
79
+ .sort((a, b) => {
80
+ const numA = parseInt(a.match(/\d+/)[0], 10);
81
+ const numB = parseInt(b.match(/\d+/)[0], 10);
82
+ return numA - numB || a.localeCompare(b);
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Escape HTML for safe embedding inside srcdoc="..." attribute.
88
+ * Must escape &, ", < so the srcdoc attribute value is valid.
89
+ */
90
+ export function escapeForSrcdoc(html) {
91
+ return html
92
+ .replace(/&/g, '&amp;')
93
+ .replace(/"/g, '&quot;');
94
+ }
95
+
96
+ export function loadSlides(slidesDir) {
97
+ return findSlideFiles(slidesDir).map((file) => {
98
+ const html = readFileSync(join(slidesDir, file), 'utf-8');
99
+ return {
100
+ file,
101
+ html: buildSlideRuntimeHtml(html, {
102
+ baseHref: './',
103
+ slideFile: file,
104
+ }),
105
+ };
106
+ });
107
+ }
108
+
109
+ export function buildViewerHtml(slides) {
110
+ return `<!DOCTYPE html>
111
+ <html lang="ko">
112
+ <head>
113
+ <meta charset="UTF-8">
114
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
115
+ <title>Slide Viewer</title>
116
+ <style>
117
+ * { margin: 0; padding: 0; box-sizing: border-box; }
118
+
119
+ html, body {
120
+ width: 100%;
121
+ height: 100%;
122
+ overflow: hidden;
123
+ background: #111;
124
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
125
+ }
126
+
127
+ .viewer-container {
128
+ width: 100%;
129
+ height: 100%;
130
+ display: flex;
131
+ flex-direction: column;
132
+ }
133
+
134
+ /* Navigation bar */
135
+ .nav-bar {
136
+ height: 48px;
137
+ background: #1a1a1a;
138
+ border-bottom: 1px solid #333;
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ gap: 16px;
143
+ flex-shrink: 0;
144
+ z-index: 100;
145
+ }
146
+
147
+ .nav-bar button {
148
+ background: #333;
149
+ color: #fff;
150
+ border: none;
151
+ border-radius: 6px;
152
+ padding: 6px 16px;
153
+ font-size: 13px;
154
+ font-family: inherit;
155
+ cursor: pointer;
156
+ transition: background 0.15s;
157
+ }
158
+
159
+ .nav-bar button:hover {
160
+ background: #555;
161
+ }
162
+
163
+ .nav-bar button:disabled {
164
+ opacity: 0.3;
165
+ cursor: default;
166
+ }
167
+
168
+ .slide-counter {
169
+ color: #aaa;
170
+ font-size: 14px;
171
+ font-weight: 500;
172
+ min-width: 60px;
173
+ text-align: center;
174
+ font-variant-numeric: tabular-nums;
175
+ }
176
+
177
+ .btn-fullscreen {
178
+ position: absolute;
179
+ right: 16px;
180
+ background: transparent !important;
181
+ font-size: 18px;
182
+ padding: 6px 10px !important;
183
+ }
184
+
185
+ /* Slide viewport */
186
+ .slide-viewport {
187
+ flex: 1;
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ overflow: hidden;
192
+ position: relative;
193
+ }
194
+
195
+ .slide-scaler {
196
+ width: 720pt;
197
+ height: 405pt;
198
+ position: relative;
199
+ transform-origin: center center;
200
+ }
201
+
202
+ /* Slide frames (iframes) */
203
+ .slide-frame {
204
+ position: absolute;
205
+ inset: 0;
206
+ width: 720pt;
207
+ height: 405pt;
208
+ border: none;
209
+ overflow: hidden;
210
+ opacity: 0;
211
+ pointer-events: none;
212
+ transition: opacity 0.25s ease;
213
+ }
214
+
215
+ .slide-frame.active {
216
+ opacity: 1;
217
+ pointer-events: auto;
218
+ }
219
+ </style>
220
+ </head>
221
+ <body>
222
+ <div class="viewer-container">
223
+ <!-- Navigation -->
224
+ <div class="nav-bar">
225
+ <button id="btn-prev" title="Previous (\\u2190)">Prev</button>
226
+ <span class="slide-counter" id="counter">1 / ${slides.length}</span>
227
+ <button id="btn-next" title="Next (\\u2192)">Next</button>
228
+ <button class="btn-fullscreen" id="btn-fs" title="Fullscreen (F)">&#x26F6;</button>
229
+ </div>
230
+
231
+ <!-- Slide viewport -->
232
+ <div class="slide-viewport" id="viewport">
233
+ <div class="slide-scaler" id="scaler">
234
+ ${slides.map((s, i) => ` <iframe class="slide-frame${i === 0 ? ' active' : ''}" data-slide="${i + 1}" srcdoc="${escapeForSrcdoc(s.html)}" sandbox="allow-same-origin"></iframe>`).join('\n')}
235
+ </div>
236
+ </div>
237
+ </div>
238
+
239
+ <script>
240
+ const TOTAL = ${slides.length};
241
+ let current = 1;
242
+
243
+ const frames = document.querySelectorAll('.slide-frame');
244
+ const counter = document.getElementById('counter');
245
+ const btnPrev = document.getElementById('btn-prev');
246
+ const btnNext = document.getElementById('btn-next');
247
+ const scaler = document.getElementById('scaler');
248
+ const viewport = document.getElementById('viewport');
249
+
250
+ function goTo(n) {
251
+ n = Math.max(1, Math.min(TOTAL, n));
252
+ if (n === current) return;
253
+ frames[current - 1].classList.remove('active');
254
+ current = n;
255
+ frames[current - 1].classList.add('active');
256
+ counter.textContent = current + ' / ' + TOTAL;
257
+ btnPrev.disabled = current === 1;
258
+ btnNext.disabled = current === TOTAL;
259
+ }
260
+
261
+ function next() { goTo(current + 1); }
262
+ function prev() { goTo(current - 1); }
263
+
264
+ btnPrev.addEventListener('click', prev);
265
+ btnNext.addEventListener('click', next);
266
+ btnPrev.disabled = true;
267
+
268
+ // Keyboard navigation
269
+ document.addEventListener('keydown', (e) => {
270
+ if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); next(); }
271
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }
272
+ else if (e.key === 'Home') { e.preventDefault(); goTo(1); }
273
+ else if (e.key === 'End') { e.preventDefault(); goTo(TOTAL); }
274
+ else if (e.key === 'f' || e.key === 'F') {
275
+ e.preventDefault();
276
+ toggleFullscreen();
277
+ }
278
+ });
279
+
280
+ // Fullscreen
281
+ function toggleFullscreen() {
282
+ if (!document.fullscreenElement) {
283
+ document.documentElement.requestFullscreen().catch(() => {});
284
+ } else {
285
+ document.exitFullscreen();
286
+ }
287
+ }
288
+ document.getElementById('btn-fs').addEventListener('click', toggleFullscreen);
289
+
290
+ // Auto-scale to fit viewport (95% fit)
291
+ function rescale() {
292
+ const vw = viewport.clientWidth;
293
+ const vh = viewport.clientHeight;
294
+ const slideW = scaler.offsetWidth;
295
+ const slideH = scaler.offsetHeight;
296
+ const scale = Math.min(vw / slideW, vh / slideH) * 0.95;
297
+ scaler.style.transform = 'scale(' + scale + ')';
298
+ }
299
+
300
+ window.addEventListener('resize', rescale);
301
+ document.addEventListener('fullscreenchange', () => setTimeout(rescale, 100));
302
+ rescale();
303
+ </script>
304
+ </body>
305
+ </html>`;
306
+ }
307
+
308
+ export function main(args = process.argv.slice(2)) {
309
+ const options = parseCliArgs(args);
310
+ if (options.help) {
311
+ printUsage();
312
+ return null;
313
+ }
314
+
315
+ const slidesDir = resolve(process.cwd(), options.slidesDir);
316
+ const output = join(slidesDir, 'viewer.html');
317
+
318
+ let slides;
319
+ try {
320
+ slides = loadSlides(slidesDir);
321
+ } catch (error) {
322
+ const message = error instanceof Error ? error.message : String(error);
323
+ process.stderr.write(`Failed to read slide directory: ${slidesDir}\n${message}\n`);
324
+ process.exitCode = 1;
325
+ return null;
326
+ }
327
+
328
+ if (slides.length === 0) {
329
+ console.error(`No slide-*.html files found in: ${slidesDir}`);
330
+ process.exitCode = 1;
331
+ return null;
332
+ }
333
+
334
+ console.log(`Found ${slides.length} slides`);
335
+ writeFileSync(output, buildViewerHtml(slides), 'utf-8');
336
+ console.log(`Built viewer: ${output}`);
337
+ return { slidesDir, output, slides };
338
+ }
339
+
340
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
341
+
342
+ if (isMain) {
343
+ try {
344
+ main();
345
+ } catch (error) {
346
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
347
+ process.exit(1);
348
+ }
349
+ }
@@ -18,6 +18,7 @@ import {
18
18
  scaleSelectionToScreenshot,
19
19
  writeAnnotatedScreenshot,
20
20
  } from '../src/editor/codex-edit.js';
21
+ import { buildSlideRuntimeHtml } from '../src/image-contract.js';
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = dirname(__filename);
@@ -431,6 +432,7 @@ async function startServer(opts) {
431
432
  const app = express();
432
433
  app.use(express.json({ limit: '5mb' }));
433
434
  app.use('/js', express.static(join(PACKAGE_ROOT, 'src', 'editor', 'js')));
435
+ app.use('/slides/assets', express.static(join(slidesDirectory, 'assets')));
434
436
 
435
437
  const editorHtmlPath = join(PACKAGE_ROOT, 'src', 'editor', 'editor.html');
436
438
 
@@ -461,7 +463,11 @@ async function startServer(opts) {
461
463
  const filePath = join(slidesDirectory, file);
462
464
  try {
463
465
  const html = await readFile(filePath, 'utf-8');
464
- res.type('html').send(html);
466
+ const runtimeHtml = buildSlideRuntimeHtml(html, {
467
+ baseHref: '/slides/',
468
+ slideFile: file,
469
+ });
470
+ res.type('html').send(runtimeHtml);
465
471
  } catch {
466
472
  res.status(404).send(`Slide not found: ${file}`);
467
473
  }