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.
- package/AGENTS.md +80 -0
- package/LICENSE +21 -0
- package/PROGRESS.md +39 -0
- package/README.md +120 -0
- package/SETUP.md +51 -0
- package/bin/ppt-agent.js +204 -0
- package/convert.cjs +184 -0
- package/package.json +51 -0
- package/prd.json +135 -0
- package/prd.md +104 -0
- package/scripts/editor-server.js +779 -0
- package/scripts/html2pdf.js +217 -0
- package/scripts/validate-slides.js +416 -0
- package/skills/ppt-design-skill/SKILL.md +38 -0
- package/skills/ppt-plan-skill/SKILL.md +37 -0
- package/skills/ppt-pptx-skill/SKILL.md +37 -0
- package/skills/ppt-presentation-skill/SKILL.md +57 -0
- package/src/editor/codex-edit.js +213 -0
- package/src/editor/editor.html +1733 -0
- package/src/editor/js/editor-bbox.js +332 -0
- package/src/editor/js/editor-chat.js +56 -0
- package/src/editor/js/editor-direct-edit.js +110 -0
- package/src/editor/js/editor-dom.js +55 -0
- package/src/editor/js/editor-init.js +284 -0
- package/src/editor/js/editor-navigation.js +54 -0
- package/src/editor/js/editor-select.js +264 -0
- package/src/editor/js/editor-send.js +157 -0
- package/src/editor/js/editor-sse.js +163 -0
- package/src/editor/js/editor-state.js +32 -0
- package/src/editor/js/editor-utils.js +167 -0
- package/src/editor/screenshot.js +73 -0
- package/src/resolve.js +159 -0
- package/templates/chart.html +121 -0
- package/templates/closing.html +54 -0
- package/templates/content.html +50 -0
- package/templates/contents.html +60 -0
- package/templates/cover.html +64 -0
- package/templates/custom/.gitkeep +0 -0
- package/templates/custom/README.md +7 -0
- package/templates/diagram.html +98 -0
- package/templates/quote.html +31 -0
- package/templates/section-divider.html +43 -0
- package/templates/split-layout.html +41 -0
- package/templates/statistics.html +55 -0
- package/templates/team.html +49 -0
- package/templates/timeline.html +59 -0
- package/themes/corporate.css +8 -0
- package/themes/executive.css +10 -0
- package/themes/modern-dark.css +9 -0
- package/themes/sage.css +9 -0
- 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`
|