slides-grab 1.0.0

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 (51) hide show
  1. package/AGENTS.md +80 -0
  2. package/LICENSE +21 -0
  3. package/PROGRESS.md +39 -0
  4. package/README.md +120 -0
  5. package/SETUP.md +51 -0
  6. package/bin/ppt-agent.js +204 -0
  7. package/convert.cjs +184 -0
  8. package/package.json +51 -0
  9. package/prd.json +135 -0
  10. package/prd.md +104 -0
  11. package/scripts/editor-server.js +779 -0
  12. package/scripts/html2pdf.js +217 -0
  13. package/scripts/validate-slides.js +416 -0
  14. package/skills/ppt-design-skill/SKILL.md +38 -0
  15. package/skills/ppt-plan-skill/SKILL.md +37 -0
  16. package/skills/ppt-pptx-skill/SKILL.md +37 -0
  17. package/skills/ppt-presentation-skill/SKILL.md +57 -0
  18. package/src/editor/codex-edit.js +213 -0
  19. package/src/editor/editor.html +1733 -0
  20. package/src/editor/js/editor-bbox.js +332 -0
  21. package/src/editor/js/editor-chat.js +56 -0
  22. package/src/editor/js/editor-direct-edit.js +110 -0
  23. package/src/editor/js/editor-dom.js +55 -0
  24. package/src/editor/js/editor-init.js +284 -0
  25. package/src/editor/js/editor-navigation.js +54 -0
  26. package/src/editor/js/editor-select.js +264 -0
  27. package/src/editor/js/editor-send.js +157 -0
  28. package/src/editor/js/editor-sse.js +163 -0
  29. package/src/editor/js/editor-state.js +32 -0
  30. package/src/editor/js/editor-utils.js +167 -0
  31. package/src/editor/screenshot.js +73 -0
  32. package/src/resolve.js +159 -0
  33. package/templates/chart.html +121 -0
  34. package/templates/closing.html +54 -0
  35. package/templates/content.html +50 -0
  36. package/templates/contents.html +60 -0
  37. package/templates/cover.html +64 -0
  38. package/templates/custom/.gitkeep +0 -0
  39. package/templates/custom/README.md +7 -0
  40. package/templates/diagram.html +98 -0
  41. package/templates/quote.html +31 -0
  42. package/templates/section-divider.html +43 -0
  43. package/templates/split-layout.html +41 -0
  44. package/templates/statistics.html +55 -0
  45. package/templates/team.html +49 -0
  46. package/templates/timeline.html +59 -0
  47. package/themes/corporate.css +8 -0
  48. package/themes/executive.css +10 -0
  49. package/themes/modern-dark.css +9 -0
  50. package/themes/sage.css +9 -0
  51. package/themes/warm.css +8 -0
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readdir, writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { chromium } from 'playwright';
7
+ import { PDFDocument } from 'pdf-lib';
8
+
9
+ const DEFAULT_OUTPUT = 'slides.pdf';
10
+ const DEFAULT_SLIDES_DIR = 'slides';
11
+ const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
12
+ const FALLBACK_SLIDE_SIZE = { width: 960, height: 540 };
13
+
14
+ function printUsage() {
15
+ process.stdout.write(
16
+ [
17
+ 'Usage: node scripts/html2pdf.js [options]',
18
+ '',
19
+ 'Options:',
20
+ ` --output <path> Output PDF path (default: ${DEFAULT_OUTPUT})`,
21
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
22
+ ' -h, --help Show this help message',
23
+ '',
24
+ 'Examples:',
25
+ ' node scripts/html2pdf.js',
26
+ ' node scripts/html2pdf.js --output dist/deck.pdf',
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
+ function toSlideOrder(fileName) {
41
+ const match = fileName.match(/\d+/);
42
+ return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
43
+ }
44
+
45
+ export function sortSlideFiles(a, b) {
46
+ const orderA = toSlideOrder(a);
47
+ const orderB = toSlideOrder(b);
48
+ if (orderA !== orderB) return orderA - orderB;
49
+ return a.localeCompare(b);
50
+ }
51
+
52
+ export function parseCliArgs(args) {
53
+ const options = {
54
+ output: DEFAULT_OUTPUT,
55
+ slidesDir: DEFAULT_SLIDES_DIR,
56
+ help: false,
57
+ };
58
+
59
+ for (let i = 0; i < args.length; i += 1) {
60
+ const arg = args[i];
61
+
62
+ if (arg === '-h' || arg === '--help') {
63
+ options.help = true;
64
+ continue;
65
+ }
66
+
67
+ if (arg === '--output') {
68
+ options.output = readOptionValue(args, i, '--output');
69
+ i += 1;
70
+ continue;
71
+ }
72
+
73
+ if (arg.startsWith('--output=')) {
74
+ options.output = arg.slice('--output='.length);
75
+ continue;
76
+ }
77
+
78
+ if (arg === '--slides-dir') {
79
+ options.slidesDir = readOptionValue(args, i, '--slides-dir');
80
+ i += 1;
81
+ continue;
82
+ }
83
+
84
+ if (arg.startsWith('--slides-dir=')) {
85
+ options.slidesDir = arg.slice('--slides-dir='.length);
86
+ continue;
87
+ }
88
+
89
+ throw new Error(`Unknown option: ${arg}`);
90
+ }
91
+
92
+ if (typeof options.output !== 'string' || options.output.trim() === '') {
93
+ throw new Error('--output must be a non-empty string.');
94
+ }
95
+ if (typeof options.slidesDir !== 'string' || options.slidesDir.trim() === '') {
96
+ throw new Error('--slides-dir must be a non-empty string.');
97
+ }
98
+
99
+ options.output = options.output.trim();
100
+ options.slidesDir = options.slidesDir.trim();
101
+
102
+ return options;
103
+ }
104
+
105
+ export async function findSlideFiles(slidesDir = resolve(process.cwd(), DEFAULT_SLIDES_DIR)) {
106
+ const entries = await readdir(slidesDir, { withFileTypes: true });
107
+ return entries
108
+ .filter((entry) => entry.isFile() && SLIDE_FILE_PATTERN.test(entry.name))
109
+ .map((entry) => entry.name)
110
+ .sort(sortSlideFiles);
111
+ }
112
+
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
+ export function buildPdfOptions(widthPx, heightPx) {
121
+ return {
122
+ width: `${normalizeDimension(widthPx, FALLBACK_SLIDE_SIZE.width)}px`,
123
+ height: `${normalizeDimension(heightPx, FALLBACK_SLIDE_SIZE.height)}px`,
124
+ printBackground: true,
125
+ pageRanges: '1',
126
+ margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' },
127
+ preferCSSPageSize: false,
128
+ };
129
+ }
130
+
131
+ async function getSlideSize(page) {
132
+ const size = await page.evaluate(() => {
133
+ const body = document.body;
134
+ const rect = body.getBoundingClientRect();
135
+ const style = window.getComputedStyle(body);
136
+
137
+ return {
138
+ width: Number.parseFloat(style.width) || rect.width || 0,
139
+ height: Number.parseFloat(style.height) || rect.height || 0,
140
+ };
141
+ });
142
+
143
+ return {
144
+ width: normalizeDimension(size.width, FALLBACK_SLIDE_SIZE.width),
145
+ height: normalizeDimension(size.height, FALLBACK_SLIDE_SIZE.height),
146
+ };
147
+ }
148
+
149
+ async function renderSlideToPdf(page, slideFile, slidesDir) {
150
+ const slidePath = join(slidesDir, slideFile);
151
+ const slideUrl = pathToFileURL(slidePath).href;
152
+
153
+ await page.goto(slideUrl, { waitUntil: 'load' });
154
+ await page.evaluate(async () => {
155
+ if (document.fonts?.ready) {
156
+ await document.fonts.ready;
157
+ }
158
+ });
159
+
160
+ const size = await getSlideSize(page);
161
+ return page.pdf(buildPdfOptions(size.width, size.height));
162
+ }
163
+
164
+ export async function mergePdfBuffers(pdfBuffers) {
165
+ const outputPdf = await PDFDocument.create();
166
+
167
+ for (const pdfBuffer of pdfBuffers) {
168
+ const sourcePdf = await PDFDocument.load(pdfBuffer);
169
+ const pageIndices = sourcePdf.getPageIndices();
170
+ const pages = await outputPdf.copyPages(sourcePdf, pageIndices);
171
+ for (const page of pages) {
172
+ outputPdf.addPage(page);
173
+ }
174
+ }
175
+
176
+ return outputPdf.save();
177
+ }
178
+
179
+ async function main() {
180
+ const options = parseCliArgs(process.argv.slice(2));
181
+ if (options.help) {
182
+ printUsage();
183
+ return;
184
+ }
185
+
186
+ const slidesDir = resolve(process.cwd(), options.slidesDir);
187
+ const slideFiles = await findSlideFiles(slidesDir);
188
+ if (slideFiles.length === 0) {
189
+ throw new Error(`No slide-*.html files found in: ${slidesDir}`);
190
+ }
191
+
192
+ const browser = await chromium.launch({ headless: true });
193
+ const page = await browser.newPage();
194
+ const slidePdfs = [];
195
+
196
+ try {
197
+ for (const slideFile of slideFiles) {
198
+ const slidePdf = await renderSlideToPdf(page, slideFile, slidesDir);
199
+ slidePdfs.push(slidePdf);
200
+ }
201
+ } finally {
202
+ await browser.close();
203
+ }
204
+
205
+ const mergedPdf = await mergePdfBuffers(slidePdfs);
206
+ const outputPath = resolve(process.cwd(), options.output);
207
+ await writeFile(outputPath, mergedPdf);
208
+
209
+ process.stdout.write(`Generated PDF: ${outputPath}\n`);
210
+ }
211
+
212
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
213
+ main().catch((error) => {
214
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
215
+ process.exit(1);
216
+ });
217
+ }
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readdir } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { chromium } from 'playwright';
7
+
8
+ const FRAME_PT = { width: 720, height: 405 };
9
+ const PT_TO_PX = 96 / 72;
10
+ const FRAME_PX = {
11
+ width: FRAME_PT.width * PT_TO_PX,
12
+ height: FRAME_PT.height * PT_TO_PX
13
+ };
14
+ const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
15
+ const TEXT_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,li';
16
+ const TOLERANCE_PX = 0.5;
17
+ const DEFAULT_SLIDES_DIR = 'slides';
18
+
19
+ function toSlideOrder(fileName) {
20
+ const match = fileName.match(/\d+/);
21
+ return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
22
+ }
23
+
24
+ function sortSlideFiles(a, b) {
25
+ const orderA = toSlideOrder(a);
26
+ const orderB = toSlideOrder(b);
27
+ if (orderA !== orderB) return orderA - orderB;
28
+ return a.localeCompare(b);
29
+ }
30
+
31
+ function buildIssue(code, message, payload = {}) {
32
+ return { code, message, ...payload };
33
+ }
34
+
35
+ function summarizeSlides(slides) {
36
+ const summary = {
37
+ totalSlides: slides.length,
38
+ passedSlides: 0,
39
+ failedSlides: 0,
40
+ criticalIssues: 0,
41
+ warnings: 0
42
+ };
43
+
44
+ for (const slide of slides) {
45
+ if (slide.status === 'pass') {
46
+ summary.passedSlides += 1;
47
+ } else {
48
+ summary.failedSlides += 1;
49
+ }
50
+ summary.criticalIssues += slide.summary.criticalCount;
51
+ summary.warnings += slide.summary.warningCount;
52
+ }
53
+
54
+ return summary;
55
+ }
56
+
57
+ function printUsage() {
58
+ process.stdout.write(
59
+ [
60
+ 'Usage: node scripts/validate-slides.js [options]',
61
+ '',
62
+ 'Options:',
63
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
64
+ ' -h, --help Show this help message',
65
+ ].join('\n'),
66
+ );
67
+ process.stdout.write('\n');
68
+ }
69
+
70
+ function readOptionValue(args, index, optionName) {
71
+ const next = args[index + 1];
72
+ if (!next || next.startsWith('-')) {
73
+ throw new Error(`Missing value for ${optionName}.`);
74
+ }
75
+ return next;
76
+ }
77
+
78
+ function parseCliArgs(args) {
79
+ const options = {
80
+ slidesDir: DEFAULT_SLIDES_DIR,
81
+ help: false,
82
+ };
83
+
84
+ for (let i = 0; i < args.length; i += 1) {
85
+ const arg = args[i];
86
+
87
+ if (arg === '-h' || arg === '--help') {
88
+ options.help = true;
89
+ continue;
90
+ }
91
+
92
+ if (arg === '--slides-dir') {
93
+ options.slidesDir = readOptionValue(args, i, '--slides-dir');
94
+ i += 1;
95
+ continue;
96
+ }
97
+
98
+ if (arg.startsWith('--slides-dir=')) {
99
+ options.slidesDir = arg.slice('--slides-dir='.length);
100
+ continue;
101
+ }
102
+
103
+ throw new Error(`Unknown option: ${arg}`);
104
+ }
105
+
106
+ if (typeof options.slidesDir !== 'string' || options.slidesDir.trim() === '') {
107
+ throw new Error('--slides-dir must be a non-empty string.');
108
+ }
109
+
110
+ options.slidesDir = options.slidesDir.trim();
111
+ return options;
112
+ }
113
+
114
+ async function findSlideFiles(slidesDir) {
115
+ const entries = await readdir(slidesDir, { withFileTypes: true });
116
+ return entries
117
+ .filter((entry) => entry.isFile() && SLIDE_FILE_PATTERN.test(entry.name))
118
+ .map((entry) => entry.name)
119
+ .sort(sortSlideFiles);
120
+ }
121
+
122
+ async function inspectSlide(page, fileName, slidesDir) {
123
+ const slidePath = join(slidesDir, fileName);
124
+ const slideUrl = pathToFileURL(slidePath).href;
125
+
126
+ await page.goto(slideUrl, { waitUntil: 'load' });
127
+ await page.evaluate(async () => {
128
+ if (document.fonts?.ready) {
129
+ await document.fonts.ready;
130
+ }
131
+ });
132
+
133
+ const inspection = await page.evaluate(
134
+ ({ framePx, textSelector, tolerancePx }) => {
135
+ const skipTags = new Set(['SCRIPT', 'STYLE', 'META', 'LINK', 'HEAD', 'TITLE', 'NOSCRIPT']);
136
+ const critical = [];
137
+ const warning = [];
138
+ const seenOverlaps = new Set();
139
+
140
+ const round = (value) => Number(value.toFixed(2));
141
+
142
+ const normalizeRect = (rect) => {
143
+ const left = rect.left ?? rect.x ?? 0;
144
+ const top = rect.top ?? rect.y ?? 0;
145
+ const width = rect.width ?? (rect.right - left) ?? 0;
146
+ const height = rect.height ?? (rect.bottom - top) ?? 0;
147
+ const right = rect.right ?? (left + width);
148
+ const bottom = rect.bottom ?? (top + height);
149
+ return {
150
+ x: round(left),
151
+ y: round(top),
152
+ width: round(width),
153
+ height: round(height),
154
+ left: round(left),
155
+ top: round(top),
156
+ right: round(right),
157
+ bottom: round(bottom)
158
+ };
159
+ };
160
+
161
+ const elementPath = (element) => {
162
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return '';
163
+ if (element === document.body) return 'body';
164
+
165
+ const parts = [];
166
+ let current = element;
167
+
168
+ while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
169
+ let part = current.tagName.toLowerCase();
170
+ if (current.id) {
171
+ part += `#${current.id}`;
172
+ parts.unshift(part);
173
+ break;
174
+ }
175
+
176
+ const classNames = Array.from(current.classList).slice(0, 2);
177
+ if (classNames.length > 0) {
178
+ part += `.${classNames.join('.')}`;
179
+ }
180
+
181
+ if (current.parentElement) {
182
+ const siblingsOfSameTag = Array.from(current.parentElement.children)
183
+ .filter((sibling) => sibling.tagName === current.tagName);
184
+ if (siblingsOfSameTag.length > 1) {
185
+ const index = siblingsOfSameTag.indexOf(current);
186
+ part += `:nth-of-type(${index + 1})`;
187
+ }
188
+ }
189
+
190
+ parts.unshift(part);
191
+ current = current.parentElement;
192
+ }
193
+
194
+ return `body > ${parts.join(' > ')}`;
195
+ };
196
+
197
+ const isVisible = (element) => {
198
+ if (skipTags.has(element.tagName)) return false;
199
+ const style = window.getComputedStyle(element);
200
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
201
+ return false;
202
+ }
203
+ const rect = element.getBoundingClientRect();
204
+ return rect.width > 0 && rect.height > 0;
205
+ };
206
+
207
+ const bodyRect = document.body.getBoundingClientRect();
208
+ const frameRect = {
209
+ left: bodyRect.left,
210
+ top: bodyRect.top,
211
+ right: bodyRect.left + (bodyRect.width || framePx.width),
212
+ bottom: bodyRect.top + (bodyRect.height || framePx.height),
213
+ width: bodyRect.width || framePx.width,
214
+ height: bodyRect.height || framePx.height
215
+ };
216
+
217
+ const allVisibleElements = Array.from(document.body.querySelectorAll('*')).filter(isVisible);
218
+ const visibleSet = new Set(allVisibleElements);
219
+
220
+ for (const element of allVisibleElements) {
221
+ const rect = element.getBoundingClientRect();
222
+ const outsideFrame = (
223
+ rect.left < frameRect.left - tolerancePx ||
224
+ rect.top < frameRect.top - tolerancePx ||
225
+ rect.right > frameRect.right + tolerancePx ||
226
+ rect.bottom > frameRect.bottom + tolerancePx
227
+ );
228
+
229
+ if (outsideFrame) {
230
+ critical.push({
231
+ code: 'overflow-outside-frame',
232
+ message: 'Element exceeds the 720pt x 405pt slide frame.',
233
+ element: elementPath(element),
234
+ bbox: normalizeRect(rect),
235
+ frame: normalizeRect(frameRect)
236
+ });
237
+ }
238
+ }
239
+
240
+ const textElements = Array.from(document.querySelectorAll(textSelector));
241
+ for (const element of textElements) {
242
+ if (!isVisible(element)) continue;
243
+ const content = (element.textContent || '').trim();
244
+ if (!content) continue;
245
+
246
+ const clipped = element.scrollHeight > element.clientHeight;
247
+ if (!clipped) continue;
248
+
249
+ critical.push({
250
+ code: 'text-clipped',
251
+ message: 'Text element is clipped because scrollHeight is larger than clientHeight.',
252
+ element: elementPath(element),
253
+ metrics: {
254
+ scrollHeight: element.scrollHeight,
255
+ clientHeight: element.clientHeight
256
+ },
257
+ bbox: normalizeRect(element.getBoundingClientRect())
258
+ });
259
+ }
260
+
261
+ const parents = [document.body, ...allVisibleElements];
262
+ for (const parent of parents) {
263
+ const children = Array.from(parent.children).filter((child) => visibleSet.has(child));
264
+ if (children.length < 2) continue;
265
+
266
+ for (let i = 0; i < children.length; i += 1) {
267
+ for (let j = i + 1; j < children.length; j += 1) {
268
+ const first = children[i];
269
+ const second = children[j];
270
+
271
+ const rectA = first.getBoundingClientRect();
272
+ const rectB = second.getBoundingClientRect();
273
+
274
+ const overlapWidth = Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left);
275
+ const overlapHeight = Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top);
276
+
277
+ if (overlapWidth <= tolerancePx || overlapHeight <= tolerancePx) continue;
278
+
279
+ const firstPath = elementPath(first);
280
+ const secondPath = elementPath(second);
281
+ const overlapKey = [firstPath, secondPath].sort().join('::');
282
+
283
+ if (seenOverlaps.has(overlapKey)) continue;
284
+ seenOverlaps.add(overlapKey);
285
+
286
+ warning.push({
287
+ code: 'sibling-overlap',
288
+ message: 'Sibling elements overlap in their bounding boxes.',
289
+ parent: elementPath(parent),
290
+ elements: [firstPath, secondPath],
291
+ intersection: {
292
+ x: round(Math.max(rectA.left, rectB.left)),
293
+ y: round(Math.max(rectA.top, rectB.top)),
294
+ width: round(overlapWidth),
295
+ height: round(overlapHeight)
296
+ },
297
+ boxes: [normalizeRect(rectA), normalizeRect(rectB)]
298
+ });
299
+ }
300
+ }
301
+ }
302
+
303
+ return {
304
+ critical,
305
+ warning
306
+ };
307
+ },
308
+ {
309
+ framePx: FRAME_PX,
310
+ textSelector: TEXT_SELECTOR,
311
+ tolerancePx: TOLERANCE_PX
312
+ }
313
+ );
314
+
315
+ const summary = {
316
+ criticalCount: inspection.critical.length,
317
+ warningCount: inspection.warning.length
318
+ };
319
+
320
+ return {
321
+ slide: fileName,
322
+ status: summary.criticalCount > 0 ? 'fail' : 'pass',
323
+ critical: inspection.critical,
324
+ warning: inspection.warning,
325
+ summary
326
+ };
327
+ }
328
+
329
+ async function main() {
330
+ const options = parseCliArgs(process.argv.slice(2));
331
+ if (options.help) {
332
+ printUsage();
333
+ return;
334
+ }
335
+
336
+ const slidesDir = resolve(process.cwd(), options.slidesDir);
337
+ const slideFiles = await findSlideFiles(slidesDir);
338
+ if (slideFiles.length === 0) {
339
+ throw new Error(`No slide-*.html files found in: ${slidesDir}`);
340
+ }
341
+
342
+ const browser = await chromium.launch({ headless: true });
343
+ const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
344
+ const page = await context.newPage();
345
+
346
+ const slides = [];
347
+ try {
348
+ for (const slideFile of slideFiles) {
349
+ try {
350
+ const result = await inspectSlide(page, slideFile, slidesDir);
351
+ slides.push(result);
352
+ } catch (error) {
353
+ slides.push({
354
+ slide: slideFile,
355
+ status: 'fail',
356
+ critical: [
357
+ buildIssue(
358
+ 'slide-validation-error',
359
+ 'Slide validation failed before checks could complete.',
360
+ { detail: error instanceof Error ? error.message : String(error) }
361
+ )
362
+ ],
363
+ warning: [],
364
+ summary: {
365
+ criticalCount: 1,
366
+ warningCount: 0
367
+ }
368
+ });
369
+ }
370
+ }
371
+ } finally {
372
+ await browser.close();
373
+ }
374
+
375
+ const result = {
376
+ generatedAt: new Date().toISOString(),
377
+ frame: {
378
+ widthPt: FRAME_PT.width,
379
+ heightPt: FRAME_PT.height,
380
+ widthPx: FRAME_PX.width,
381
+ heightPx: FRAME_PX.height
382
+ },
383
+ slides,
384
+ summary: summarizeSlides(slides)
385
+ };
386
+
387
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
388
+
389
+ if (result.summary.failedSlides > 0) {
390
+ process.exitCode = 1;
391
+ }
392
+ }
393
+
394
+ main().catch((error) => {
395
+ const failure = {
396
+ generatedAt: new Date().toISOString(),
397
+ frame: {
398
+ widthPt: FRAME_PT.width,
399
+ heightPt: FRAME_PT.height,
400
+ widthPx: FRAME_PX.width,
401
+ heightPx: FRAME_PX.height
402
+ },
403
+ slides: [],
404
+ summary: {
405
+ totalSlides: 0,
406
+ passedSlides: 0,
407
+ failedSlides: 0,
408
+ criticalIssues: 1,
409
+ warnings: 0
410
+ },
411
+ error: error instanceof Error ? error.message : String(error)
412
+ };
413
+
414
+ process.stdout.write(`${JSON.stringify(failure, null, 2)}\n`);
415
+ process.exit(1);
416
+ });
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: ppt-design-skill
3
+ description: Stage 2 design skill for Codex. Generate and iterate slide-XX.html files in the selected slides workspace.
4
+ metadata:
5
+ short-description: Build HTML slides and viewer for review loop
6
+ ---
7
+
8
+ # PPT Design Skill (Codex)
9
+
10
+ Use this after `slide-outline.md` is approved.
11
+
12
+ ## Goal
13
+ Generate high-quality `slide-XX.html` files in the selected slides workspace (`slides/` by default) and support revision loops.
14
+
15
+ ## Inputs
16
+ - Approved `slide-outline.md`
17
+ - Theme/layout preferences
18
+ - Requested edits per slide
19
+
20
+ ## Outputs
21
+ - `<slides-dir>/slide-01.html ... slide-XX.html`
22
+ - Updated `<slides-dir>/viewer.html` via build script
23
+
24
+ ## Workflow
25
+ 1. Read approved `slide-outline.md`.
26
+ 2. Generate slide HTML files with 2-digit numbering in selected `--slides-dir`.
27
+ 3. Run `node scripts/build-viewer.js --slides-dir <path>` after generation or edits.
28
+ 4. Iterate on user feedback by editing only requested slide files.
29
+ 5. Keep revising until user approves conversion stage.
30
+
31
+ ## Rules
32
+ - Keep slide size 720pt x 405pt.
33
+ - Keep semantic text tags (`p`, `h1-h6`, `ul`, `ol`, `li`).
34
+ - Do not start conversion before approval.
35
+
36
+ ## Reference
37
+ For full constraints and style system, follow:
38
+ - `.claude/skills/design-skill/SKILL.md`
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: ppt-plan-skill
3
+ description: Stage 1 planning skill for Codex. Build and iterate slide-outline.md until explicit user approval.
4
+ metadata:
5
+ short-description: Create and revise slide outline before design stage
6
+ ---
7
+
8
+ # PPT Plan Skill (Codex)
9
+
10
+ Use this when the user asks to start a new presentation from scratch.
11
+
12
+ ## Goal
13
+ Produce an approved `slide-outline.md` before any slide HTML generation.
14
+
15
+ ## Inputs
16
+ - Topic and intent
17
+ - Audience
18
+ - Tone and constraints
19
+ - Optional research findings
20
+
21
+ ## Output
22
+ - `slide-outline.md`
23
+
24
+ ## Workflow
25
+ 1. Analyze user goal and audience.
26
+ 2. Create or revise `slide-outline.md` with ordered slides and key messages.
27
+ 3. Present a concise summary to user.
28
+ 4. Repeat revisions until explicit approval.
29
+
30
+ ## Rules
31
+ - Do not generate slide HTML (`<slides-dir>/slide-*.html`) in this stage.
32
+ - Keep scope to structure and narrative.
33
+ - Ask for approval before moving to design.
34
+
35
+ ## Reference
36
+ If needed, use rules from:
37
+ - `.claude/skills/plan-skill/SKILL.md`