@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.
- package/LICENSE +21 -0
- package/README-ko.md +292 -0
- package/README.md +301 -0
- package/bin/ohos-postinstall.cjs +118 -0
- package/bin/ppt-agent.js +620 -0
- package/convert.cjs +20 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/glib-2.0/include/glibconfig.h +220 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/index.js +1 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libexpat.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libffi.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libjpeg.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libpcre2-8.so.0 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips-cpp.so.42.20.3 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips.so.42.20.3 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/package.json +13 -0
- package/native/@img/sharp-openharmony-arm64/lib/sharp-openharmony-arm64.node +0 -0
- package/native/@img/sharp-openharmony-arm64/package.json +16 -0
- package/package.json +95 -0
- package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
- package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
- package/scripts/build-viewer.js +444 -0
- package/scripts/design-gate.js +258 -0
- package/scripts/download-video.js +213 -0
- package/scripts/editor-server.js +1167 -0
- package/scripts/figma-export.js +169 -0
- package/scripts/generate-image.js +116 -0
- package/scripts/generate-images.js +164 -0
- package/scripts/html2pdf.js +822 -0
- package/scripts/html2png.js +246 -0
- package/scripts/html2pptx.js +162 -0
- package/scripts/import-template.js +86 -0
- package/scripts/install-runtime.js +216 -0
- package/scripts/render-tldraw.js +44 -0
- package/scripts/validate-slides.js +221 -0
- package/skills/slides-grab/SKILL.md +65 -0
- package/skills/slides-grab/references/presentation-workflow-reference.md +61 -0
- package/skills/slides-grab-card-news/SKILL.md +35 -0
- package/skills/slides-grab-design/SKILL.md +88 -0
- package/skills/slides-grab-design/references/beautiful-slide-defaults.md +88 -0
- package/skills/slides-grab-design/references/design-gate.md +349 -0
- package/skills/slides-grab-design/references/design-rules.md +76 -0
- package/skills/slides-grab-design/references/design-system-full.md +568 -0
- package/skills/slides-grab-design/references/detailed-design-rules.md +70 -0
- package/skills/slides-grab-export/SKILL.md +55 -0
- package/skills/slides-grab-export/references/export-rules.md +27 -0
- package/skills/slides-grab-export/references/html2pptx.md +627 -0
- package/skills/slides-grab-export/references/ooxml.md +427 -0
- package/skills/slides-grab-export/references/pptx-skill-reference.md +189 -0
- package/skills/slides-grab-html/SKILL.md +67 -0
- package/skills/slides-grab-image/SKILL.md +82 -0
- package/skills/slides-grab-plan/SKILL.md +68 -0
- package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
- package/skills/slides-grab-plan/references/outline-format.md +47 -0
- package/skills/slides-grab-plan/references/plan-workflow-reference.md +140 -0
- package/src/codex-imagen.js +182 -0
- package/src/design-diversity-data.js +6932 -0
- package/src/design-gate-report.js +244 -0
- package/src/design-gate-state.js +329 -0
- package/src/design-import.js +164 -0
- package/src/design-md-parser.js +415 -0
- package/src/design-styles-data.js +1928 -0
- package/src/design-styles.js +209 -0
- package/src/editor/codex-edit.js +584 -0
- package/src/editor/edit-subprocess.js +170 -0
- package/src/editor/editor-codex-prompt.md +50 -0
- package/src/editor/editor.html +1785 -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 +238 -0
- package/src/editor/js/editor-dom.js +59 -0
- package/src/editor/js/editor-init.js +405 -0
- package/src/editor/js/editor-navigation.js +54 -0
- package/src/editor/js/editor-select.js +558 -0
- package/src/editor/js/editor-send.js +175 -0
- package/src/editor/js/editor-sse.js +163 -0
- package/src/editor/js/editor-state.js +41 -0
- package/src/editor/js/editor-type.js +71 -0
- package/src/editor/js/editor-utils.js +167 -0
- package/src/editor/js/model-registry.js +37 -0
- package/src/editor/screenshot.js +82 -0
- package/src/export-resolution.cjs +68 -0
- package/src/figma.js +71 -0
- package/src/html2pptx-scale.cjs +120 -0
- package/src/html2pptx.cjs +1262 -0
- package/src/image-contract.js +329 -0
- package/src/image-native.js +468 -0
- package/src/nano-banana.js +841 -0
- package/src/ohos-browser-bridge.cjs +272 -0
- package/src/pptx-raster-export.cjs +299 -0
- package/src/resolve.js +110 -0
- package/src/slide-mode.cjs +72 -0
- package/src/template-fidelity.js +267 -0
- package/src/template-import.js +505 -0
- package/src/template-layout.js +261 -0
- package/src/template-pack.js +255 -0
- package/src/tldraw/render.js +473 -0
- package/src/validation/cli.js +120 -0
- package/src/validation/core.js +1023 -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/design-styles/README.md +20 -0
- package/templates/design-styles/preview.html +4438 -0
- package/templates/diagram-tldraw.html +56 -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 +43 -0
- package/templates/statistics.html +55 -0
- package/templates/team.html +49 -0
- package/templates/timeline.html +59 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* build-viewer.js
|
|
5
|
+
*
|
|
6
|
+
* Builds a single viewer.html from slide-*.html files in selected --slides-dir.
|
|
7
|
+
* Works with file:// protocol — each slide is embedded via <iframe srcdoc="...">
|
|
8
|
+
* for perfect CSS isolation (no regex scoping needed).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { dirname, extname, join, resolve } from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
import { buildSlideRuntimeHtml } from '../src/image-contract.js';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const {
|
|
20
|
+
DEFAULT_SLIDE_MODE,
|
|
21
|
+
getSlideModeChoices,
|
|
22
|
+
getSlideModeConfig,
|
|
23
|
+
normalizeSlideMode,
|
|
24
|
+
} = require('../src/slide-mode.cjs');
|
|
25
|
+
|
|
26
|
+
const DEFAULT_SLIDES_DIR = 'slides';
|
|
27
|
+
const LOCAL_ASSET_PREFIX = './assets/';
|
|
28
|
+
|
|
29
|
+
// Deliberate sandbox decision: allow-scripts (so Chart.js and other in-slide JS
|
|
30
|
+
// render in the viewer) WITHOUT allow-same-origin. Omitting allow-same-origin gives
|
|
31
|
+
// each iframe an opaque origin so first-party slide content cannot reach the parent
|
|
32
|
+
// viewer document. Small local image references are inlined for srcdoc portability;
|
|
33
|
+
// large media such as videos remain relative to the generated viewer file.
|
|
34
|
+
const SLIDE_FRAME_SANDBOX = 'allow-scripts';
|
|
35
|
+
|
|
36
|
+
const MIME_BY_EXTENSION = new Map([
|
|
37
|
+
['.avif', 'image/avif'],
|
|
38
|
+
['.gif', 'image/gif'],
|
|
39
|
+
['.jpg', 'image/jpeg'],
|
|
40
|
+
['.jpeg', 'image/jpeg'],
|
|
41
|
+
['.png', 'image/png'],
|
|
42
|
+
['.svg', 'image/svg+xml'],
|
|
43
|
+
['.webp', 'image/webp'],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function printUsage() {
|
|
47
|
+
process.stdout.write(
|
|
48
|
+
[
|
|
49
|
+
'Usage: node scripts/build-viewer.js [options]',
|
|
50
|
+
'',
|
|
51
|
+
'Options:',
|
|
52
|
+
` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
|
|
53
|
+
` --mode <mode> Slide mode: ${getSlideModeChoices().join(', ')} (default: ${DEFAULT_SLIDE_MODE})`,
|
|
54
|
+
' -h, --help Show this help message',
|
|
55
|
+
].join('\n'),
|
|
56
|
+
);
|
|
57
|
+
process.stdout.write('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readOptionValue(args, index, optionName) {
|
|
61
|
+
const next = args[index + 1];
|
|
62
|
+
if (!next || next.startsWith('-')) {
|
|
63
|
+
throw new Error(`Missing value for ${optionName}.`);
|
|
64
|
+
}
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseCliArgs(args) {
|
|
69
|
+
const options = {
|
|
70
|
+
slidesDir: DEFAULT_SLIDES_DIR,
|
|
71
|
+
mode: DEFAULT_SLIDE_MODE,
|
|
72
|
+
help: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
76
|
+
const arg = args[i];
|
|
77
|
+
|
|
78
|
+
if (arg === '-h' || arg === '--help') {
|
|
79
|
+
options.help = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (arg === '--slides-dir') {
|
|
84
|
+
options.slidesDir = readOptionValue(args, i, '--slides-dir');
|
|
85
|
+
i += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (arg.startsWith('--slides-dir=')) {
|
|
90
|
+
options.slidesDir = arg.slice('--slides-dir='.length);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (arg === '--mode') {
|
|
95
|
+
options.mode = normalizeSlideMode(readOptionValue(args, i, '--mode'));
|
|
96
|
+
i += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (arg.startsWith('--mode=')) {
|
|
101
|
+
options.mode = normalizeSlideMode(arg.slice('--mode='.length));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof options.slidesDir !== 'string' || options.slidesDir.trim() === '') {
|
|
109
|
+
throw new Error('--slides-dir must be a non-empty string.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
options.slidesDir = options.slidesDir.trim();
|
|
113
|
+
options.mode = normalizeSlideMode(options.mode);
|
|
114
|
+
return options;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toSlideOrder(fileName) {
|
|
118
|
+
const match = fileName.match(/\d+/);
|
|
119
|
+
return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sortSlideFiles(a, b) {
|
|
123
|
+
const orderA = toSlideOrder(a);
|
|
124
|
+
const orderB = toSlideOrder(b);
|
|
125
|
+
if (orderA !== orderB) {
|
|
126
|
+
return orderA - orderB;
|
|
127
|
+
}
|
|
128
|
+
return a.localeCompare(b);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function findSlideFiles(slidesDir) {
|
|
132
|
+
return readdirSync(slidesDir)
|
|
133
|
+
.filter((file) => /^slide-.*\.html$/i.test(file))
|
|
134
|
+
.sort(sortSlideFiles);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Escape HTML for safe embedding inside srcdoc="..." attribute.
|
|
139
|
+
* Must escape &, ", < so the srcdoc attribute value is valid.
|
|
140
|
+
*/
|
|
141
|
+
export function escapeForSrcdoc(html) {
|
|
142
|
+
return html
|
|
143
|
+
.replace(/&/g, '&')
|
|
144
|
+
.replace(/"/g, '"');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function loadSlides(slidesDir) {
|
|
148
|
+
return findSlideFiles(slidesDir).map((file) => {
|
|
149
|
+
const slidePath = join(slidesDir, file);
|
|
150
|
+
const html = inlineLocalAssetsForSrcdoc(readFileSync(slidePath, 'utf-8'), slidePath);
|
|
151
|
+
return {
|
|
152
|
+
file,
|
|
153
|
+
html: buildSlideRuntimeHtml(html, {
|
|
154
|
+
baseHref: './',
|
|
155
|
+
slideFile: file,
|
|
156
|
+
}),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getMimeType(filePath) {
|
|
162
|
+
return MIME_BY_EXTENSION.get(extname(filePath).toLowerCase()) || 'application/octet-stream';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function toDataUrl(filePath) {
|
|
166
|
+
const bytes = readFileSync(filePath);
|
|
167
|
+
return `data:${getMimeType(filePath)};base64,${bytes.toString('base64')}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isInlineableAsset(filePath) {
|
|
171
|
+
return getMimeType(filePath).startsWith('image/');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function inlineLocalAssetsForSrcdoc(html, slidePath) {
|
|
175
|
+
const resolveAsset = (source) => {
|
|
176
|
+
if (!source.startsWith(LOCAL_ASSET_PREFIX)) {
|
|
177
|
+
return source;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const filePath = resolve(dirname(slidePath), source);
|
|
182
|
+
if (!isInlineableAsset(filePath)) {
|
|
183
|
+
return source;
|
|
184
|
+
}
|
|
185
|
+
return toDataUrl(filePath);
|
|
186
|
+
} catch {
|
|
187
|
+
return source;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return html
|
|
192
|
+
.replace(/\b(src|poster)=("([^"]*)"|'([^']*)')/gi, (match, attribute, quoted, doubleValue, singleValue) => {
|
|
193
|
+
const value = doubleValue ?? singleValue ?? '';
|
|
194
|
+
const nextValue = resolveAsset(value);
|
|
195
|
+
if (nextValue === value) {
|
|
196
|
+
return match;
|
|
197
|
+
}
|
|
198
|
+
const quote = quoted.startsWith("'") ? "'" : '"';
|
|
199
|
+
return `${attribute}=${quote}${nextValue}${quote}`;
|
|
200
|
+
})
|
|
201
|
+
.replace(/url\(\s*(['"]?)(\.\/assets\/[^'")]+)\1\s*\)/gi, (match, quote, source) => {
|
|
202
|
+
const nextValue = resolveAsset(source);
|
|
203
|
+
if (nextValue === source) {
|
|
204
|
+
return match;
|
|
205
|
+
}
|
|
206
|
+
return `url("${nextValue}")`;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function buildViewerHtml(slides, { slideMode = DEFAULT_SLIDE_MODE } = {}) {
|
|
211
|
+
const { framePt } = getSlideModeConfig(slideMode);
|
|
212
|
+
|
|
213
|
+
return `<!DOCTYPE html>
|
|
214
|
+
<html lang="ko">
|
|
215
|
+
<head>
|
|
216
|
+
<meta charset="UTF-8">
|
|
217
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
218
|
+
<title>Slide Viewer</title>
|
|
219
|
+
<style>
|
|
220
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
221
|
+
|
|
222
|
+
html, body {
|
|
223
|
+
width: 100%;
|
|
224
|
+
height: 100%;
|
|
225
|
+
overflow: hidden;
|
|
226
|
+
background: #111;
|
|
227
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.viewer-container {
|
|
231
|
+
width: 100%;
|
|
232
|
+
height: 100%;
|
|
233
|
+
display: flex;
|
|
234
|
+
flex-direction: column;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.nav-bar {
|
|
238
|
+
height: 48px;
|
|
239
|
+
background: #1a1a1a;
|
|
240
|
+
border-bottom: 1px solid #333;
|
|
241
|
+
display: flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
justify-content: center;
|
|
244
|
+
gap: 16px;
|
|
245
|
+
flex-shrink: 0;
|
|
246
|
+
z-index: 100;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.nav-bar button {
|
|
250
|
+
background: #333;
|
|
251
|
+
color: #fff;
|
|
252
|
+
border: none;
|
|
253
|
+
border-radius: 6px;
|
|
254
|
+
padding: 6px 16px;
|
|
255
|
+
font-size: 13px;
|
|
256
|
+
font-family: inherit;
|
|
257
|
+
cursor: pointer;
|
|
258
|
+
transition: background 0.15s;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.nav-bar button:hover {
|
|
262
|
+
background: #555;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.nav-bar button:disabled {
|
|
266
|
+
opacity: 0.3;
|
|
267
|
+
cursor: default;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.slide-counter {
|
|
271
|
+
color: #aaa;
|
|
272
|
+
font-size: 14px;
|
|
273
|
+
font-weight: 500;
|
|
274
|
+
min-width: 60px;
|
|
275
|
+
text-align: center;
|
|
276
|
+
font-variant-numeric: tabular-nums;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.btn-fullscreen {
|
|
280
|
+
position: absolute;
|
|
281
|
+
right: 16px;
|
|
282
|
+
background: transparent !important;
|
|
283
|
+
font-size: 18px;
|
|
284
|
+
padding: 6px 10px !important;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.slide-viewport {
|
|
288
|
+
flex: 1;
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
justify-content: center;
|
|
292
|
+
overflow: hidden;
|
|
293
|
+
position: relative;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.slide-scaler {
|
|
297
|
+
width: ${framePt.width}pt;
|
|
298
|
+
height: ${framePt.height}pt;
|
|
299
|
+
position: relative;
|
|
300
|
+
transform-origin: center center;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.slide-frame {
|
|
304
|
+
position: absolute;
|
|
305
|
+
inset: 0;
|
|
306
|
+
width: ${framePt.width}pt;
|
|
307
|
+
height: ${framePt.height}pt;
|
|
308
|
+
border: none;
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
opacity: 0;
|
|
311
|
+
pointer-events: none;
|
|
312
|
+
transition: opacity 0.25s ease;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.slide-frame.active {
|
|
316
|
+
opacity: 1;
|
|
317
|
+
pointer-events: auto;
|
|
318
|
+
}
|
|
319
|
+
</style>
|
|
320
|
+
</head>
|
|
321
|
+
<body>
|
|
322
|
+
<div class="viewer-container">
|
|
323
|
+
<div class="nav-bar">
|
|
324
|
+
<button id="btn-prev" title="Previous (\\u2190)">Prev</button>
|
|
325
|
+
<span class="slide-counter" id="counter">1 / ${slides.length}</span>
|
|
326
|
+
<button id="btn-next" title="Next (\\u2192)">Next</button>
|
|
327
|
+
<button class="btn-fullscreen" id="btn-fs" title="Fullscreen (F)">⛶</button>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div class="slide-viewport" id="viewport">
|
|
331
|
+
<div class="slide-scaler" id="scaler">
|
|
332
|
+
${slides.map((s, i) => ` <iframe class="slide-frame${i === 0 ? ' active' : ''}" data-slide="${i + 1}" srcdoc="${escapeForSrcdoc(s.html)}" sandbox="${SLIDE_FRAME_SANDBOX}"></iframe>`).join('\n')}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<script>
|
|
338
|
+
const TOTAL = ${slides.length};
|
|
339
|
+
let current = 1;
|
|
340
|
+
|
|
341
|
+
const frames = document.querySelectorAll('.slide-frame');
|
|
342
|
+
const counter = document.getElementById('counter');
|
|
343
|
+
const btnPrev = document.getElementById('btn-prev');
|
|
344
|
+
const btnNext = document.getElementById('btn-next');
|
|
345
|
+
const scaler = document.getElementById('scaler');
|
|
346
|
+
const viewport = document.getElementById('viewport');
|
|
347
|
+
|
|
348
|
+
function goTo(n) {
|
|
349
|
+
n = Math.max(1, Math.min(TOTAL, n));
|
|
350
|
+
if (n === current) return;
|
|
351
|
+
frames[current - 1].classList.remove('active');
|
|
352
|
+
current = n;
|
|
353
|
+
frames[current - 1].classList.add('active');
|
|
354
|
+
counter.textContent = current + ' / ' + TOTAL;
|
|
355
|
+
btnPrev.disabled = current === 1;
|
|
356
|
+
btnNext.disabled = current === TOTAL;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function next() { goTo(current + 1); }
|
|
360
|
+
function prev() { goTo(current - 1); }
|
|
361
|
+
|
|
362
|
+
btnPrev.addEventListener('click', prev);
|
|
363
|
+
btnNext.addEventListener('click', next);
|
|
364
|
+
btnPrev.disabled = true;
|
|
365
|
+
|
|
366
|
+
document.addEventListener('keydown', (e) => {
|
|
367
|
+
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); next(); }
|
|
368
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }
|
|
369
|
+
else if (e.key === 'Home') { e.preventDefault(); goTo(1); }
|
|
370
|
+
else if (e.key === 'End') { e.preventDefault(); goTo(TOTAL); }
|
|
371
|
+
else if (e.key === 'f' || e.key === 'F') {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
toggleFullscreen();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
function toggleFullscreen() {
|
|
378
|
+
if (!document.fullscreenElement) {
|
|
379
|
+
document.documentElement.requestFullscreen().catch(() => {});
|
|
380
|
+
} else {
|
|
381
|
+
document.exitFullscreen();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
document.getElementById('btn-fs').addEventListener('click', toggleFullscreen);
|
|
385
|
+
|
|
386
|
+
function rescale() {
|
|
387
|
+
const vw = viewport.clientWidth;
|
|
388
|
+
const vh = viewport.clientHeight;
|
|
389
|
+
const slideW = scaler.offsetWidth;
|
|
390
|
+
const slideH = scaler.offsetHeight;
|
|
391
|
+
const scale = Math.min(vw / slideW, vh / slideH) * 0.95;
|
|
392
|
+
scaler.style.transform = 'scale(' + scale + ')';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
window.addEventListener('resize', rescale);
|
|
396
|
+
document.addEventListener('fullscreenchange', () => setTimeout(rescale, 100));
|
|
397
|
+
rescale();
|
|
398
|
+
</script>
|
|
399
|
+
</body>
|
|
400
|
+
</html>`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function main(args = process.argv.slice(2)) {
|
|
404
|
+
const options = parseCliArgs(args);
|
|
405
|
+
if (options.help) {
|
|
406
|
+
printUsage();
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const slidesDir = resolve(process.cwd(), options.slidesDir);
|
|
411
|
+
const output = join(slidesDir, 'viewer.html');
|
|
412
|
+
|
|
413
|
+
let slides;
|
|
414
|
+
try {
|
|
415
|
+
slides = loadSlides(slidesDir);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
418
|
+
process.stderr.write(`Failed to read slide directory: ${slidesDir}\n${message}\n`);
|
|
419
|
+
process.exitCode = 1;
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (slides.length === 0) {
|
|
424
|
+
console.error(`No slide-*.html files found in: ${slidesDir}`);
|
|
425
|
+
process.exitCode = 1;
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
console.log(`Found ${slides.length} slides`);
|
|
430
|
+
writeFileSync(output, buildViewerHtml(slides, { slideMode: options.mode }), 'utf-8');
|
|
431
|
+
console.log(`Built viewer: ${output}`);
|
|
432
|
+
return { slidesDir, output, slides };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
436
|
+
|
|
437
|
+
if (isMain) {
|
|
438
|
+
try {
|
|
439
|
+
main();
|
|
440
|
+
} catch (error) {
|
|
441
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { ensureSlidesPassValidation } from './validate-slides.js';
|
|
10
|
+
import { assertProceedReportsComplete } from '../src/design-gate-report.js';
|
|
11
|
+
import { assertTemplateFidelityPass, buildTemplateFidelityReport } from '../src/template-fidelity.js';
|
|
12
|
+
import {
|
|
13
|
+
buildDesignGatePaths,
|
|
14
|
+
buildDesignGateReport,
|
|
15
|
+
collectFileFingerprints,
|
|
16
|
+
collectSlideFingerprints,
|
|
17
|
+
createDesignGateState,
|
|
18
|
+
findGateSlideFiles,
|
|
19
|
+
normalizeGateVerdict,
|
|
20
|
+
writeDesignGateState,
|
|
21
|
+
} from '../src/design-gate-state.js';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const packageRoot = resolve(__dirname, '..');
|
|
25
|
+
const DEFAULT_SLIDES_DIR = 'slides';
|
|
26
|
+
const DEFAULT_SLIDE_MODE = 'presentation';
|
|
27
|
+
const DEFAULT_RESOLUTION = '2160p';
|
|
28
|
+
|
|
29
|
+
function printUsage() {
|
|
30
|
+
process.stdout.write(
|
|
31
|
+
[
|
|
32
|
+
'Usage: slides-grab design-gate [options]',
|
|
33
|
+
'',
|
|
34
|
+
'Options:',
|
|
35
|
+
` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
|
|
36
|
+
` --slide-mode <mode> Slide mode: presentation|card-news (default: ${DEFAULT_SLIDE_MODE})`,
|
|
37
|
+
` --resolution <preset> PNG evidence resolution preset (default: ${DEFAULT_RESOLUTION})`,
|
|
38
|
+
' --verdict <verdict> Gate verdict: proceed|revise|rethink',
|
|
39
|
+
' --pass-a-report <path> Pass A review report file',
|
|
40
|
+
' --pass-b-report <path> Pass B review report file',
|
|
41
|
+
' --output-dir <path> PNG evidence directory (default: <slides-dir>/.slides-grab/gate-preview)',
|
|
42
|
+
' -h, --help Show this help message',
|
|
43
|
+
].join('\n'),
|
|
44
|
+
);
|
|
45
|
+
process.stdout.write('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseArgs(args) {
|
|
49
|
+
const options = {
|
|
50
|
+
slidesDir: DEFAULT_SLIDES_DIR,
|
|
51
|
+
slideMode: DEFAULT_SLIDE_MODE,
|
|
52
|
+
resolution: DEFAULT_RESOLUTION,
|
|
53
|
+
verdict: '',
|
|
54
|
+
passAReport: '',
|
|
55
|
+
passBReport: '',
|
|
56
|
+
outputDir: '',
|
|
57
|
+
help: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
61
|
+
const arg = args[index];
|
|
62
|
+
if (arg === '-h' || arg === '--help') {
|
|
63
|
+
options.help = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ARGUMENT_READERS.has(arg)) {
|
|
68
|
+
options[ARGUMENT_READERS.get(arg)] = readOptionValue(args, index, arg);
|
|
69
|
+
index += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const equalSignIndex = arg.indexOf('=');
|
|
74
|
+
if (equalSignIndex > -1) {
|
|
75
|
+
const name = arg.slice(0, equalSignIndex);
|
|
76
|
+
const value = arg.slice(equalSignIndex + 1);
|
|
77
|
+
if (ARGUMENT_READERS.has(name)) {
|
|
78
|
+
options[ARGUMENT_READERS.get(name)] = value;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.help) return options;
|
|
87
|
+
|
|
88
|
+
options.slidesDir = requireNonEmpty(options.slidesDir, '--slides-dir');
|
|
89
|
+
options.slideMode = requireNonEmpty(options.slideMode, '--slide-mode');
|
|
90
|
+
options.resolution = requireNonEmpty(options.resolution, '--resolution');
|
|
91
|
+
options.verdict = normalizeGateVerdict(requireNonEmpty(options.verdict, '--verdict'));
|
|
92
|
+
options.passAReport = requireNonEmpty(options.passAReport, '--pass-a-report');
|
|
93
|
+
options.passBReport = requireNonEmpty(options.passBReport, '--pass-b-report');
|
|
94
|
+
if (options.outputDir) options.outputDir = options.outputDir.trim();
|
|
95
|
+
return options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ARGUMENT_READERS = new Map([
|
|
99
|
+
['--slides-dir', 'slidesDir'],
|
|
100
|
+
['--slide-mode', 'slideMode'],
|
|
101
|
+
['--resolution', 'resolution'],
|
|
102
|
+
['--verdict', 'verdict'],
|
|
103
|
+
['--pass-a-report', 'passAReport'],
|
|
104
|
+
['--pass-b-report', 'passBReport'],
|
|
105
|
+
['--output-dir', 'outputDir'],
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
function readOptionValue(args, index, optionName) {
|
|
109
|
+
const value = args[index + 1];
|
|
110
|
+
if (!value || value.startsWith('-')) {
|
|
111
|
+
throw new Error(`Missing value for ${optionName}.`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function requireNonEmpty(value, optionName) {
|
|
117
|
+
const trimmed = String(value || '').trim();
|
|
118
|
+
if (!trimmed) throw new Error(`${optionName} must be a non-empty string.`);
|
|
119
|
+
return trimmed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function renderGateEvidence(options, outputDir) {
|
|
123
|
+
await mkdir(outputDir, { recursive: true });
|
|
124
|
+
const scriptPath = resolve(packageRoot, 'scripts/html2png.js');
|
|
125
|
+
const args = [
|
|
126
|
+
scriptPath,
|
|
127
|
+
'--slides-dir',
|
|
128
|
+
options.slidesDir,
|
|
129
|
+
'--output-dir',
|
|
130
|
+
outputDir,
|
|
131
|
+
'--slide-mode',
|
|
132
|
+
options.slideMode,
|
|
133
|
+
'--resolution',
|
|
134
|
+
options.resolution,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
138
|
+
const child = spawn(process.execPath, args, {
|
|
139
|
+
cwd: process.cwd(),
|
|
140
|
+
stdio: 'inherit',
|
|
141
|
+
env: { ...process.env, PPT_AGENT_PACKAGE_ROOT: packageRoot },
|
|
142
|
+
});
|
|
143
|
+
child.on('error', rejectPromise);
|
|
144
|
+
child.on('close', (code, signal) => {
|
|
145
|
+
if (signal) {
|
|
146
|
+
rejectPromise(new Error(`PNG evidence render terminated by signal ${signal}.`));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (code !== 0) {
|
|
150
|
+
rejectPromise(new Error(`PNG evidence render failed with exit code ${code}.`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
resolvePromise();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readEvidenceReport(filePath, label) {
|
|
159
|
+
const resolvedPath = resolve(process.cwd(), filePath);
|
|
160
|
+
if (!existsSync(resolvedPath)) {
|
|
161
|
+
throw new Error(`${label} report not found: ${resolvedPath}`);
|
|
162
|
+
}
|
|
163
|
+
const report = (await readFile(resolvedPath, 'utf-8')).trim();
|
|
164
|
+
if (!report) {
|
|
165
|
+
throw new Error(`${label} report is empty: ${resolvedPath}`);
|
|
166
|
+
}
|
|
167
|
+
return { resolvedPath, report };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
171
|
+
const options = parseArgs(argv);
|
|
172
|
+
if (options.help) {
|
|
173
|
+
printUsage();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const paths = buildDesignGatePaths(options.slidesDir);
|
|
178
|
+
const previewDir = options.outputDir ? resolve(process.cwd(), options.outputDir) : paths.previewDir;
|
|
179
|
+
await ensureSlidesPassValidation(paths.slidesDir, {
|
|
180
|
+
exportLabel: 'design gate',
|
|
181
|
+
slideMode: options.slideMode,
|
|
182
|
+
shouldBlockIssue: () => true,
|
|
183
|
+
});
|
|
184
|
+
await renderGateEvidence(options, previewDir);
|
|
185
|
+
|
|
186
|
+
const passA = await readEvidenceReport(options.passAReport, 'Pass A');
|
|
187
|
+
const passB = await readEvidenceReport(options.passBReport, 'Pass B');
|
|
188
|
+
let gateValidation = { status: options.verdict === 'proceed' ? 'passed' : 'not-run' };
|
|
189
|
+
const slideFiles = await findGateSlideFiles(paths.slidesDir);
|
|
190
|
+
const slideFingerprints = await collectSlideFingerprints(paths.slidesDir);
|
|
191
|
+
const evidenceFiles = slideFiles.map((fileName) => fileName.replace(/\.html$/i, '.png'));
|
|
192
|
+
const previewRelativeDir = relative(paths.slidesDir, previewDir);
|
|
193
|
+
const previewFingerprintFiles = evidenceFiles.map((fileName) => join(previewRelativeDir, fileName));
|
|
194
|
+
const passReportFingerprintFiles = [
|
|
195
|
+
relative(paths.slidesDir, passA.resolvedPath),
|
|
196
|
+
relative(paths.slidesDir, passB.resolvedPath),
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const templateFidelity = await buildTemplateFidelityReport({
|
|
200
|
+
slidesDir: paths.slidesDir,
|
|
201
|
+
slideFiles,
|
|
202
|
+
previewRelativeDir,
|
|
203
|
+
});
|
|
204
|
+
if (options.verdict === 'proceed') {
|
|
205
|
+
assertTemplateFidelityPass(templateFidelity);
|
|
206
|
+
}
|
|
207
|
+
const templateReferenceFingerprintFiles = templateFidelity.referencePreviewFiles || [];
|
|
208
|
+
|
|
209
|
+
if (options.verdict === 'proceed') {
|
|
210
|
+
gateValidation = assertProceedReportsComplete({
|
|
211
|
+
passAReport: passA.report,
|
|
212
|
+
passBReport: passB.report,
|
|
213
|
+
evidenceFiles,
|
|
214
|
+
slideFingerprints,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const state = await createDesignGateState({
|
|
218
|
+
slidesDir: paths.slidesDir,
|
|
219
|
+
slideMode: options.slideMode,
|
|
220
|
+
resolution: options.resolution,
|
|
221
|
+
verdict: options.verdict,
|
|
222
|
+
previewDir,
|
|
223
|
+
reportPath: paths.reportPath,
|
|
224
|
+
passA: { reportPath: passA.resolvedPath, summary: firstLine(passA.report), ...gateValidation.passA },
|
|
225
|
+
passB: { reportPath: passB.resolvedPath, summary: firstLine(passB.report), ...gateValidation.passB },
|
|
226
|
+
gateValidation,
|
|
227
|
+
passReportFingerprints: await collectFileFingerprints(paths.slidesDir, passReportFingerprintFiles),
|
|
228
|
+
previewFingerprints: await collectFileFingerprints(paths.slidesDir, previewFingerprintFiles),
|
|
229
|
+
templateFidelity: {
|
|
230
|
+
status: templateFidelity.status,
|
|
231
|
+
slides: templateFidelity.slides,
|
|
232
|
+
findings: templateFidelity.findings,
|
|
233
|
+
referencePreviewFingerprints: await collectFileFingerprints(paths.slidesDir, templateReferenceFingerprintFiles),
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const report = buildDesignGateReport(state, passA.report, passB.report);
|
|
237
|
+
await writeDesignGateState(paths.slidesDir, state, report);
|
|
238
|
+
|
|
239
|
+
process.stdout.write(`Design gate recorded: ${state.verdict}\n`);
|
|
240
|
+
process.stdout.write(`Evidence PNGs: ${previewDir}\n`);
|
|
241
|
+
process.stdout.write(`Gate report: ${paths.reportPath}\n`);
|
|
242
|
+
process.stdout.write(`Gate state: ${paths.statePath}\n`);
|
|
243
|
+
|
|
244
|
+
if (state.verdict !== 'proceed') {
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function firstLine(value) {
|
|
250
|
+
return value.split(/\r?\n/).find((line) => line.trim())?.trim() || '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
254
|
+
main().catch((error) => {
|
|
255
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
});
|
|
258
|
+
}
|