@ohos-ports/slides-grab 1.5.0-beta.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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README-ko.md +292 -0
  3. package/README.md +301 -0
  4. package/bin/ohos-postinstall.cjs +118 -0
  5. package/bin/ppt-agent.js +620 -0
  6. package/convert.cjs +20 -0
  7. package/native/@img/sharp-libvips-openharmony-arm64/lib/glib-2.0/include/glibconfig.h +220 -0
  8. package/native/@img/sharp-libvips-openharmony-arm64/lib/index.js +1 -0
  9. package/native/@img/sharp-libvips-openharmony-arm64/lib/libexpat.so +0 -0
  10. package/native/@img/sharp-libvips-openharmony-arm64/lib/libffi.so +0 -0
  11. package/native/@img/sharp-libvips-openharmony-arm64/lib/libjpeg.so +0 -0
  12. package/native/@img/sharp-libvips-openharmony-arm64/lib/libpcre2-8.so.0 +0 -0
  13. package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips-cpp.so.42.20.3 +0 -0
  14. package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips.so.42.20.3 +0 -0
  15. package/native/@img/sharp-libvips-openharmony-arm64/package.json +13 -0
  16. package/native/@img/sharp-openharmony-arm64/lib/sharp-openharmony-arm64.node +0 -0
  17. package/native/@img/sharp-openharmony-arm64/package.json +16 -0
  18. package/package.json +95 -0
  19. package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
  20. package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
  21. package/scripts/build-viewer.js +444 -0
  22. package/scripts/design-gate.js +258 -0
  23. package/scripts/download-video.js +213 -0
  24. package/scripts/editor-server.js +1167 -0
  25. package/scripts/figma-export.js +169 -0
  26. package/scripts/generate-image.js +116 -0
  27. package/scripts/generate-images.js +164 -0
  28. package/scripts/html2pdf.js +822 -0
  29. package/scripts/html2png.js +246 -0
  30. package/scripts/html2pptx.js +162 -0
  31. package/scripts/import-template.js +86 -0
  32. package/scripts/install-runtime.js +216 -0
  33. package/scripts/render-tldraw.js +44 -0
  34. package/scripts/validate-slides.js +221 -0
  35. package/skills/slides-grab/SKILL.md +65 -0
  36. package/skills/slides-grab/references/presentation-workflow-reference.md +61 -0
  37. package/skills/slides-grab-card-news/SKILL.md +35 -0
  38. package/skills/slides-grab-design/SKILL.md +88 -0
  39. package/skills/slides-grab-design/references/beautiful-slide-defaults.md +88 -0
  40. package/skills/slides-grab-design/references/design-gate.md +349 -0
  41. package/skills/slides-grab-design/references/design-rules.md +76 -0
  42. package/skills/slides-grab-design/references/design-system-full.md +568 -0
  43. package/skills/slides-grab-design/references/detailed-design-rules.md +70 -0
  44. package/skills/slides-grab-export/SKILL.md +55 -0
  45. package/skills/slides-grab-export/references/export-rules.md +27 -0
  46. package/skills/slides-grab-export/references/html2pptx.md +627 -0
  47. package/skills/slides-grab-export/references/ooxml.md +427 -0
  48. package/skills/slides-grab-export/references/pptx-skill-reference.md +189 -0
  49. package/skills/slides-grab-html/SKILL.md +67 -0
  50. package/skills/slides-grab-image/SKILL.md +82 -0
  51. package/skills/slides-grab-plan/SKILL.md +68 -0
  52. package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
  53. package/skills/slides-grab-plan/references/outline-format.md +47 -0
  54. package/skills/slides-grab-plan/references/plan-workflow-reference.md +140 -0
  55. package/src/codex-imagen.js +182 -0
  56. package/src/design-diversity-data.js +6932 -0
  57. package/src/design-gate-report.js +244 -0
  58. package/src/design-gate-state.js +329 -0
  59. package/src/design-import.js +164 -0
  60. package/src/design-md-parser.js +415 -0
  61. package/src/design-styles-data.js +1928 -0
  62. package/src/design-styles.js +209 -0
  63. package/src/editor/codex-edit.js +584 -0
  64. package/src/editor/edit-subprocess.js +170 -0
  65. package/src/editor/editor-codex-prompt.md +50 -0
  66. package/src/editor/editor.html +1785 -0
  67. package/src/editor/js/editor-bbox.js +332 -0
  68. package/src/editor/js/editor-chat.js +56 -0
  69. package/src/editor/js/editor-direct-edit.js +238 -0
  70. package/src/editor/js/editor-dom.js +59 -0
  71. package/src/editor/js/editor-init.js +405 -0
  72. package/src/editor/js/editor-navigation.js +54 -0
  73. package/src/editor/js/editor-select.js +558 -0
  74. package/src/editor/js/editor-send.js +175 -0
  75. package/src/editor/js/editor-sse.js +163 -0
  76. package/src/editor/js/editor-state.js +41 -0
  77. package/src/editor/js/editor-type.js +71 -0
  78. package/src/editor/js/editor-utils.js +167 -0
  79. package/src/editor/js/model-registry.js +37 -0
  80. package/src/editor/screenshot.js +82 -0
  81. package/src/export-resolution.cjs +68 -0
  82. package/src/figma.js +71 -0
  83. package/src/html2pptx-scale.cjs +120 -0
  84. package/src/html2pptx.cjs +1262 -0
  85. package/src/image-contract.js +329 -0
  86. package/src/image-native.js +468 -0
  87. package/src/nano-banana.js +841 -0
  88. package/src/ohos-browser-bridge.cjs +272 -0
  89. package/src/pptx-raster-export.cjs +299 -0
  90. package/src/resolve.js +110 -0
  91. package/src/slide-mode.cjs +72 -0
  92. package/src/template-fidelity.js +267 -0
  93. package/src/template-import.js +505 -0
  94. package/src/template-layout.js +261 -0
  95. package/src/template-pack.js +255 -0
  96. package/src/tldraw/render.js +473 -0
  97. package/src/validation/cli.js +120 -0
  98. package/src/validation/core.js +1023 -0
  99. package/templates/chart.html +121 -0
  100. package/templates/closing.html +54 -0
  101. package/templates/content.html +50 -0
  102. package/templates/contents.html +60 -0
  103. package/templates/cover.html +64 -0
  104. package/templates/custom/.gitkeep +0 -0
  105. package/templates/custom/README.md +7 -0
  106. package/templates/design-styles/README.md +20 -0
  107. package/templates/design-styles/preview.html +4438 -0
  108. package/templates/diagram-tldraw.html +56 -0
  109. package/templates/diagram.html +98 -0
  110. package/templates/quote.html +31 -0
  111. package/templates/section-divider.html +43 -0
  112. package/templates/split-layout.html +43 -0
  113. package/templates/statistics.html +55 -0
  114. package/templates/team.html +49 -0
  115. package/templates/timeline.html +59 -0
