slides-grab 1.2.2 → 1.2.3
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 +16 -0
- package/bin/ppt-agent.js +35 -5
- package/package.json +3 -3
- package/scripts/build-viewer.js +48 -21
- package/scripts/editor-server.js +45 -10
- package/scripts/figma-export.js +16 -1
- package/scripts/html2pdf.js +51 -23
- package/scripts/html2pptx.js +22 -1
- package/scripts/validate-slides.js +22 -5
- package/skills/slides-grab/SKILL.md +9 -5
- package/skills/slides-grab-card-news/SKILL.md +35 -0
- package/skills/slides-grab-design/SKILL.md +13 -11
- package/skills/slides-grab-design/references/design-rules.md +5 -0
- package/skills/slides-grab-design/references/detailed-design-rules.md +5 -0
- package/skills/slides-grab-export/SKILL.md +15 -8
- package/skills/slides-grab-export/references/html2pptx.md +4 -4
- package/src/editor/codex-edit.js +45 -8
- package/src/editor/editor-codex-prompt.md +3 -1
- package/src/editor/js/editor-init.js +34 -2
- package/src/editor/js/editor-state.js +9 -2
- package/src/editor/screenshot.js +4 -3
- package/src/export-resolution.cjs +21 -11
- package/src/figma.js +11 -3
- package/src/pptx-raster-export.cjs +79 -21
- package/src/slide-mode.cjs +72 -0
- package/src/validation/cli.js +23 -0
- package/src/validation/core.js +39 -25
package/src/editor/codex-edit.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import sharp from 'sharp';
|
|
5
6
|
|
|
6
7
|
import { getPackageRoot } from '../resolve.js';
|
|
7
8
|
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const {
|
|
11
|
+
DEFAULT_SLIDE_MODE,
|
|
12
|
+
getSlideModeConfig,
|
|
13
|
+
} = require('../slide-mode.cjs');
|
|
14
|
+
|
|
8
15
|
export const SLIDE_SIZE = { width: 960, height: 540 };
|
|
16
|
+
export function getSlideSize(slideMode = DEFAULT_SLIDE_MODE) {
|
|
17
|
+
return getSlideModeConfig(slideMode).framePx;
|
|
18
|
+
}
|
|
9
19
|
|
|
10
20
|
const PPT_DESIGN_SKILL_PATH = join(getPackageRoot(), 'skills', 'slides-grab-design', 'SKILL.md');
|
|
11
21
|
const EDITOR_CODEX_PROMPT_PATH = join(dirname(new URL(import.meta.url).pathname), 'editor-codex-prompt.md');
|
|
@@ -18,9 +28,13 @@ const EDITOR_PPT_DESIGN_SECTION_HEADINGS = [
|
|
|
18
28
|
const DETAILED_DESIGN_SECTION_HEADINGS = [
|
|
19
29
|
'## Base Settings',
|
|
20
30
|
'## Text Usage Rules',
|
|
31
|
+
'## Icon Usage Rules',
|
|
21
32
|
'## Workflow (Stage 2: Design + Human Review)',
|
|
22
33
|
'## Important Notes',
|
|
23
34
|
];
|
|
35
|
+
const DETAILED_DESIGN_REQUIRED_SECTION_HEADINGS = [
|
|
36
|
+
'## Icon Usage Rules',
|
|
37
|
+
];
|
|
24
38
|
const BEAUTIFUL_SLIDE_DEFAULTS_SECTION_HEADINGS = [
|
|
25
39
|
'## Working Model',
|
|
26
40
|
'## Beautiful Defaults for Slides',
|
|
@@ -49,6 +63,8 @@ const EDITOR_PPT_DESIGN_SKILL_FALLBACK = [
|
|
|
49
63
|
'## Rules',
|
|
50
64
|
'- Keep slide size 720pt x 405pt.',
|
|
51
65
|
'- Keep semantic text tags (`p`, `h1-h6`, `ul`, `ol`, `li`).',
|
|
66
|
+
'- Prefer Lucide as the default icon library for slide UI elements, callouts, and supporting visuals.',
|
|
67
|
+
'- Do not default to emoji for iconography unless the brief explicitly asks for a playful or native-emoji tone.',
|
|
52
68
|
'- Put local images and videos under `<slides-dir>/assets/` and reference them as `./assets/<file>`.',
|
|
53
69
|
'- Allow `data:` URLs when the slide must be fully self-contained.',
|
|
54
70
|
'- Do not leave remote `http(s)://` image URLs in saved slide HTML; download source images into `<slides-dir>/assets/` and reference them as `./assets/<file>`.',
|
|
@@ -87,8 +103,14 @@ const DETAILED_DESIGN_SKILL_FALLBACK = [
|
|
|
87
103
|
'- All text must be inside <p>, <h1>-<h6>, <ul>, <ol>, or <li>.',
|
|
88
104
|
'- Never place text directly in <div> or <span>.',
|
|
89
105
|
'',
|
|
106
|
+
'## Icon Usage Rules',
|
|
107
|
+
'- Prefer Lucide as the default icon library for slide UI elements, callouts, and supporting visuals.',
|
|
108
|
+
'- Do not default to emoji for iconography; reserve emoji for cases where the brief explicitly wants a playful or native-emoji tone.',
|
|
109
|
+
'- Keep icon sizing, stroke weight, and color aligned with the deck\'s approved design tokens.',
|
|
110
|
+
'',
|
|
90
111
|
'## Workflow (Stage 2: Design + Human Review)',
|
|
91
|
-
'- After slide generation or edits, run slides-grab
|
|
112
|
+
'- After slide generation or edits, run slides-grab validate --slides-dir <path>.',
|
|
113
|
+
'- Only after validation passes, run slides-grab build-viewer --slides-dir <path>.',
|
|
92
114
|
'- Edit only the relevant HTML file during revision loops.',
|
|
93
115
|
'- Prefer slides-grab image before remote image sourcing when a slide explicitly needs bespoke imagery.',
|
|
94
116
|
'- Never start PPTX conversion without explicit approval.',
|
|
@@ -281,14 +303,20 @@ function pruneDuplicateLines(markdown, patterns) {
|
|
|
281
303
|
return filtered.join('\n').trim();
|
|
282
304
|
}
|
|
283
305
|
|
|
284
|
-
function loadMarkdownSections(markdownPath, headings, fallback) {
|
|
306
|
+
function loadMarkdownSections(markdownPath, headings, fallback, options = {}) {
|
|
285
307
|
try {
|
|
286
308
|
const markdown = readFileSync(markdownPath, 'utf8');
|
|
309
|
+
const { requiredHeadings = [] } = options;
|
|
310
|
+
const sectionsByHeading = new Map(headings.map((heading) => [
|
|
311
|
+
heading,
|
|
312
|
+
extractMarkdownSection(markdown, heading),
|
|
313
|
+
]));
|
|
287
314
|
const sections = headings
|
|
288
|
-
.map((heading) =>
|
|
315
|
+
.map((heading) => sectionsByHeading.get(heading))
|
|
289
316
|
.filter(Boolean);
|
|
317
|
+
const isMissingRequiredSection = requiredHeadings.some((requiredHeading) => !sectionsByHeading.get(requiredHeading));
|
|
290
318
|
|
|
291
|
-
return sections.length > 0
|
|
319
|
+
return sections.length > 0 && !isMissingRequiredSection
|
|
292
320
|
? sections.join('\n\n')
|
|
293
321
|
: fallback;
|
|
294
322
|
} catch {
|
|
@@ -305,6 +333,9 @@ function getStructuralDesignSkillPrompt() {
|
|
|
305
333
|
DETAILED_DESIGN_SKILL_PATH,
|
|
306
334
|
DETAILED_DESIGN_SECTION_HEADINGS,
|
|
307
335
|
DETAILED_DESIGN_SKILL_FALLBACK,
|
|
336
|
+
{
|
|
337
|
+
requiredHeadings: DETAILED_DESIGN_REQUIRED_SECTION_HEADINGS,
|
|
338
|
+
},
|
|
308
339
|
);
|
|
309
340
|
|
|
310
341
|
return cachedStructuralDesignSkillPrompt;
|
|
@@ -331,11 +362,12 @@ export function getDetailedDesignSkillPrompt() {
|
|
|
331
362
|
].filter(Boolean).join('\n\n');
|
|
332
363
|
}
|
|
333
364
|
|
|
334
|
-
export function buildCodexEditPrompt({ slideFile, slidePath, userPrompt, selections = [] }) {
|
|
365
|
+
export function buildCodexEditPrompt({ slideFile, slidePath, userPrompt, slideMode = DEFAULT_SLIDE_MODE, selections = [] }) {
|
|
335
366
|
const sanitizedPrompt = typeof userPrompt === 'string' ? userPrompt.trim() : '';
|
|
336
367
|
if (!sanitizedPrompt) {
|
|
337
368
|
throw new Error('Prompt must be a non-empty string.');
|
|
338
369
|
}
|
|
370
|
+
const { coordinateSpaceLabel, sizeLabel } = getSlideModeConfig(slideMode);
|
|
339
371
|
|
|
340
372
|
const normalizedSlidePath = typeof slidePath === 'string' && slidePath.trim() !== ''
|
|
341
373
|
? slidePath.trim()
|
|
@@ -357,7 +389,12 @@ export function buildCodexEditPrompt({ slideFile, slidePath, userPrompt, selecti
|
|
|
357
389
|
];
|
|
358
390
|
});
|
|
359
391
|
|
|
360
|
-
const editorPrompt = getEditorPptDesignSkillPrompt()
|
|
392
|
+
const editorPrompt = getEditorPptDesignSkillPrompt()
|
|
393
|
+
.replaceAll('720pt x 405pt', sizeLabel)
|
|
394
|
+
.replace(
|
|
395
|
+
'Run `slides-grab validate --slides-dir <path>` after editing.',
|
|
396
|
+
`Run \`slides-grab validate --slides-dir <path>${slideMode === DEFAULT_SLIDE_MODE ? '' : ` --mode ${slideMode}`}\` after editing.`,
|
|
397
|
+
);
|
|
361
398
|
const editorPromptLines = editorPrompt
|
|
362
399
|
? [
|
|
363
400
|
'Slide edit rules (follow strictly):',
|
|
@@ -373,13 +410,13 @@ export function buildCodexEditPrompt({ slideFile, slidePath, userPrompt, selecti
|
|
|
373
410
|
'User edit request (this is the primary objective — follow it faithfully):',
|
|
374
411
|
sanitizedPrompt,
|
|
375
412
|
'',
|
|
376
|
-
|
|
413
|
+
`Selected regions on slide (${coordinateSpaceLabel} coordinate space):`,
|
|
377
414
|
...selectionLines,
|
|
378
415
|
'Rules:',
|
|
379
416
|
'- Edit only the requested slide HTML file among slide-*.html files.',
|
|
380
417
|
'- Do not modify any other slide HTML files unless explicitly requested.',
|
|
381
418
|
'- Keep existing structure/content unless the request requires a change.',
|
|
382
|
-
|
|
419
|
+
`- Keep slide dimensions at ${sizeLabel}.`,
|
|
383
420
|
'- Keep text in semantic tags (<p>, <h1>-<h6>, <ul>, <ol>, <li>).',
|
|
384
421
|
'- You may add or update supporting files required for the requested slide, including local images and videos under <slides-dir>/assets/ and tldraw source/export files used to generate those assets.',
|
|
385
422
|
'- When the request needs bespoke imagery, prefer `slides-grab image --prompt "<prompt>" --slides-dir <path>` so Nano Banana Pro saves the asset under <slides-dir>/assets/.',
|
|
@@ -15,7 +15,7 @@ The user's edit request is the primary objective. All rules below exist to suppo
|
|
|
15
15
|
5. Return after applying the change.
|
|
16
16
|
|
|
17
17
|
## Slide Rules
|
|
18
|
-
- Keep slide size 720pt x 405pt.
|
|
18
|
+
- Keep slide size appropriate for the current mode (`720pt x 405pt` for presentation, `720pt x 720pt` for card-news).
|
|
19
19
|
- Keep semantic text tags (`p`, `h1-h6`, `ul`, `ol`, `li`).
|
|
20
20
|
- Never place text directly in `<div>` or `<span>`.
|
|
21
21
|
- Always include `#` prefix in CSS colors.
|
|
@@ -38,6 +38,8 @@ The user's edit request is the primary objective. All rules below exist to suppo
|
|
|
38
38
|
- Give each slide one job, one dominant visual anchor, one primary takeaway.
|
|
39
39
|
- Keep copy short enough to scan in seconds.
|
|
40
40
|
- Use whitespace, alignment, scale, cropping, and contrast before adding decorative chrome.
|
|
41
|
+
- Prefer Lucide as the default icon library for slide UI elements, callouts, and supporting visuals.
|
|
42
|
+
- Do not default to emoji for iconography unless the brief explicitly asks for a playful or native-emoji tone.
|
|
41
43
|
- Default to cardless layouts unless a card improves structure.
|
|
42
44
|
- Limit to two typefaces max and one accent color.
|
|
43
45
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// editor-init.js — Entry point: imports, event bindings, init()
|
|
2
2
|
|
|
3
|
-
import { state, TOOL_MODE_DRAW, TOOL_MODE_SELECT } from './editor-state.js';
|
|
3
|
+
import { state, TOOL_MODE_DRAW, TOOL_MODE_SELECT, setSlideFrame } from './editor-state.js';
|
|
4
4
|
import {
|
|
5
|
-
btnPrev, btnNext, slideIframe, drawLayer, promptInput, modelSelect,
|
|
5
|
+
btnPrev, btnNext, slideIframe, slideWrapper, drawLayer, promptInput, modelSelect,
|
|
6
6
|
btnSend, btnClearBboxes, slideCounter,
|
|
7
7
|
toggleBold, toggleItalic, toggleUnderline, toggleStrike,
|
|
8
8
|
alignLeft, alignCenter, alignRight,
|
|
@@ -256,10 +256,42 @@ slideIframe.addEventListener('load', () => {
|
|
|
256
256
|
updateSendState();
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
function applySlideFrameCss(width, height) {
|
|
260
|
+
if (slideWrapper) {
|
|
261
|
+
slideWrapper.style.width = `${width}px`;
|
|
262
|
+
slideWrapper.style.height = `${height}px`;
|
|
263
|
+
}
|
|
264
|
+
if (slideIframe) {
|
|
265
|
+
slideIframe.style.width = `${width}px`;
|
|
266
|
+
slideIframe.style.height = `${height}px`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function loadEditorConfig() {
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch('/api/config');
|
|
273
|
+
if (!res.ok) return;
|
|
274
|
+
const cfg = await res.json();
|
|
275
|
+
const w = cfg?.framePx?.width;
|
|
276
|
+
const h = cfg?.framePx?.height;
|
|
277
|
+
if (w && h) {
|
|
278
|
+
setSlideFrame(w, h);
|
|
279
|
+
applySlideFrameCss(w, h);
|
|
280
|
+
}
|
|
281
|
+
if (cfg?.slideMode && document?.body) {
|
|
282
|
+
document.body.dataset.slideMode = cfg.slideMode;
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// Defaults (960x540) stay in effect.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
// Init
|
|
260
290
|
async function init() {
|
|
261
291
|
setStatus('Loading slide list...');
|
|
262
292
|
|
|
293
|
+
await loadEditorConfig();
|
|
294
|
+
|
|
263
295
|
try {
|
|
264
296
|
const res = await fetch('/api/slides');
|
|
265
297
|
if (!res.ok) {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// editor-state.js — State variables, constants, Maps/Sets
|
|
2
2
|
|
|
3
|
-
export
|
|
4
|
-
export
|
|
3
|
+
export let SLIDE_W = 960;
|
|
4
|
+
export let SLIDE_H = 540;
|
|
5
|
+
|
|
6
|
+
export function setSlideFrame(width, height) {
|
|
7
|
+
const w = Number(width);
|
|
8
|
+
const h = Number(height);
|
|
9
|
+
if (Number.isFinite(w) && w > 0) SLIDE_W = Math.round(w);
|
|
10
|
+
if (Number.isFinite(h) && h > 0) SLIDE_H = Math.round(h);
|
|
11
|
+
}
|
|
5
12
|
export const TOOL_MODE_DRAW = 'draw';
|
|
6
13
|
export const TOOL_MODE_SELECT = 'select';
|
|
7
14
|
export const POPOVER_TEXT = 'text';
|
package/src/editor/screenshot.js
CHANGED
|
@@ -17,8 +17,8 @@ export async function createScreenshotBrowser() {
|
|
|
17
17
|
* Create a fresh screenshot page/context from an existing browser.
|
|
18
18
|
* Caller must close the returned context.
|
|
19
19
|
*/
|
|
20
|
-
export async function createScreenshotPage(browser) {
|
|
21
|
-
const context = await browser.newContext({ viewport:
|
|
20
|
+
export async function createScreenshotPage(browser, screenshotSize = SCREENSHOT_SIZE) {
|
|
21
|
+
const context = await browser.newContext({ viewport: screenshotSize });
|
|
22
22
|
const page = await context.newPage();
|
|
23
23
|
return { context, page };
|
|
24
24
|
}
|
|
@@ -34,6 +34,7 @@ export async function createScreenshotPage(browser) {
|
|
|
34
34
|
* @param {boolean} [options.useHttp] – if true, slidesDir is treated as a base URL
|
|
35
35
|
*/
|
|
36
36
|
export async function captureSlideScreenshot(page, slideFile, screenshotPath, slidesDir, options = {}) {
|
|
37
|
+
const screenshotSize = options.screenshotSize || SCREENSHOT_SIZE;
|
|
37
38
|
const slideUrl = options.useHttp
|
|
38
39
|
? `${slidesDir}/${slideFile}`
|
|
39
40
|
: pathToFileURL(join(slidesDir, slideFile)).href;
|
|
@@ -64,7 +65,7 @@ export async function captureSlideScreenshot(page, slideFile, screenshotPath, sl
|
|
|
64
65
|
const scale = Math.min(width / sourceWidth, height / sourceHeight);
|
|
65
66
|
|
|
66
67
|
bodyStyle.transform = `scale(${scale})`;
|
|
67
|
-
},
|
|
68
|
+
}, screenshotSize);
|
|
68
69
|
|
|
69
70
|
await page.screenshot({
|
|
70
71
|
path: screenshotPath,
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
const {
|
|
2
|
+
DEFAULT_SLIDE_MODE,
|
|
3
|
+
getSlideModeConfig,
|
|
4
|
+
} = require('./slide-mode.cjs');
|
|
5
|
+
|
|
6
|
+
const RESOLUTION_HEIGHTS = Object.freeze({
|
|
7
|
+
'720p': 720,
|
|
8
|
+
'1080p': 1080,
|
|
9
|
+
'1440p': 1440,
|
|
10
|
+
'2160p': 2160,
|
|
6
11
|
});
|
|
7
12
|
|
|
8
13
|
const RESOLUTION_ALIASES = Object.freeze({
|
|
@@ -11,7 +16,7 @@ const RESOLUTION_ALIASES = Object.freeze({
|
|
|
11
16
|
});
|
|
12
17
|
|
|
13
18
|
function getResolutionChoices() {
|
|
14
|
-
return Object.keys(
|
|
19
|
+
return Object.keys(RESOLUTION_HEIGHTS);
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
function normalizeResolutionPreset(value, options = {}) {
|
|
@@ -33,25 +38,30 @@ function normalizeResolutionPreset(value, options = {}) {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
const normalized = RESOLUTION_ALIASES[trimmed] || trimmed;
|
|
36
|
-
if (!
|
|
41
|
+
if (!RESOLUTION_HEIGHTS[normalized]) {
|
|
37
42
|
throw new Error(`Unknown resolution "${value}". Expected one of: ${getResolutionChoices().join(', ')}, 4k`);
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
return normalized;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
function getResolutionSize(value) {
|
|
48
|
+
function getResolutionSize(value, slideMode = DEFAULT_SLIDE_MODE) {
|
|
44
49
|
const normalized = normalizeResolutionPreset(value);
|
|
45
50
|
if (!normalized) {
|
|
46
51
|
return null;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
const
|
|
50
|
-
|
|
54
|
+
const height = RESOLUTION_HEIGHTS[normalized];
|
|
55
|
+
const { framePx } = getSlideModeConfig(slideMode);
|
|
56
|
+
const aspectRatio = framePx.width / framePx.height;
|
|
57
|
+
return {
|
|
58
|
+
width: Math.round(height * aspectRatio),
|
|
59
|
+
height,
|
|
60
|
+
};
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
module.exports = {
|
|
54
|
-
RESOLUTION_PRESETS,
|
|
64
|
+
RESOLUTION_PRESETS: RESOLUTION_HEIGHTS,
|
|
55
65
|
getResolutionChoices,
|
|
56
66
|
getResolutionSize,
|
|
57
67
|
normalizeResolutionPreset,
|
package/src/figma.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import { mkdir } from 'node:fs/promises';
|
|
2
3
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
3
4
|
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const {
|
|
7
|
+
DEFAULT_SLIDE_MODE,
|
|
8
|
+
getSlideModeConfig,
|
|
9
|
+
} = require('./slide-mode.cjs');
|
|
10
|
+
|
|
4
11
|
export const DEFAULT_FIGMA_SUFFIX = '-figma.pptx';
|
|
5
12
|
export const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
|
|
6
13
|
export const FIGMA_EXPORT_LAYOUT_NAME = 'SLIDES_GRAB_STANDARD';
|
|
@@ -36,11 +43,12 @@ export function getFigmaManualImportInstructions() {
|
|
|
36
43
|
return 'Figma Slides -> Import -> select the generated .pptx file.';
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
export function configureFigmaExportPresentation(pres) {
|
|
46
|
+
export function configureFigmaExportPresentation(pres, slideMode = DEFAULT_SLIDE_MODE) {
|
|
47
|
+
const { figmaSizeIn } = getSlideModeConfig(slideMode);
|
|
40
48
|
pres.defineLayout({
|
|
41
49
|
name: FIGMA_EXPORT_LAYOUT_NAME,
|
|
42
|
-
width:
|
|
43
|
-
height:
|
|
50
|
+
width: figmaSizeIn.width,
|
|
51
|
+
height: figmaSizeIn.height,
|
|
44
52
|
});
|
|
45
53
|
pres.layout = FIGMA_EXPORT_LAYOUT_NAME;
|
|
46
54
|
return pres;
|
|
@@ -9,14 +9,19 @@ const {
|
|
|
9
9
|
getResolutionSize,
|
|
10
10
|
normalizeResolutionPreset,
|
|
11
11
|
} = require('./export-resolution.cjs');
|
|
12
|
+
const {
|
|
13
|
+
DEFAULT_SLIDE_MODE,
|
|
14
|
+
getSlideModeChoices,
|
|
15
|
+
getSlideModeConfig,
|
|
16
|
+
normalizeSlideMode,
|
|
17
|
+
} = require('./slide-mode.cjs');
|
|
12
18
|
|
|
13
19
|
const DEFAULT_SLIDES_DIR = 'slides';
|
|
14
20
|
const DEFAULT_OUTPUT = 'output.pptx';
|
|
15
21
|
const DEFAULT_RESOLUTION = '2160p';
|
|
16
|
-
const DEFAULT_CAPTURE_VIEWPORT = { width: 960, height: 540 };
|
|
17
22
|
const DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR = 2;
|
|
18
23
|
const TARGET_RASTER_DPI = 150;
|
|
19
|
-
const
|
|
24
|
+
const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
|
|
20
25
|
|
|
21
26
|
function normalizeDimension(value, fallback) {
|
|
22
27
|
if (!Number.isFinite(value) || value <= 0) {
|
|
@@ -25,31 +30,63 @@ function normalizeDimension(value, fallback) {
|
|
|
25
30
|
return Math.max(1, Math.round(value));
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
function buildPageOptions(resolution = '') {
|
|
29
|
-
const
|
|
33
|
+
function buildPageOptions(resolution = '', slideMode = DEFAULT_SLIDE_MODE) {
|
|
34
|
+
const { framePx } = getSlideModeConfig(slideMode);
|
|
35
|
+
const targetResolution = getResolutionSize(resolution, slideMode);
|
|
30
36
|
return {
|
|
31
37
|
viewport: {
|
|
32
|
-
width:
|
|
33
|
-
height:
|
|
38
|
+
width: framePx.width,
|
|
39
|
+
height: framePx.height,
|
|
34
40
|
},
|
|
35
41
|
deviceScaleFactor: targetResolution
|
|
36
|
-
? targetResolution.height /
|
|
42
|
+
? targetResolution.height / framePx.height
|
|
37
43
|
: DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR,
|
|
38
44
|
};
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
function getTargetRasterSize(resolution = '') {
|
|
42
|
-
const targetResolution = getResolutionSize(resolution);
|
|
47
|
+
function getTargetRasterSize(resolution = '', slideMode = DEFAULT_SLIDE_MODE) {
|
|
48
|
+
const targetResolution = getResolutionSize(resolution, slideMode);
|
|
43
49
|
if (targetResolution) {
|
|
44
50
|
return targetResolution;
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
const { pptxSizeIn } = getSlideModeConfig(slideMode);
|
|
47
54
|
return {
|
|
48
|
-
width: Math.round(
|
|
49
|
-
height: Math.round(
|
|
55
|
+
width: Math.round(pptxSizeIn.width * TARGET_RASTER_DPI),
|
|
56
|
+
height: Math.round(pptxSizeIn.height * TARGET_RASTER_DPI),
|
|
50
57
|
};
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
function toSlideOrder(fileName) {
|
|
61
|
+
const match = fileName.match(/\d+/);
|
|
62
|
+
return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sortSlideFiles(a, b) {
|
|
66
|
+
const orderA = toSlideOrder(a);
|
|
67
|
+
const orderB = toSlideOrder(b);
|
|
68
|
+
if (orderA !== orderB) {
|
|
69
|
+
return orderA - orderB;
|
|
70
|
+
}
|
|
71
|
+
return a.localeCompare(b);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getHtmlSlides(slidesDir) {
|
|
75
|
+
if (!fs.existsSync(slidesDir)) {
|
|
76
|
+
throw new Error(`Slides directory not found: ${slidesDir}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const files = fs.readdirSync(slidesDir)
|
|
80
|
+
.filter((fileName) => SLIDE_FILE_PATTERN.test(fileName))
|
|
81
|
+
.sort(sortSlideFiles);
|
|
82
|
+
|
|
83
|
+
if (files.length === 0) {
|
|
84
|
+
throw new Error(`No slide-*.html files found in ${slidesDir}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
function printUsage() {
|
|
54
91
|
process.stdout.write(
|
|
55
92
|
[
|
|
@@ -58,6 +95,7 @@ function printUsage() {
|
|
|
58
95
|
'Options:',
|
|
59
96
|
` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
|
|
60
97
|
` --output <path> Output pptx path (default: ${DEFAULT_OUTPUT})`,
|
|
98
|
+
` --mode <mode> Slide mode: ${getSlideModeChoices().join('|')} (default: ${DEFAULT_SLIDE_MODE})`,
|
|
61
99
|
` --resolution <preset> Raster size preset: ${getResolutionChoices().join('|')}|4k (default: ${DEFAULT_RESOLUTION})`,
|
|
62
100
|
' -h, --help Show this help message',
|
|
63
101
|
].join('\n'),
|
|
@@ -77,6 +115,7 @@ function parseArgs(args) {
|
|
|
77
115
|
const options = {
|
|
78
116
|
slidesDir: DEFAULT_SLIDES_DIR,
|
|
79
117
|
output: DEFAULT_OUTPUT,
|
|
118
|
+
mode: DEFAULT_SLIDE_MODE,
|
|
80
119
|
resolution: DEFAULT_RESOLUTION,
|
|
81
120
|
help: false,
|
|
82
121
|
};
|
|
@@ -110,6 +149,17 @@ function parseArgs(args) {
|
|
|
110
149
|
continue;
|
|
111
150
|
}
|
|
112
151
|
|
|
152
|
+
if (arg === '--mode') {
|
|
153
|
+
options.mode = normalizeSlideMode(readOptionValue(args, i, '--mode'));
|
|
154
|
+
i += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (arg.startsWith('--mode=')) {
|
|
159
|
+
options.mode = normalizeSlideMode(arg.slice('--mode='.length));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
113
163
|
if (arg === '--resolution') {
|
|
114
164
|
options.resolution = normalizeResolutionPreset(readOptionValue(args, i, '--resolution'));
|
|
115
165
|
i += 1;
|
|
@@ -134,14 +184,17 @@ function parseArgs(args) {
|
|
|
134
184
|
|
|
135
185
|
options.slidesDir = options.slidesDir.trim();
|
|
136
186
|
options.output = options.output.trim();
|
|
187
|
+
options.mode = normalizeSlideMode(options.mode);
|
|
137
188
|
options.resolution = normalizeResolutionPreset(options.resolution);
|
|
138
189
|
return options;
|
|
139
190
|
}
|
|
140
191
|
|
|
141
192
|
async function convertSlide(htmlFile, pres, browser, options = {}) {
|
|
142
193
|
const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
|
|
194
|
+
const slideMode = normalizeSlideMode(options.mode || DEFAULT_SLIDE_MODE);
|
|
195
|
+
const fallbackSize = getSlideModeConfig(slideMode).framePx;
|
|
143
196
|
|
|
144
|
-
const page = await browser.newPage(buildPageOptions(options.resolution));
|
|
197
|
+
const page = await browser.newPage(buildPageOptions(options.resolution, slideMode));
|
|
145
198
|
await page.goto(`file://${filePath}`);
|
|
146
199
|
|
|
147
200
|
const bodyDimensions = await page.evaluate(() => {
|
|
@@ -154,14 +207,14 @@ async function convertSlide(htmlFile, pres, browser, options = {}) {
|
|
|
154
207
|
});
|
|
155
208
|
|
|
156
209
|
await page.setViewportSize({
|
|
157
|
-
width: normalizeDimension(bodyDimensions.width,
|
|
158
|
-
height: normalizeDimension(bodyDimensions.height,
|
|
210
|
+
width: normalizeDimension(bodyDimensions.width, fallbackSize.width),
|
|
211
|
+
height: normalizeDimension(bodyDimensions.height, fallbackSize.height),
|
|
159
212
|
});
|
|
160
213
|
|
|
161
214
|
const screenshot = await page.screenshot({ type: 'png' });
|
|
162
215
|
await page.close();
|
|
163
216
|
|
|
164
|
-
const targetSize = getTargetRasterSize(options.resolution);
|
|
217
|
+
const targetSize = getTargetRasterSize(options.resolution, slideMode);
|
|
165
218
|
|
|
166
219
|
const resized = await sharp(screenshot)
|
|
167
220
|
.resize(targetSize.width, targetSize.height, { fit: 'fill' })
|
|
@@ -191,14 +244,18 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
191
244
|
}
|
|
192
245
|
|
|
193
246
|
const pres = new PptxGenJS();
|
|
194
|
-
|
|
247
|
+
const { pptxSizeIn } = getSlideModeConfig(options.mode);
|
|
248
|
+
pres.defineLayout({
|
|
249
|
+
name: 'SLIDES_GRAB_DYNAMIC',
|
|
250
|
+
width: pptxSizeIn.width,
|
|
251
|
+
height: pptxSizeIn.height,
|
|
252
|
+
});
|
|
253
|
+
pres.layout = 'SLIDES_GRAB_DYNAMIC';
|
|
195
254
|
|
|
196
255
|
const slidesDir = path.resolve(process.cwd(), options.slidesDir);
|
|
197
256
|
const { ensureSlidesPassValidation } = await import('../scripts/validate-slides.js');
|
|
198
|
-
await ensureSlidesPassValidation(slidesDir, { exportLabel: 'PPTX export' });
|
|
199
|
-
const files =
|
|
200
|
-
.filter((fileName) => fileName.endsWith('.html'))
|
|
201
|
-
.sort();
|
|
257
|
+
await ensureSlidesPassValidation(slidesDir, { exportLabel: 'PPTX export', slideMode: options.mode });
|
|
258
|
+
const files = getHtmlSlides(slidesDir);
|
|
202
259
|
|
|
203
260
|
console.log(`Converting ${files.length} slides...`);
|
|
204
261
|
|
|
@@ -209,7 +266,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
209
266
|
const filePath = path.join(slidesDir, file);
|
|
210
267
|
console.log(` Processing: ${file}`);
|
|
211
268
|
try {
|
|
212
|
-
const tmpPath = await convertSlide(filePath, pres, browser, { resolution: options.resolution });
|
|
269
|
+
const tmpPath = await convertSlide(filePath, pres, browser, { mode: options.mode, resolution: options.resolution });
|
|
213
270
|
tmpFiles.push(tmpPath);
|
|
214
271
|
console.log(` ✓ ${file} done`);
|
|
215
272
|
} catch (error) {
|
|
@@ -232,6 +289,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
232
289
|
|
|
233
290
|
module.exports = {
|
|
234
291
|
buildPageOptions,
|
|
292
|
+
getHtmlSlides,
|
|
235
293
|
getTargetRasterSize,
|
|
236
294
|
main,
|
|
237
295
|
parseArgs,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const DEFAULT_SLIDE_MODE = 'presentation';
|
|
2
|
+
const PT_TO_PX = 96 / 72;
|
|
3
|
+
|
|
4
|
+
const SLIDE_MODES = Object.freeze({
|
|
5
|
+
presentation: Object.freeze({
|
|
6
|
+
name: 'presentation',
|
|
7
|
+
framePt: Object.freeze({ width: 720, height: 405 }),
|
|
8
|
+
framePx: Object.freeze({ width: 720 * PT_TO_PX, height: 405 * PT_TO_PX }),
|
|
9
|
+
screenshotPx: Object.freeze({ width: 1600, height: 900 }),
|
|
10
|
+
pptxSizeIn: Object.freeze({ width: 13.33, height: 7.5 }),
|
|
11
|
+
figmaSizeIn: Object.freeze({ width: 10, height: 5.625 }),
|
|
12
|
+
sizeLabel: '720pt x 405pt',
|
|
13
|
+
coordinateSpaceLabel: '960x540',
|
|
14
|
+
aspectRatioLabel: '16:9',
|
|
15
|
+
}),
|
|
16
|
+
'card-news': Object.freeze({
|
|
17
|
+
name: 'card-news',
|
|
18
|
+
framePt: Object.freeze({ width: 720, height: 720 }),
|
|
19
|
+
framePx: Object.freeze({ width: 720 * PT_TO_PX, height: 720 * PT_TO_PX }),
|
|
20
|
+
screenshotPx: Object.freeze({ width: 1600, height: 1600 }),
|
|
21
|
+
pptxSizeIn: Object.freeze({ width: 10, height: 10 }),
|
|
22
|
+
figmaSizeIn: Object.freeze({ width: 10, height: 10 }),
|
|
23
|
+
sizeLabel: '720pt x 720pt',
|
|
24
|
+
coordinateSpaceLabel: '960x960',
|
|
25
|
+
aspectRatioLabel: '1:1',
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function getSlideModeChoices() {
|
|
30
|
+
return Object.keys(SLIDE_MODES);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeSlideMode(value, options = {}) {
|
|
34
|
+
const {
|
|
35
|
+
allowEmpty = false,
|
|
36
|
+
optionName = '--mode',
|
|
37
|
+
} = options;
|
|
38
|
+
|
|
39
|
+
if (typeof value !== 'string') {
|
|
40
|
+
if (allowEmpty) {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`${optionName} must be one of: ${getSlideModeChoices().join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const normalized = value.trim().toLowerCase();
|
|
47
|
+
if (normalized === '') {
|
|
48
|
+
if (allowEmpty) {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`${optionName} must be one of: ${getSlideModeChoices().join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!SLIDE_MODES[normalized]) {
|
|
55
|
+
throw new Error(`Unknown ${optionName} value: ${value}. Expected one of: ${getSlideModeChoices().join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getSlideModeConfig(value = DEFAULT_SLIDE_MODE) {
|
|
62
|
+
return SLIDE_MODES[normalizeSlideMode(value, { optionName: '--mode' })];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
DEFAULT_SLIDE_MODE,
|
|
67
|
+
PT_TO_PX,
|
|
68
|
+
SLIDE_MODES,
|
|
69
|
+
getSlideModeChoices,
|
|
70
|
+
getSlideModeConfig,
|
|
71
|
+
normalizeSlideMode,
|
|
72
|
+
};
|
package/src/validation/cli.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const {
|
|
5
|
+
DEFAULT_SLIDE_MODE,
|
|
6
|
+
getSlideModeChoices,
|
|
7
|
+
normalizeSlideMode,
|
|
8
|
+
} = require('../slide-mode.cjs');
|
|
9
|
+
|
|
1
10
|
export const DEFAULT_SLIDES_DIR = 'slides';
|
|
2
11
|
export const DEFAULT_VALIDATE_FORMAT = 'concise';
|
|
3
12
|
export const VALIDATE_FORMATS = ['concise', 'json', 'json-full'];
|
|
@@ -14,6 +23,7 @@ export function parseValidateCliArgs(args) {
|
|
|
14
23
|
const options = {
|
|
15
24
|
slidesDir: DEFAULT_SLIDES_DIR,
|
|
16
25
|
format: DEFAULT_VALIDATE_FORMAT,
|
|
26
|
+
mode: DEFAULT_SLIDE_MODE,
|
|
17
27
|
help: false,
|
|
18
28
|
slides: [],
|
|
19
29
|
};
|
|
@@ -48,6 +58,17 @@ export function parseValidateCliArgs(args) {
|
|
|
48
58
|
continue;
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
if (arg === '--mode') {
|
|
62
|
+
options.mode = normalizeSlideMode(readOptionValue(args, i, '--mode'));
|
|
63
|
+
i += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (arg.startsWith('--mode=')) {
|
|
68
|
+
options.mode = normalizeSlideMode(arg.slice('--mode='.length));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
if (arg === '--slide') {
|
|
52
73
|
options.slides.push(readOptionValue(args, i, '--slide'));
|
|
53
74
|
i += 1;
|
|
@@ -72,6 +93,7 @@ export function parseValidateCliArgs(args) {
|
|
|
72
93
|
|
|
73
94
|
options.slidesDir = options.slidesDir.trim();
|
|
74
95
|
options.format = options.format.trim();
|
|
96
|
+
options.mode = normalizeSlideMode(options.mode);
|
|
75
97
|
|
|
76
98
|
if (!VALIDATE_FORMATS.includes(options.format)) {
|
|
77
99
|
throw new Error(`Unknown --format value: ${options.format}. Expected one of: ${VALIDATE_FORMATS.join(', ')}`);
|
|
@@ -91,6 +113,7 @@ export function getValidateUsage() {
|
|
|
91
113
|
'Options:',
|
|
92
114
|
` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
|
|
93
115
|
` --format <format> Output format: ${VALIDATE_FORMATS.join(', ')} (default: ${DEFAULT_VALIDATE_FORMAT})`,
|
|
116
|
+
` --mode <mode> Slide mode: ${getSlideModeChoices().join(', ')} (default: ${DEFAULT_SLIDE_MODE})`,
|
|
94
117
|
' --slide <file> Validate only the named slide file (repeatable)',
|
|
95
118
|
' -h, --help Show this help message',
|
|
96
119
|
].join('\n');
|