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
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { access, readdir } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
import {
|
|
6
|
+
buildImageContractReport,
|
|
7
|
+
classifyImageSource,
|
|
8
|
+
resolveSlideSourcePath,
|
|
9
|
+
} from '../image-contract.js';
|
|
10
|
+
|
|
11
|
+
export const FRAME_PT = { width: 720, height: 405 };
|
|
12
|
+
export const PT_TO_PX = 96 / 72;
|
|
13
|
+
export const FRAME_PX = {
|
|
14
|
+
width: FRAME_PT.width * PT_TO_PX,
|
|
15
|
+
height: FRAME_PT.height * PT_TO_PX,
|
|
16
|
+
};
|
|
17
|
+
export const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
|
|
18
|
+
export const TEXT_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,li';
|
|
19
|
+
export const TOLERANCE_PX = 0.5;
|
|
20
|
+
|
|
21
|
+
export function toSlideOrder(fileName) {
|
|
22
|
+
const match = fileName.match(/\d+/);
|
|
23
|
+
return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sortSlideFiles(a, b) {
|
|
27
|
+
const orderA = toSlideOrder(a);
|
|
28
|
+
const orderB = toSlideOrder(b);
|
|
29
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
30
|
+
return a.localeCompare(b);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildIssue(code, message, payload = {}) {
|
|
34
|
+
return { code, message, ...payload };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function summarizeSlides(slides) {
|
|
38
|
+
const summary = {
|
|
39
|
+
totalSlides: slides.length,
|
|
40
|
+
passedSlides: 0,
|
|
41
|
+
failedSlides: 0,
|
|
42
|
+
criticalIssues: 0,
|
|
43
|
+
warnings: 0,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const slide of slides) {
|
|
47
|
+
if (slide.status === 'pass') {
|
|
48
|
+
summary.passedSlides += 1;
|
|
49
|
+
} else {
|
|
50
|
+
summary.failedSlides += 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
summary.criticalIssues += slide.summary.criticalCount;
|
|
54
|
+
summary.warnings += slide.summary.warningCount;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return summary;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createValidationResult(slides) {
|
|
61
|
+
return {
|
|
62
|
+
generatedAt: new Date().toISOString(),
|
|
63
|
+
frame: {
|
|
64
|
+
widthPt: FRAME_PT.width,
|
|
65
|
+
heightPt: FRAME_PT.height,
|
|
66
|
+
widthPx: FRAME_PX.width,
|
|
67
|
+
heightPx: FRAME_PX.height,
|
|
68
|
+
},
|
|
69
|
+
slides,
|
|
70
|
+
summary: summarizeSlides(slides),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createValidationFailure(error) {
|
|
75
|
+
return {
|
|
76
|
+
generatedAt: new Date().toISOString(),
|
|
77
|
+
frame: {
|
|
78
|
+
widthPt: FRAME_PT.width,
|
|
79
|
+
heightPt: FRAME_PT.height,
|
|
80
|
+
widthPx: FRAME_PX.width,
|
|
81
|
+
heightPx: FRAME_PX.height,
|
|
82
|
+
},
|
|
83
|
+
slides: [],
|
|
84
|
+
summary: {
|
|
85
|
+
totalSlides: 0,
|
|
86
|
+
passedSlides: 0,
|
|
87
|
+
failedSlides: 0,
|
|
88
|
+
criticalIssues: 1,
|
|
89
|
+
warnings: 0,
|
|
90
|
+
},
|
|
91
|
+
error: error instanceof Error ? error.message : String(error),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildElementPath(element) {
|
|
96
|
+
return typeof element === 'string' && element ? element : 'unknown';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildImageIssue(severity, code, message, payload = {}) {
|
|
100
|
+
return {
|
|
101
|
+
severity,
|
|
102
|
+
code,
|
|
103
|
+
message,
|
|
104
|
+
...payload,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fileExists(filePath) {
|
|
109
|
+
try {
|
|
110
|
+
await access(filePath);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function inspectImageContract(slidesDir, fileName, inspection) {
|
|
118
|
+
const critical = [];
|
|
119
|
+
const warning = [];
|
|
120
|
+
const slidePath = join(slidesDir, fileName);
|
|
121
|
+
|
|
122
|
+
for (const image of inspection.images) {
|
|
123
|
+
const source = image.src;
|
|
124
|
+
const classification = classifyImageSource(source);
|
|
125
|
+
const issues = buildImageContractReport({
|
|
126
|
+
slideFile: fileName,
|
|
127
|
+
sources: [{
|
|
128
|
+
element: buildElementPath(image.element),
|
|
129
|
+
source,
|
|
130
|
+
}],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
for (const issue of issues) {
|
|
134
|
+
const target = issue.severity === 'critical' ? critical : warning;
|
|
135
|
+
target.push(issue);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
classification.kind === 'empty'
|
|
140
|
+
|| classification.kind === 'data-url'
|
|
141
|
+
|| classification.kind === 'remote-url'
|
|
142
|
+
|| classification.kind === 'remote-url-insecure'
|
|
143
|
+
|| classification.kind === 'absolute-filesystem-path'
|
|
144
|
+
|| classification.kind === 'root-relative-path'
|
|
145
|
+
|| classification.kind === 'other-scheme'
|
|
146
|
+
) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const assetPath = resolveSlideSourcePath(slidePath, source);
|
|
151
|
+
if (!(await fileExists(assetPath))) {
|
|
152
|
+
critical.push(buildImageIssue(
|
|
153
|
+
'critical',
|
|
154
|
+
'missing-local-asset',
|
|
155
|
+
'Local image asset is missing.',
|
|
156
|
+
{
|
|
157
|
+
slide: fileName,
|
|
158
|
+
element: buildElementPath(image.element),
|
|
159
|
+
source,
|
|
160
|
+
assetPath,
|
|
161
|
+
},
|
|
162
|
+
));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const background of inspection.backgrounds) {
|
|
167
|
+
if (background.urls.length === 0) continue;
|
|
168
|
+
|
|
169
|
+
if (background.element !== 'body') {
|
|
170
|
+
critical.push(buildImageIssue(
|
|
171
|
+
'critical',
|
|
172
|
+
'unsupported-background-image',
|
|
173
|
+
'Non-body background-image usage is not supported for slide content. Use <img src="./assets/<file>"> instead.',
|
|
174
|
+
{
|
|
175
|
+
slide: fileName,
|
|
176
|
+
element: buildElementPath(background.element),
|
|
177
|
+
backgroundImage: background.backgroundImage,
|
|
178
|
+
sources: background.urls,
|
|
179
|
+
},
|
|
180
|
+
));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const issues = buildImageContractReport({
|
|
184
|
+
slideFile: fileName,
|
|
185
|
+
sources: background.urls.map((source) => ({
|
|
186
|
+
element: buildElementPath(background.element),
|
|
187
|
+
source,
|
|
188
|
+
})),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
for (const issue of issues) {
|
|
192
|
+
const code = issue.code === 'remote-image-url'
|
|
193
|
+
? 'remote-background-image-url'
|
|
194
|
+
: issue.code === 'remote-image-url-insecure'
|
|
195
|
+
? 'remote-background-image-url-insecure'
|
|
196
|
+
: issue.code;
|
|
197
|
+
const nextIssue = { ...issue, code };
|
|
198
|
+
const target = issue.severity === 'critical' ? critical : warning;
|
|
199
|
+
target.push(nextIssue);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const source of background.urls) {
|
|
203
|
+
const classification = classifyImageSource(source);
|
|
204
|
+
if (
|
|
205
|
+
classification.kind === 'empty'
|
|
206
|
+
|| classification.kind === 'data-url'
|
|
207
|
+
|| classification.kind === 'remote-url'
|
|
208
|
+
|| classification.kind === 'remote-url-insecure'
|
|
209
|
+
|| classification.kind === 'absolute-filesystem-path'
|
|
210
|
+
|| classification.kind === 'root-relative-path'
|
|
211
|
+
|| classification.kind === 'other-scheme'
|
|
212
|
+
) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const assetPath = resolveSlideSourcePath(slidePath, source);
|
|
217
|
+
if (!(await fileExists(assetPath))) {
|
|
218
|
+
critical.push(buildImageIssue(
|
|
219
|
+
'critical',
|
|
220
|
+
'missing-local-background-asset',
|
|
221
|
+
'Background image references a missing local asset.',
|
|
222
|
+
{
|
|
223
|
+
slide: fileName,
|
|
224
|
+
element: buildElementPath(background.element),
|
|
225
|
+
source,
|
|
226
|
+
assetPath,
|
|
227
|
+
},
|
|
228
|
+
));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { critical, warning };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function findSlideFiles(slidesDir) {
|
|
237
|
+
const entries = await readdir(slidesDir, { withFileTypes: true });
|
|
238
|
+
return entries
|
|
239
|
+
.filter((entry) => entry.isFile() && SLIDE_FILE_PATTERN.test(entry.name))
|
|
240
|
+
.map((entry) => entry.name)
|
|
241
|
+
.sort(sortSlideFiles);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function selectSlideFiles(slideFiles, selectedSlides = [], slidesDir = '') {
|
|
245
|
+
if (!Array.isArray(selectedSlides) || selectedSlides.length === 0) {
|
|
246
|
+
return slideFiles;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const requested = [...new Set(
|
|
250
|
+
selectedSlides
|
|
251
|
+
.map((slide) => basename(String(slide).trim()))
|
|
252
|
+
.filter(Boolean),
|
|
253
|
+
)];
|
|
254
|
+
|
|
255
|
+
const available = new Set(slideFiles);
|
|
256
|
+
const missing = requested.filter((slide) => !available.has(slide));
|
|
257
|
+
if (missing.length > 0) {
|
|
258
|
+
const location = slidesDir ? ` in ${slidesDir}` : '';
|
|
259
|
+
throw new Error(`Requested slide file(s) not found${location}: ${missing.join(', ')}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return slideFiles.filter((slide) => available.has(slide) && requested.includes(slide));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function inspectSlide(page, fileName, slidesDir) {
|
|
266
|
+
const slidePath = join(slidesDir, fileName);
|
|
267
|
+
const slideUrl = pathToFileURL(slidePath).href;
|
|
268
|
+
|
|
269
|
+
await page.goto(slideUrl, { waitUntil: 'load' });
|
|
270
|
+
await page.evaluate(async () => {
|
|
271
|
+
if (document.fonts?.ready) {
|
|
272
|
+
await document.fonts.ready;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const inspection = await page.evaluate(
|
|
277
|
+
({ framePx, textSelector, tolerancePx }) => {
|
|
278
|
+
const skipTags = new Set(['SCRIPT', 'STYLE', 'META', 'LINK', 'HEAD', 'TITLE', 'NOSCRIPT']);
|
|
279
|
+
const critical = [];
|
|
280
|
+
const warning = [];
|
|
281
|
+
const seenOverlaps = new Set();
|
|
282
|
+
const cssUrlRe = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
|
|
283
|
+
|
|
284
|
+
const round = (value) => Number(value.toFixed(2));
|
|
285
|
+
const extractUrls = (value) => {
|
|
286
|
+
const input = typeof value === 'string' ? value : '';
|
|
287
|
+
const matches = [];
|
|
288
|
+
let match;
|
|
289
|
+
cssUrlRe.lastIndex = 0;
|
|
290
|
+
while ((match = cssUrlRe.exec(input)) !== null) {
|
|
291
|
+
const candidate = (match[2] || '').trim();
|
|
292
|
+
if (candidate) {
|
|
293
|
+
matches.push(candidate);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return matches;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const normalizeRect = (rect) => {
|
|
300
|
+
const left = rect.left ?? rect.x ?? 0;
|
|
301
|
+
const top = rect.top ?? rect.y ?? 0;
|
|
302
|
+
const width = rect.width ?? (rect.right - left) ?? 0;
|
|
303
|
+
const height = rect.height ?? (rect.bottom - top) ?? 0;
|
|
304
|
+
const right = rect.right ?? (left + width);
|
|
305
|
+
const bottom = rect.bottom ?? (top + height);
|
|
306
|
+
return {
|
|
307
|
+
x: round(left),
|
|
308
|
+
y: round(top),
|
|
309
|
+
width: round(width),
|
|
310
|
+
height: round(height),
|
|
311
|
+
left: round(left),
|
|
312
|
+
top: round(top),
|
|
313
|
+
right: round(right),
|
|
314
|
+
bottom: round(bottom),
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const elementPath = (element) => {
|
|
319
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) return '';
|
|
320
|
+
if (element === document.body) return 'body';
|
|
321
|
+
|
|
322
|
+
const parts = [];
|
|
323
|
+
let current = element;
|
|
324
|
+
|
|
325
|
+
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
|
|
326
|
+
let part = current.tagName.toLowerCase();
|
|
327
|
+
if (current.id) {
|
|
328
|
+
part += `#${current.id}`;
|
|
329
|
+
parts.unshift(part);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const classNames = Array.from(current.classList).slice(0, 2);
|
|
334
|
+
if (classNames.length > 0) {
|
|
335
|
+
part += `.${classNames.join('.')}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (current.parentElement) {
|
|
339
|
+
const siblingsOfSameTag = Array.from(current.parentElement.children)
|
|
340
|
+
.filter((sibling) => sibling.tagName === current.tagName);
|
|
341
|
+
if (siblingsOfSameTag.length > 1) {
|
|
342
|
+
const index = siblingsOfSameTag.indexOf(current);
|
|
343
|
+
part += `:nth-of-type(${index + 1})`;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
parts.unshift(part);
|
|
348
|
+
current = current.parentElement;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return `body > ${parts.join(' > ')}`;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const isVisible = (element) => {
|
|
355
|
+
if (skipTags.has(element.tagName)) return false;
|
|
356
|
+
const style = window.getComputedStyle(element);
|
|
357
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const rect = element.getBoundingClientRect();
|
|
362
|
+
return rect.width > 0 && rect.height > 0;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const collectDeclaredBackgroundValues = (element) => {
|
|
366
|
+
const values = [];
|
|
367
|
+
const pushValue = (value) => {
|
|
368
|
+
if (
|
|
369
|
+
typeof value !== 'string'
|
|
370
|
+
|| value === ''
|
|
371
|
+
|| value === 'none'
|
|
372
|
+
|| !value.includes('url(')
|
|
373
|
+
|| values.includes(value)
|
|
374
|
+
) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
values.push(value);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
pushValue(element.style?.getPropertyValue('background-image'));
|
|
381
|
+
pushValue(element.style?.getPropertyValue('background'));
|
|
382
|
+
|
|
383
|
+
const visitRules = (rules) => {
|
|
384
|
+
for (const rule of Array.from(rules || [])) {
|
|
385
|
+
if (rule.type === CSSRule.STYLE_RULE) {
|
|
386
|
+
let matches = false;
|
|
387
|
+
try {
|
|
388
|
+
matches = element.matches(rule.selectorText);
|
|
389
|
+
} catch {
|
|
390
|
+
matches = false;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!matches) continue;
|
|
394
|
+
pushValue(rule.style?.getPropertyValue('background-image'));
|
|
395
|
+
pushValue(rule.style?.getPropertyValue('background'));
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if ('cssRules' in rule) {
|
|
400
|
+
try {
|
|
401
|
+
visitRules(rule.cssRules);
|
|
402
|
+
} catch {
|
|
403
|
+
// Ignore cross-origin and invalid stylesheet access.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
410
|
+
try {
|
|
411
|
+
visitRules(sheet.cssRules);
|
|
412
|
+
} catch {
|
|
413
|
+
// Ignore cross-origin stylesheets.
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return values;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const bodyRect = document.body.getBoundingClientRect();
|
|
421
|
+
const frameRect = {
|
|
422
|
+
left: bodyRect.left,
|
|
423
|
+
top: bodyRect.top,
|
|
424
|
+
right: bodyRect.left + (bodyRect.width || framePx.width),
|
|
425
|
+
bottom: bodyRect.top + (bodyRect.height || framePx.height),
|
|
426
|
+
width: bodyRect.width || framePx.width,
|
|
427
|
+
height: bodyRect.height || framePx.height,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const allVisibleElements = Array.from(document.body.querySelectorAll('*')).filter(isVisible);
|
|
431
|
+
const visibleSet = new Set(allVisibleElements);
|
|
432
|
+
|
|
433
|
+
for (const element of allVisibleElements) {
|
|
434
|
+
const rect = element.getBoundingClientRect();
|
|
435
|
+
const outsideFrame = (
|
|
436
|
+
rect.left < frameRect.left - tolerancePx
|
|
437
|
+
|| rect.top < frameRect.top - tolerancePx
|
|
438
|
+
|| rect.right > frameRect.right + tolerancePx
|
|
439
|
+
|| rect.bottom > frameRect.bottom + tolerancePx
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (outsideFrame) {
|
|
443
|
+
critical.push({
|
|
444
|
+
code: 'overflow-outside-frame',
|
|
445
|
+
message: 'Element exceeds the 720pt x 405pt slide frame.',
|
|
446
|
+
element: elementPath(element),
|
|
447
|
+
bbox: normalizeRect(rect),
|
|
448
|
+
frame: normalizeRect(frameRect),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const textElements = Array.from(document.querySelectorAll(textSelector));
|
|
454
|
+
for (const element of textElements) {
|
|
455
|
+
if (!isVisible(element)) continue;
|
|
456
|
+
const content = (element.textContent || '').trim();
|
|
457
|
+
if (!content) continue;
|
|
458
|
+
|
|
459
|
+
const clipped = element.scrollHeight > element.clientHeight;
|
|
460
|
+
if (!clipped) continue;
|
|
461
|
+
|
|
462
|
+
critical.push({
|
|
463
|
+
code: 'text-clipped',
|
|
464
|
+
message: 'Text element is clipped because scrollHeight is larger than clientHeight.',
|
|
465
|
+
element: elementPath(element),
|
|
466
|
+
metrics: {
|
|
467
|
+
scrollHeight: element.scrollHeight,
|
|
468
|
+
clientHeight: element.clientHeight,
|
|
469
|
+
},
|
|
470
|
+
bbox: normalizeRect(element.getBoundingClientRect()),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const parents = [document.body, ...allVisibleElements];
|
|
475
|
+
for (const parent of parents) {
|
|
476
|
+
const children = Array.from(parent.children).filter((child) => visibleSet.has(child));
|
|
477
|
+
if (children.length < 2) continue;
|
|
478
|
+
|
|
479
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
480
|
+
for (let j = i + 1; j < children.length; j += 1) {
|
|
481
|
+
const first = children[i];
|
|
482
|
+
const second = children[j];
|
|
483
|
+
|
|
484
|
+
const rectA = first.getBoundingClientRect();
|
|
485
|
+
const rectB = second.getBoundingClientRect();
|
|
486
|
+
|
|
487
|
+
const overlapWidth = Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left);
|
|
488
|
+
const overlapHeight = Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top);
|
|
489
|
+
|
|
490
|
+
if (overlapWidth <= tolerancePx || overlapHeight <= tolerancePx) continue;
|
|
491
|
+
|
|
492
|
+
const firstPath = elementPath(first);
|
|
493
|
+
const secondPath = elementPath(second);
|
|
494
|
+
const overlapKey = [firstPath, secondPath].sort().join('::');
|
|
495
|
+
|
|
496
|
+
if (seenOverlaps.has(overlapKey)) continue;
|
|
497
|
+
seenOverlaps.add(overlapKey);
|
|
498
|
+
|
|
499
|
+
warning.push({
|
|
500
|
+
code: 'sibling-overlap',
|
|
501
|
+
message: 'Sibling elements overlap in their bounding boxes.',
|
|
502
|
+
parent: elementPath(parent),
|
|
503
|
+
elements: [firstPath, secondPath],
|
|
504
|
+
intersection: {
|
|
505
|
+
x: round(Math.max(rectA.left, rectB.left)),
|
|
506
|
+
y: round(Math.max(rectA.top, rectB.top)),
|
|
507
|
+
width: round(overlapWidth),
|
|
508
|
+
height: round(overlapHeight),
|
|
509
|
+
},
|
|
510
|
+
boxes: [normalizeRect(rectA), normalizeRect(rectB)],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const images = Array.from(document.querySelectorAll('img')).map((element) => ({
|
|
517
|
+
element: elementPath(element),
|
|
518
|
+
src: (element.getAttribute('src') || '').trim(),
|
|
519
|
+
alt: (element.getAttribute('alt') || '').trim(),
|
|
520
|
+
}));
|
|
521
|
+
|
|
522
|
+
const backgrounds = [document.body, ...Array.from(document.body.querySelectorAll('*'))]
|
|
523
|
+
.map((element) => {
|
|
524
|
+
const computedBackgroundImage = window.getComputedStyle(element).backgroundImage;
|
|
525
|
+
const declaredBackgroundValues = collectDeclaredBackgroundValues(element);
|
|
526
|
+
const declaredBackgroundImage = declaredBackgroundValues.find((value) => extractUrls(value).length > 0) || '';
|
|
527
|
+
const declaredUrls = declaredBackgroundValues.flatMap(extractUrls);
|
|
528
|
+
const urls = declaredUrls.length > 0 ? declaredUrls : extractUrls(computedBackgroundImage);
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
element: element === document.body ? 'body' : elementPath(element),
|
|
532
|
+
backgroundImage: declaredBackgroundImage || computedBackgroundImage,
|
|
533
|
+
urls,
|
|
534
|
+
};
|
|
535
|
+
})
|
|
536
|
+
.filter((entry) => entry.urls.length > 0);
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
critical,
|
|
540
|
+
warning,
|
|
541
|
+
images,
|
|
542
|
+
backgrounds,
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
framePx: FRAME_PX,
|
|
547
|
+
textSelector: TEXT_SELECTOR,
|
|
548
|
+
tolerancePx: TOLERANCE_PX,
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const imageContractIssues = await inspectImageContract(slidesDir, fileName, {
|
|
553
|
+
images: inspection.images,
|
|
554
|
+
backgrounds: inspection.backgrounds,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
inspection.critical.push(...imageContractIssues.critical);
|
|
558
|
+
inspection.warning.push(...imageContractIssues.warning);
|
|
559
|
+
|
|
560
|
+
const summary = {
|
|
561
|
+
criticalCount: inspection.critical.length,
|
|
562
|
+
warningCount: inspection.warning.length,
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
slide: fileName,
|
|
567
|
+
status: summary.criticalCount > 0 ? 'fail' : 'pass',
|
|
568
|
+
critical: inspection.critical,
|
|
569
|
+
warning: inspection.warning,
|
|
570
|
+
summary,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export async function scanSlides(page, slidesDir, slideFiles) {
|
|
575
|
+
const slides = [];
|
|
576
|
+
|
|
577
|
+
for (const slideFile of slideFiles) {
|
|
578
|
+
try {
|
|
579
|
+
const result = await inspectSlide(page, slideFile, slidesDir);
|
|
580
|
+
slides.push(result);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
slides.push({
|
|
583
|
+
slide: slideFile,
|
|
584
|
+
status: 'fail',
|
|
585
|
+
critical: [
|
|
586
|
+
buildIssue(
|
|
587
|
+
'slide-validation-error',
|
|
588
|
+
'Slide validation failed before checks could complete.',
|
|
589
|
+
{ detail: error instanceof Error ? error.message : String(error) },
|
|
590
|
+
),
|
|
591
|
+
],
|
|
592
|
+
warning: [],
|
|
593
|
+
summary: {
|
|
594
|
+
criticalCount: 1,
|
|
595
|
+
warningCount: 0,
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return slides;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function formatValidationFailureForExport(result, exportLabel = 'Export') {
|
|
605
|
+
const findings = [];
|
|
606
|
+
|
|
607
|
+
for (const slide of result.slides) {
|
|
608
|
+
if (slide.status !== 'fail') continue;
|
|
609
|
+
for (const issue of slide.critical) {
|
|
610
|
+
const source = typeof issue.source === 'string' ? ` (${issue.source})` : '';
|
|
611
|
+
findings.push(`- ${slide.slide}: ${issue.code}${source}`);
|
|
612
|
+
if (findings.length >= 8) break;
|
|
613
|
+
}
|
|
614
|
+
if (findings.length >= 8) break;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const suffix = findings.length > 0 ? `\n${findings.join('\n')}` : '';
|
|
618
|
+
return `${exportLabel} blocked by slide validation. Run \`slides-grab validate --slides-dir <path>\` for full diagnostics.${suffix}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const EXPORT_BLOCKING_IMAGE_CONTRACT_CODES = new Set([
|
|
622
|
+
'absolute-filesystem-image-path',
|
|
623
|
+
'missing-local-asset',
|
|
624
|
+
'missing-local-background-asset',
|
|
625
|
+
'root-relative-image-path',
|
|
626
|
+
'unsupported-background-image',
|
|
627
|
+
]);
|
|
628
|
+
|
|
629
|
+
function filterExportBlockingSlides(result, shouldBlockIssue) {
|
|
630
|
+
const slides = result.slides
|
|
631
|
+
.map((slide) => {
|
|
632
|
+
const critical = slide.critical.filter(shouldBlockIssue);
|
|
633
|
+
const warning = slide.warning.filter(shouldBlockIssue);
|
|
634
|
+
const criticalCount = critical.length;
|
|
635
|
+
const warningCount = warning.length;
|
|
636
|
+
return {
|
|
637
|
+
...slide,
|
|
638
|
+
status: criticalCount > 0 ? 'fail' : 'pass',
|
|
639
|
+
critical,
|
|
640
|
+
warning,
|
|
641
|
+
summary: {
|
|
642
|
+
...slide.summary,
|
|
643
|
+
criticalCount,
|
|
644
|
+
warningCount,
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
})
|
|
648
|
+
.filter((slide) => slide.critical.length > 0 || slide.warning.length > 0);
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
...result,
|
|
652
|
+
slides,
|
|
653
|
+
summary: summarizeSlides(slides),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function isBlockingImageContractIssue(issue) {
|
|
658
|
+
return EXPORT_BLOCKING_IMAGE_CONTRACT_CODES.has(issue?.code);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export async function ensureSlidesPassValidation(
|
|
662
|
+
slidesDir,
|
|
663
|
+
{
|
|
664
|
+
exportLabel = 'Export',
|
|
665
|
+
shouldBlockIssue = isBlockingImageContractIssue,
|
|
666
|
+
} = {},
|
|
667
|
+
) {
|
|
668
|
+
const slideFiles = await findSlideFiles(slidesDir);
|
|
669
|
+
if (slideFiles.length === 0) {
|
|
670
|
+
throw new Error(`No slide-*.html files found in: ${slidesDir}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const browser = await chromium.launch({ headless: true });
|
|
674
|
+
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
|
675
|
+
const page = await context.newPage();
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
const slides = await scanSlides(page, slidesDir, slideFiles);
|
|
679
|
+
const result = createValidationResult(slides);
|
|
680
|
+
const blockingResult = filterExportBlockingSlides(result, shouldBlockIssue);
|
|
681
|
+
if (blockingResult.summary.failedSlides > 0) {
|
|
682
|
+
throw new Error(formatValidationFailureForExport(blockingResult, exportLabel));
|
|
683
|
+
}
|
|
684
|
+
return blockingResult;
|
|
685
|
+
} finally {
|
|
686
|
+
await browser.close();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
<body>
|
|
18
18
|
<!-- 이미지 영역 -->
|
|
19
19
|
<div style="background: #e5e5e0; display: flex; align-items: center; justify-content: center; position: relative;">
|
|
20
|
-
<div data-image-placeholder style="width: 100%; height: 100%;
|
|
20
|
+
<div data-image-placeholder style="width: 100%; height: 100%; border: 1pt dashed #b8b8b0; background: #d9d9d2; display: flex; align-items: center; justify-content: center;">
|
|
21
|
+
<p style="font-size: 11pt; font-weight: 600; letter-spacing: 0.06em; color: #5f5f58; text-transform: uppercase;">Image Placeholder</p>
|
|
22
|
+
</div>
|
|
21
23
|
<p style="position: absolute; bottom: 16pt; left: 16pt; font-size: 9pt; color: #666;">©2025 YOUR BRAND</p>
|
|
22
24
|
</div>
|
|
23
25
|
|