@@ -0,0 +1,1023 @@
1
+ import { access, readdir } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { basename, join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import {
6
+ buildImageContractReport,
7
+ buildVideoContractReport,
8
+ classifyImageSource,
9
+ resolveSlideSourcePath,
10
+ } from '../image-contract.js';
11
+ import { TemplatePackError, loadTemplatePackFromSlidesDir } from '../template-pack.js';
12
+ import { checkLayoutDensity } from '../template-layout.js';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ // OHOS: Use system Haitai browser via CDP bridge instead of standalone Chromium
16
+ const { launchBrowserViaCdp, loadPageUrl } = require('../ohos-browser-bridge.cjs');
17
+ const {
18
+ DEFAULT_SLIDE_MODE,
19
+ getSlideModeConfig,
20
+ } = require('../slide-mode.cjs');
21
+
22
+ export const FRAME_PT = getSlideModeConfig(DEFAULT_SLIDE_MODE).framePt;
23
+ export const FRAME_PX = getSlideModeConfig(DEFAULT_SLIDE_MODE).framePx;
24
+ export const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
25
+ export const TEXT_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,li';
26
+ export const TOLERANCE_PX = 0.5;
27
+
28
+ export function toSlideOrder(fileName) {
29
+ const match = fileName.match(/\d+/);
30
+ return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
31
+ }
32
+
33
+ export function sortSlideFiles(a, b) {
34
+ const orderA = toSlideOrder(a);
35
+ const orderB = toSlideOrder(b);
36
+ if (orderA !== orderB) return orderA - orderB;
37
+ return a.localeCompare(b);
38
+ }
39
+
40
+ export function buildIssue(code, message, payload = {}) {
41
+ return { code, message, ...payload };
42
+ }
43
+
44
+ export function summarizeSlides(slides) {
45
+ const summary = {
46
+ totalSlides: slides.length,
47
+ passedSlides: 0,
48
+ failedSlides: 0,
49
+ criticalIssues: 0,
50
+ warnings: 0,
51
+ };
52
+
53
+ for (const slide of slides) {
54
+ if (slide.status === 'pass') {
55
+ summary.passedSlides += 1;
56
+ } else {
57
+ summary.failedSlides += 1;
58
+ }
59
+
60
+ summary.criticalIssues += slide.summary.criticalCount;
61
+ summary.warnings += slide.summary.warningCount;
62
+ }
63
+
64
+ return summary;
65
+ }
66
+
67
+ export function createValidationResult(slides, slideMode = DEFAULT_SLIDE_MODE) {
68
+ const { framePt, framePx } = getSlideModeConfig(slideMode);
69
+ return {
70
+ generatedAt: new Date().toISOString(),
71
+ frame: {
72
+ widthPt: framePt.width,
73
+ heightPt: framePt.height,
74
+ widthPx: framePx.width,
75
+ heightPx: framePx.height,
76
+ },
77
+ slides,
78
+ summary: summarizeSlides(slides),
79
+ };
80
+ }
81
+
82
+ export function createValidationFailure(error, slideMode = DEFAULT_SLIDE_MODE) {
83
+ const { framePt, framePx } = getSlideModeConfig(slideMode);
84
+ return {
85
+ generatedAt: new Date().toISOString(),
86
+ frame: {
87
+ widthPt: framePt.width,
88
+ heightPt: framePt.height,
89
+ widthPx: framePx.width,
90
+ heightPx: framePx.height,
91
+ },
92
+ slides: [],
93
+ summary: {
94
+ totalSlides: 0,
95
+ passedSlides: 0,
96
+ failedSlides: 0,
97
+ criticalIssues: 1,
98
+ warnings: 0,
99
+ },
100
+ error: error instanceof Error ? error.message : String(error),
101
+ };
102
+ }
103
+
104
+ function buildElementPath(element) {
105
+ return typeof element === 'string' && element ? element : 'unknown';
106
+ }
107
+
108
+ function buildImageIssue(severity, code, message, payload = {}) {
109
+ return {
110
+ severity,
111
+ code,
112
+ message,
113
+ ...payload,
114
+ };
115
+ }
116
+
117
+ async function fileExists(filePath) {
118
+ try {
119
+ await access(filePath);
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function shouldSkipLocalAssetExistenceCheck(classification) {
127
+ return (
128
+ classification.kind === 'empty'
129
+ || classification.kind === 'data-url'
130
+ || classification.kind === 'remote-url'
131
+ || classification.kind === 'remote-url-insecure'
132
+ || classification.kind === 'absolute-filesystem-path'
133
+ || classification.kind === 'root-relative-path'
134
+ || classification.kind === 'other-scheme'
135
+ );
136
+ }
137
+
138
+ async function inspectImageContract(slidesDir, fileName, inspection) {
139
+ const critical = [];
140
+ const warning = [];
141
+ const slidePath = join(slidesDir, fileName);
142
+
143
+ for (const image of inspection.images) {
144
+ const source = image.src;
145
+ const classification = classifyImageSource(source);
146
+ const issues = buildImageContractReport({
147
+ slideFile: fileName,
148
+ sources: [{
149
+ element: buildElementPath(image.element),
150
+ source,
151
+ }],
152
+ });
153
+
154
+ for (const issue of issues) {
155
+ const target = issue.severity === 'critical' ? critical : warning;
156
+ target.push(issue);
157
+ }
158
+
159
+ if (shouldSkipLocalAssetExistenceCheck(classification)) {
160
+ continue;
161
+ }
162
+
163
+ const assetPath = resolveSlideSourcePath(slidePath, source);
164
+ if (!(await fileExists(assetPath))) {
165
+ critical.push(buildImageIssue(
166
+ 'critical',
167
+ 'missing-local-asset',
168
+ 'Local image asset is missing.',
169
+ {
170
+ slide: fileName,
171
+ element: buildElementPath(image.element),
172
+ source,
173
+ assetPath,
174
+ },
175
+ ));
176
+ }
177
+ }
178
+
179
+ for (const background of inspection.backgrounds) {
180
+ if (background.urls.length === 0) continue;
181
+
182
+ if (background.element !== 'body') {
183
+ critical.push(buildImageIssue(
184
+ 'critical',
185
+ 'unsupported-background-image',
186
+ 'Non-body background-image usage is not supported for slide content. Use <img src="./assets/<file>"> instead.',
187
+ {
188
+ slide: fileName,
189
+ element: buildElementPath(background.element),
190
+ backgroundImage: background.backgroundImage,
191
+ sources: background.urls,
192
+ },
193
+ ));
194
+ }
195
+
196
+ const issues = buildImageContractReport({
197
+ slideFile: fileName,
198
+ sources: background.urls.map((source) => ({
199
+ element: buildElementPath(background.element),
200
+ source,
201
+ })),
202
+ });
203
+
204
+ for (const issue of issues) {
205
+ const code = issue.code === 'remote-image-url'
206
+ ? 'remote-background-image-url'
207
+ : issue.code === 'remote-image-url-insecure'
208
+ ? 'remote-background-image-url-insecure'
209
+ : issue.code;
210
+ const nextIssue = { ...issue, code };
211
+ const target = issue.severity === 'critical' ? critical : warning;
212
+ target.push(nextIssue);
213
+ }
214
+
215
+ for (const source of background.urls) {
216
+ const classification = classifyImageSource(source);
217
+ if (shouldSkipLocalAssetExistenceCheck(classification)) {
218
+ continue;
219
+ }
220
+
221
+ const assetPath = resolveSlideSourcePath(slidePath, source);
222
+ if (!(await fileExists(assetPath))) {
223
+ critical.push(buildImageIssue(
224
+ 'critical',
225
+ 'missing-local-background-asset',
226
+ 'Background image references a missing local asset.',
227
+ {
228
+ slide: fileName,
229
+ element: buildElementPath(background.element),
230
+ source,
231
+ assetPath,
232
+ },
233
+ ));
234
+ }
235
+ }
236
+ }
237
+
238
+ return { critical, warning };
239
+ }
240
+
241
+ async function inspectVideoContract(slidesDir, fileName, inspection) {
242
+ const critical = [];
243
+ const warning = [];
244
+ const slidePath = join(slidesDir, fileName);
245
+
246
+ for (const video of inspection.videos) {
247
+ const sources = [...new Set([
248
+ typeof video.src === 'string' ? video.src : '',
249
+ ...video.sources,
250
+ ].map((source) => source.trim()).filter(Boolean))];
251
+
252
+ const issues = buildVideoContractReport({
253
+ slideFile: fileName,
254
+ sources: sources.map((source) => ({
255
+ element: buildElementPath(video.element),
256
+ source,
257
+ })),
258
+ });
259
+
260
+ for (const issue of issues) {
261
+ const target = issue.severity === 'critical' ? critical : warning;
262
+ target.push(issue);
263
+ }
264
+
265
+ for (const source of sources) {
266
+ const classification = classifyImageSource(source);
267
+ if (shouldSkipLocalAssetExistenceCheck(classification)) {
268
+ continue;
269
+ }
270
+
271
+ const assetPath = resolveSlideSourcePath(slidePath, source);
272
+ if (!(await fileExists(assetPath))) {
273
+ critical.push(buildImageIssue(
274
+ 'critical',
275
+ 'missing-local-video-asset',
276
+ 'Local video asset is missing.',
277
+ {
278
+ slide: fileName,
279
+ element: buildElementPath(video.element),
280
+ source,
281
+ assetPath,
282
+ },
283
+ ));
284
+ }
285
+ }
286
+
287
+ const poster = typeof video.poster === 'string' ? video.poster.trim() : '';
288
+ if (!poster) {
289
+ continue;
290
+ }
291
+
292
+ const posterIssues = buildImageContractReport({
293
+ slideFile: fileName,
294
+ sources: [{
295
+ element: buildElementPath(video.element),
296
+ source: poster,
297
+ }],
298
+ });
299
+
300
+ for (const issue of posterIssues) {
301
+ const target = issue.severity === 'critical' ? critical : warning;
302
+ target.push(issue);
303
+ }
304
+
305
+ const posterClassification = classifyImageSource(poster);
306
+ if (shouldSkipLocalAssetExistenceCheck(posterClassification)) {
307
+ continue;
308
+ }
309
+
310
+ const posterPath = resolveSlideSourcePath(slidePath, poster);
311
+ if (!(await fileExists(posterPath))) {
312
+ critical.push(buildImageIssue(
313
+ 'critical',
314
+ 'missing-local-video-poster-asset',
315
+ 'Video poster image is missing.',
316
+ {
317
+ slide: fileName,
318
+ element: buildElementPath(video.element),
319
+ source: poster,
320
+ assetPath: posterPath,
321
+ },
322
+ ));
323
+ }
324
+ }
325
+
326
+ return { critical, warning };
327
+ }
328
+
329
+ export async function findSlideFiles(slidesDir) {
330
+ const entries = await readdir(slidesDir, { withFileTypes: true });
331
+ return entries
332
+ .filter((entry) => entry.isFile() && SLIDE_FILE_PATTERN.test(entry.name))
333
+ .map((entry) => entry.name)
334
+ .sort(sortSlideFiles);
335
+ }
336
+
337
+ export function selectSlideFiles(slideFiles, selectedSlides = [], slidesDir = '') {
338
+ if (!Array.isArray(selectedSlides) || selectedSlides.length === 0) {
339
+ return slideFiles;
340
+ }
341
+
342
+ const requested = [...new Set(
343
+ selectedSlides
344
+ .map((slide) => basename(String(slide).trim()))
345
+ .filter(Boolean),
346
+ )];
347
+
348
+ const available = new Set(slideFiles);
349
+ const missing = requested.filter((slide) => !available.has(slide));
350
+ if (missing.length > 0) {
351
+ const location = slidesDir ? ` in ${slidesDir}` : '';
352
+ throw new Error(`Requested slide file(s) not found${location}: ${missing.join(', ')}`);
353
+ }
354
+
355
+ return slideFiles.filter((slide) => available.has(slide) && requested.includes(slide));
356
+ }
357
+
358
+ export async function inspectSlide(page, fileName, slidesDir, slideMode = DEFAULT_SLIDE_MODE) {
359
+ const slidePath = join(slidesDir, fileName);
360
+ const slideUrl = pathToFileURL(slidePath).href;
361
+ const { framePx, sizeLabel } = getSlideModeConfig(slideMode);
362
+
363
+ await loadPageUrl(page, slideUrl, { waitUntil: 'load' });
364
+ await page.evaluate(async () => {
365
+ if (document.fonts?.ready) {
366
+ await document.fonts.ready;
367
+ }
368
+
369
+ await new Promise((resolve) => {
370
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
371
+ });
372
+ });
373
+
374
+ const inspection = await page.evaluate(
375
+ ({ framePx, sizeLabel, textSelector, tolerancePx }) => {
376
+ const skipTags = new Set(['SCRIPT', 'STYLE', 'META', 'LINK', 'HEAD', 'TITLE', 'NOSCRIPT']);
377
+ const critical = [];
378
+ const warning = [];
379
+ const seenOverlaps = new Set();
380
+ const cssUrlRe = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
381
+
382
+ const round = (value) => Number(value.toFixed(2));
383
+ const extractUrls = (value) => {
384
+ const input = typeof value === 'string' ? value : '';
385
+ const matches = [];
386
+ let match;
387
+ cssUrlRe.lastIndex = 0;
388
+ while ((match = cssUrlRe.exec(input)) !== null) {
389
+ const candidate = (match[2] || '').trim();
390
+ if (candidate) {
391
+ matches.push(candidate);
392
+ }
393
+ }
394
+ return matches;
395
+ };
396
+
397
+ const normalizeRect = (rect) => {
398
+ const left = rect.left ?? rect.x ?? 0;
399
+ const top = rect.top ?? rect.y ?? 0;
400
+ const width = rect.width ?? (rect.right - left) ?? 0;
401
+ const height = rect.height ?? (rect.bottom - top) ?? 0;
402
+ const right = rect.right ?? (left + width);
403
+ const bottom = rect.bottom ?? (top + height);
404
+ return {
405
+ x: round(left),
406
+ y: round(top),
407
+ width: round(width),
408
+ height: round(height),
409
+ left: round(left),
410
+ top: round(top),
411
+ right: round(right),
412
+ bottom: round(bottom),
413
+ };
414
+ };
415
+
416
+ const inspectCanvasPaint = (canvas) => {
417
+ const rect = canvas.getBoundingClientRect();
418
+ const context = canvas.getContext('2d');
419
+ const metrics = {
420
+ layoutWidth: round(rect.width),
421
+ layoutHeight: round(rect.height),
422
+ bufferWidth: canvas.width,
423
+ bufferHeight: canvas.height,
424
+ sampledPixels: 0,
425
+ paintedSamples: 0,
426
+ };
427
+
428
+ if (!context || canvas.width <= 0 || canvas.height <= 0) {
429
+ return {
430
+ empty: true,
431
+ reason: !context ? '2d-context-unavailable' : 'zero-size-drawing-buffer',
432
+ metrics,
433
+ };
434
+ }
435
+
436
+ try {
437
+ const imageData = context.getImageData(0, 0, canvas.width, canvas.height).data;
438
+ const totalPixels = canvas.width * canvas.height;
439
+ let sampledPixels = 0;
440
+ let paintedSamples = 0;
441
+
442
+ for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
443
+ sampledPixels += 1;
444
+ if (imageData[alphaIndex] !== 0) {
445
+ paintedSamples += 1;
446
+ break;
447
+ }
448
+ }
449
+
450
+ metrics.sampledPixels = sampledPixels;
451
+ metrics.paintedSamples = paintedSamples;
452
+
453
+ return {
454
+ empty: paintedSamples === 0,
455
+ reason: paintedSamples === 0 ? 'transparent-drawing-buffer' : '',
456
+ metrics,
457
+ };
458
+ } catch (error) {
459
+ return {
460
+ empty: true,
461
+ reason: 'drawing-buffer-unreadable',
462
+ metrics: {
463
+ ...metrics,
464
+ detail: error instanceof Error ? error.message : String(error),
465
+ },
466
+ };
467
+ }
468
+ };
469
+
470
+ const elementPath = (element) => {
471
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return '';
472
+ if (element === document.body) return 'body';
473
+
474
+ const parts = [];
475
+ let current = element;
476
+
477
+ while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
478
+ let part = current.tagName.toLowerCase();
479
+ if (current.id) {
480
+ part += `#${current.id}`;
481
+ parts.unshift(part);
482
+ break;
483
+ }
484
+
485
+ const classNames = Array.from(current.classList).slice(0, 2);
486
+ if (classNames.length > 0) {
487
+ part += `.${classNames.join('.')}`;
488
+ }
489
+
490
+ if (current.parentElement) {
491
+ const siblingsOfSameTag = Array.from(current.parentElement.children)
492
+ .filter((sibling) => sibling.tagName === current.tagName);
493
+ if (siblingsOfSameTag.length > 1) {
494
+ const index = siblingsOfSameTag.indexOf(current);
495
+ part += `:nth-of-type(${index + 1})`;
496
+ }
497
+ }
498
+
499
+ parts.unshift(part);
500
+ current = current.parentElement;
501
+ }
502
+
503
+ return `body > ${parts.join(' > ')}`;
504
+ };
505
+
506
+ const isVisible = (element) => {
507
+ if (skipTags.has(element.tagName)) return false;
508
+ const style = window.getComputedStyle(element);
509
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
510
+ return false;
511
+ }
512
+
513
+ const rect = element.getBoundingClientRect();
514
+ return rect.width > 0 && rect.height > 0;
515
+ };
516
+
517
+ const collectDeclaredBackgroundValues = (element) => {
518
+ const values = [];
519
+ const pushValue = (value) => {
520
+ if (
521
+ typeof value !== 'string'
522
+ || value === ''
523
+ || value === 'none'
524
+ || !value.includes('url(')
525
+ || values.includes(value)
526
+ ) {
527
+ return;
528
+ }
529
+ values.push(value);
530
+ };
531
+
532
+ pushValue(element.style?.getPropertyValue('background-image'));
533
+ pushValue(element.style?.getPropertyValue('background'));
534
+
535
+ const visitRules = (rules) => {
536
+ for (const rule of Array.from(rules || [])) {
537
+ if (rule.type === CSSRule.STYLE_RULE) {
538
+ let matches = false;
539
+ try {
540
+ matches = element.matches(rule.selectorText);
541
+ } catch {
542
+ matches = false;
543
+ }
544
+
545
+ if (!matches) continue;
546
+ pushValue(rule.style?.getPropertyValue('background-image'));
547
+ pushValue(rule.style?.getPropertyValue('background'));
548
+ continue;
549
+ }
550
+
551
+ if ('cssRules' in rule) {
552
+ try {
553
+ visitRules(rule.cssRules);
554
+ } catch {
555
+ // Ignore cross-origin and invalid stylesheet access.
556
+ }
557
+ }
558
+ }
559
+ };
560
+
561
+ for (const sheet of Array.from(document.styleSheets)) {
562
+ try {
563
+ visitRules(sheet.cssRules);
564
+ } catch {
565
+ // Ignore cross-origin stylesheets.
566
+ }
567
+ }
568
+
569
+ return values;
570
+ };
571
+
572
+ // Detect persisted runtime-only editor/viewer injections.
573
+ const baseElements = Array.from(document.querySelectorAll('head base[href]'));
574
+ for (const base of baseElements) {
575
+ critical.push({
576
+ code: 'persisted-editor-base-tag',
577
+ message: 'Slide contains a <base> tag injected by the editor runtime. Remove it so asset paths resolve correctly outside the editor.',
578
+ element: 'head > base',
579
+ detail: base.getAttribute('href'),
580
+ });
581
+ }
582
+
583
+ const editorScriptSignatures = ['[slides-grab:image]', '[slides-grab:'];
584
+ const scripts = Array.from(document.querySelectorAll('head script:not([src])'));
585
+ for (const script of scripts) {
586
+ const text = script.textContent || '';
587
+ const matched = editorScriptSignatures.some((sig) => text.includes(sig));
588
+ if (matched) {
589
+ critical.push({
590
+ code: 'persisted-editor-script',
591
+ message: 'Slide contains a runtime-only editor script that should not be persisted. Remove the injected <script> block.',
592
+ element: 'head > script',
593
+ });
594
+ }
595
+ }
596
+
597
+ const bodyRect = document.body.getBoundingClientRect();
598
+ const frameRect = {
599
+ left: bodyRect.left,
600
+ top: bodyRect.top,
601
+ right: bodyRect.left + (bodyRect.width || framePx.width),
602
+ bottom: bodyRect.top + (bodyRect.height || framePx.height),
603
+ width: bodyRect.width || framePx.width,
604
+ height: bodyRect.height || framePx.height,
605
+ };
606
+
607
+ const allVisibleElements = Array.from(document.body.querySelectorAll('*')).filter(isVisible);
608
+ const visibleSet = new Set(allVisibleElements);
609
+
610
+ for (const element of allVisibleElements) {
611
+ const rect = element.getBoundingClientRect();
612
+ const outsideFrame = (
613
+ rect.left < frameRect.left - tolerancePx
614
+ || rect.top < frameRect.top - tolerancePx
615
+ || rect.right > frameRect.right + tolerancePx
616
+ || rect.bottom > frameRect.bottom + tolerancePx
617
+ );
618
+
619
+ if (outsideFrame) {
620
+ critical.push({
621
+ code: 'overflow-outside-frame',
622
+ message: `Element exceeds the ${sizeLabel} slide frame.`,
623
+ element: elementPath(element),
624
+ bbox: normalizeRect(rect),
625
+ frame: normalizeRect(frameRect),
626
+ });
627
+ }
628
+ }
629
+
630
+ const textElements = Array.from(document.querySelectorAll(textSelector));
631
+ for (const element of textElements) {
632
+ if (!isVisible(element)) continue;
633
+ const content = (element.textContent || '').trim();
634
+ if (!content) continue;
635
+
636
+ const clipped = element.scrollHeight > element.clientHeight;
637
+ if (!clipped) continue;
638
+
639
+ critical.push({
640
+ code: 'text-clipped',
641
+ message: 'Text element is clipped because scrollHeight is larger than clientHeight.',
642
+ element: elementPath(element),
643
+ metrics: {
644
+ scrollHeight: element.scrollHeight,
645
+ clientHeight: element.clientHeight,
646
+ },
647
+ bbox: normalizeRect(element.getBoundingClientRect()),
648
+ });
649
+ }
650
+
651
+ const canvases = Array.from(document.querySelectorAll('canvas'));
652
+ for (const canvas of canvases) {
653
+ if (!isVisible(canvas)) continue;
654
+
655
+ const result = inspectCanvasPaint(canvas);
656
+ if (!result.empty) continue;
657
+
658
+ critical.push({
659
+ code: 'empty-canvas',
660
+ message: 'Canvas has visible layout size but no painted pixels. Chart.js and other canvas charts must render before validation.',
661
+ element: elementPath(canvas),
662
+ detail: result.reason,
663
+ metrics: result.metrics,
664
+ bbox: normalizeRect(canvas.getBoundingClientRect()),
665
+ });
666
+ }
667
+
668
+ const parents = [document.body, ...allVisibleElements];
669
+ for (const parent of parents) {
670
+ const children = Array.from(parent.children).filter((child) => visibleSet.has(child));
671
+ if (children.length < 2) continue;
672
+
673
+ for (let i = 0; i < children.length; i += 1) {
674
+ for (let j = i + 1; j < children.length; j += 1) {
675
+ const first = children[i];
676
+ const second = children[j];
677
+
678
+ const rectA = first.getBoundingClientRect();
679
+ const rectB = second.getBoundingClientRect();
680
+
681
+ const overlapWidth = Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left);
682
+ const overlapHeight = Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top);
683
+
684
+ if (overlapWidth <= tolerancePx || overlapHeight <= tolerancePx) continue;
685
+
686
+ const firstPath = elementPath(first);
687
+ const secondPath = elementPath(second);
688
+ const overlapKey = [firstPath, secondPath].sort().join('::');
689
+
690
+ if (seenOverlaps.has(overlapKey)) continue;
691
+ seenOverlaps.add(overlapKey);
692
+
693
+ warning.push({
694
+ code: 'sibling-overlap',
695
+ message: 'Sibling elements overlap in their bounding boxes.',
696
+ parent: elementPath(parent),
697
+ elements: [firstPath, secondPath],
698
+ intersection: {
699
+ x: round(Math.max(rectA.left, rectB.left)),
700
+ y: round(Math.max(rectA.top, rectB.top)),
701
+ width: round(overlapWidth),
702
+ height: round(overlapHeight),
703
+ },
704
+ boxes: [normalizeRect(rectA), normalizeRect(rectB)],
705
+ });
706
+ }
707
+ }
708
+ }
709
+
710
+ const images = Array.from(document.querySelectorAll('img')).map((element) => ({
711
+ element: elementPath(element),
712
+ src: (element.getAttribute('src') || '').trim(),
713
+ alt: (element.getAttribute('alt') || '').trim(),
714
+ }));
715
+
716
+ const videos = Array.from(document.querySelectorAll('video')).map((element) => ({
717
+ element: elementPath(element),
718
+ src: (element.getAttribute('src') || '').trim(),
719
+ sources: Array.from(element.querySelectorAll('source[src]'))
720
+ .map((source) => (source.getAttribute('src') || '').trim())
721
+ .filter(Boolean),
722
+ poster: (element.getAttribute('poster') || '').trim(),
723
+ }));
724
+
725
+ const backgrounds = [document.body, ...Array.from(document.body.querySelectorAll('*'))]
726
+ .map((element) => {
727
+ const computedBackgroundImage = window.getComputedStyle(element).backgroundImage;
728
+ const declaredBackgroundValues = collectDeclaredBackgroundValues(element);
729
+ const declaredBackgroundImage = declaredBackgroundValues.find((value) => extractUrls(value).length > 0) || '';
730
+ const declaredUrls = declaredBackgroundValues.flatMap(extractUrls);
731
+ const urls = declaredUrls.length > 0 ? declaredUrls : extractUrls(computedBackgroundImage);
732
+
733
+ return {
734
+ element: element === document.body ? 'body' : elementPath(element),
735
+ backgroundImage: declaredBackgroundImage || computedBackgroundImage,
736
+ urls,
737
+ };
738
+ })
739
+ .filter((entry) => entry.urls.length > 0);
740
+
741
+ const templateMetadata = (() => {
742
+ const commentNodes = [
743
+ ...Array.from(document.head?.childNodes || []),
744
+ ...Array.from(document.body?.childNodes || []),
745
+ ].filter((node) => node.nodeType === 8);
746
+ for (const node of commentNodes) {
747
+ const text = node.nodeValue || '';
748
+ const match = text.match(/slides-grab-template:\s*(\{[\s\S]*?\})/);
749
+ if (!match) continue;
750
+ try {
751
+ const parsed = JSON.parse(match[1]);
752
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
753
+ return {
754
+ layoutId: parsed.layoutId ?? parsed.layout_id ?? null,
755
+ layoutKind: parsed.layoutKind ?? parsed.layout_kind ?? null,
756
+ };
757
+ }
758
+ } catch {
759
+ // Ignore malformed template metadata comments so legacy slides stay untouched.
760
+ }
761
+ }
762
+ return null;
763
+ })();
764
+
765
+ const templateRoleText = {};
766
+ const normalizeTemplateRole = (role) => {
767
+ const value = typeof role === 'string' ? role.trim() : '';
768
+ return /^[a-z][a-z0-9_-]{0,63}$/i.test(value) ? value : '';
769
+ };
770
+ const textForRoleElement = (element) => {
771
+ const listItems = Array.from(element.querySelectorAll?.('li') || [])
772
+ .map((item) => (item.textContent || '').trim())
773
+ .filter(Boolean);
774
+ if (listItems.length > 0) return listItems;
775
+ return (element.textContent || '').trim();
776
+ };
777
+ const addTemplateRoleText = (role, value) => {
778
+ const normalizedRole = normalizeTemplateRole(role);
779
+ if (!normalizedRole) return;
780
+ const values = Array.isArray(value)
781
+ ? value.map((entry) => (entry || '').trim()).filter(Boolean)
782
+ : [(value || '').trim()].filter(Boolean);
783
+ if (values.length === 0) return;
784
+ const existing = templateRoleText[normalizedRole];
785
+ if (existing === undefined) {
786
+ templateRoleText[normalizedRole] = values.length === 1 ? values[0] : values;
787
+ return;
788
+ }
789
+ const existingValues = Array.isArray(existing) ? existing : [existing];
790
+ const merged = [...existingValues];
791
+ for (const entry of values) {
792
+ if (!merged.includes(entry)) merged.push(entry);
793
+ }
794
+ templateRoleText[normalizedRole] = merged.length === 1 ? merged[0] : merged;
795
+ };
796
+ if (templateMetadata) {
797
+ for (const element of Array.from(document.body.querySelectorAll('[data-template-role]'))) {
798
+ addTemplateRoleText(element.getAttribute('data-template-role'), textForRoleElement(element));
799
+ }
800
+ for (const element of Array.from(document.body.querySelectorAll('[class]'))) {
801
+ const value = textForRoleElement(element);
802
+ for (const className of Array.from(element.classList)) {
803
+ addTemplateRoleText(className, value);
804
+ }
805
+ }
806
+ const title = document.querySelector('h1') || document.querySelector('h2');
807
+ addTemplateRoleText('title', title ? title.textContent || '' : '');
808
+ const subtitle = document.querySelector('h2') || document.querySelector('.subtitle');
809
+ addTemplateRoleText('subtitle', subtitle ? subtitle.textContent || '' : '');
810
+ addTemplateRoleText('bullets', Array.from(document.querySelectorAll('li'))
811
+ .map((item) => (item.textContent || '').trim())
812
+ .filter(Boolean));
813
+ }
814
+
815
+ return {
816
+ critical,
817
+ warning,
818
+ images,
819
+ videos,
820
+ backgrounds,
821
+ template: {
822
+ metadata: templateMetadata,
823
+ roleText: templateRoleText,
824
+ },
825
+ };
826
+ },
827
+ {
828
+ framePx,
829
+ sizeLabel,
830
+ textSelector: TEXT_SELECTOR,
831
+ tolerancePx: TOLERANCE_PX,
832
+ },
833
+ );
834
+
835
+ const imageContractIssues = await inspectImageContract(slidesDir, fileName, {
836
+ images: inspection.images,
837
+ backgrounds: inspection.backgrounds,
838
+ });
839
+ const videoContractIssues = await inspectVideoContract(slidesDir, fileName, {
840
+ videos: inspection.videos,
841
+ });
842
+
843
+ inspection.critical.push(...imageContractIssues.critical);
844
+ inspection.warning.push(...imageContractIssues.warning);
845
+ inspection.critical.push(...videoContractIssues.critical);
846
+ inspection.warning.push(...videoContractIssues.warning);
847
+ const templateMetadata = inspection.template?.metadata;
848
+ if (templateMetadata) {
849
+ try {
850
+ const pack = loadTemplatePackFromSlidesDir(slidesDir, { optional: true });
851
+ if (pack && Array.isArray(pack.layouts)) {
852
+ const selectedLayout = pack.layouts.find(
853
+ (layout) => layout && layout.layoutId === templateMetadata.layoutId,
854
+ );
855
+ if (selectedLayout) {
856
+ const roleText = inspection.template?.roleText || {};
857
+ const densityWarnings = checkLayoutDensity(roleText, selectedLayout);
858
+ inspection.warning.push(...densityWarnings);
859
+ }
860
+ }
861
+ } catch (error) {
862
+ if (error instanceof TemplatePackError) {
863
+ inspection.warning.push(buildIssue(
864
+ 'template-pack-invalid',
865
+ 'Template pack could not be loaded for density checks; fix .slides-grab/template-pack.json to enable template validation.',
866
+ { detail: error.message },
867
+ ));
868
+ } else {
869
+ throw error;
870
+ }
871
+ }
872
+ }
873
+
874
+ const summary = {
875
+ criticalCount: inspection.critical.length,
876
+ warningCount: inspection.warning.length,
877
+ };
878
+
879
+ return {
880
+ slide: fileName,
881
+ status: summary.criticalCount > 0 ? 'fail' : 'pass',
882
+ critical: inspection.critical,
883
+ warning: inspection.warning,
884
+ summary,
885
+ };
886
+ }
887
+
888
+ export async function scanSlides(page, slidesDir, slideFiles, slideMode = DEFAULT_SLIDE_MODE) {
889
+ const slides = [];
890
+
891
+ for (const slideFile of slideFiles) {
892
+ try {
893
+ const result = await inspectSlide(page, slideFile, slidesDir, slideMode);
894
+ slides.push(result);
895
+ } catch (error) {
896
+ slides.push({
897
+ slide: slideFile,
898
+ status: 'fail',
899
+ critical: [
900
+ buildIssue(
901
+ 'slide-validation-error',
902
+ 'Slide validation failed before checks could complete.',
903
+ { detail: error instanceof Error ? error.message : String(error) },
904
+ ),
905
+ ],
906
+ warning: [],
907
+ summary: {
908
+ criticalCount: 1,
909
+ warningCount: 0,
910
+ },
911
+ });
912
+ }
913
+ }
914
+
915
+ return slides;
916
+ }
917
+
918
+ export function formatValidationFailureForExport(result, exportLabel = 'Export') {
919
+ const findings = [];
920
+
921
+ for (const slide of result.slides) {
922
+ if (slide.status !== 'fail') continue;
923
+ for (const issue of slide.critical) {
924
+ const source = typeof issue.source === 'string' ? ` (${issue.source})` : '';
925
+ findings.push(`- ${slide.slide}: ${issue.code}${source}`);
926
+ if (findings.length >= 8) break;
927
+ }
928
+ if (findings.length >= 8) break;
929
+ }
930
+
931
+ const suffix = findings.length > 0 ? `\n${findings.join('\n')}` : '';
932
+ const modeHint = result.slideMode && result.slideMode !== DEFAULT_SLIDE_MODE
933
+ ? ` --mode ${result.slideMode}`
934
+ : '';
935
+ return `${exportLabel} blocked by slide validation. Run \`slides-grab validate --slides-dir <path>${modeHint}\` for full diagnostics.${suffix}`;
936
+ }
937
+
938
+ const EXPORT_BLOCKING_IMAGE_CONTRACT_CODES = new Set([
939
+ 'absolute-filesystem-image-path',
940
+ 'absolute-filesystem-video-path',
941
+ 'missing-local-asset',
942
+ 'missing-local-background-asset',
943
+ 'missing-local-video-asset',
944
+ 'missing-local-video-poster-asset',
945
+ 'remote-background-image-url',
946
+ 'remote-background-image-url-insecure',
947
+ 'remote-image-url',
948
+ 'remote-image-url-insecure',
949
+ 'remote-video-url',
950
+ 'remote-video-url-insecure',
951
+ 'root-relative-image-path',
952
+ 'root-relative-video-path',
953
+ 'unsupported-image-url-scheme',
954
+ 'unsupported-video-url-scheme',
955
+ 'unsupported-background-image',
956
+ ]);
957
+
958
+ function filterExportBlockingSlides(result, shouldBlockIssue) {
959
+ const slides = result.slides
960
+ .map((slide) => {
961
+ const critical = slide.critical.filter(shouldBlockIssue);
962
+ const warning = slide.warning.filter(shouldBlockIssue);
963
+ const criticalCount = critical.length;
964
+ const warningCount = warning.length;
965
+ return {
966
+ ...slide,
967
+ status: criticalCount > 0 ? 'fail' : 'pass',
968
+ critical,
969
+ warning,
970
+ summary: {
971
+ ...slide.summary,
972
+ criticalCount,
973
+ warningCount,
974
+ },
975
+ };
976
+ })
977
+ .filter((slide) => slide.critical.length > 0 || slide.warning.length > 0);
978
+
979
+ return {
980
+ ...result,
981
+ slides,
982
+ summary: summarizeSlides(slides),
983
+ };
984
+ }
985
+
986
+ export function isBlockingImageContractIssue(issue) {
987
+ return EXPORT_BLOCKING_IMAGE_CONTRACT_CODES.has(issue?.code);
988
+ }
989
+
990
+ export async function ensureSlidesPassValidation(
991
+ slidesDir,
992
+ {
993
+ exportLabel = 'Export',
994
+ slideMode = DEFAULT_SLIDE_MODE,
995
+ shouldBlockIssue = isBlockingImageContractIssue,
996
+ } = {},
997
+ ) {
998
+ const slideFiles = await findSlideFiles(slidesDir);
999
+ if (slideFiles.length === 0) {
1000
+ throw new Error(`No slide-*.html files found in: ${slidesDir}`);
1001
+ }
1002
+
1003
+ const browser = process.platform === 'openharmony'
1004
+ ? await launchBrowserViaCdp()
1005
+ : await require('playwright').chromium.launch({ headless: true });
1006
+ const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
1007
+ const page = await context.newPage();
1008
+
1009
+ try {
1010
+ const slides = await scanSlides(page, slidesDir, slideFiles, slideMode);
1011
+ const result = {
1012
+ ...createValidationResult(slides, slideMode),
1013
+ slideMode,
1014
+ };
1015
+ const blockingResult = filterExportBlockingSlides(result, shouldBlockIssue);
1016
+ if (blockingResult.summary.failedSlides > 0) {
1017
+ throw new Error(formatValidationFailureForExport(blockingResult, exportLabel));
1018
+ }
1019
+ return blockingResult;
1020
+ } finally {
1021
+ await browser.close();
1022
+ }
1023
+ }