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/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
|
|
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
|
|
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
|
|
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:
|
|
105
|
-
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
|
|
114
|
-
const targetHeight = Math.round(7.5 * 150);
|
|
138
|
+
const targetSize = getTargetRasterSize();
|
|
115
139
|
|
|
116
140
|
const resized = await sharp(screenshot)
|
|
117
|
-
.resize(
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
4
|
-
"description": "Agent-first presentation framework — plan, design, and visually edit HTML slides with Claude Code or Codex, then export to PPTX/
|
|
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
|
|
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, '&')
|
|
93
|
+
.replace(/"/g, '"');
|
|
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)">⛶</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
|
+
}
|
package/scripts/editor-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|