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.
@@ -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 build-viewer --slides-dir <path>.',
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) => extractMarkdownSection(markdown, 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
- 'Selected regions on slide (960x540 coordinate space):',
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
- '- Keep slide dimensions at 720pt x 405pt.',
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 const SLIDE_W = 960;
4
- export const SLIDE_H = 540;
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';
@@ -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: SCREENSHOT_SIZE });
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
- }, SCREENSHOT_SIZE);
68
+ }, screenshotSize);
68
69
 
69
70
  await page.screenshot({
70
71
  path: screenshotPath,
@@ -1,8 +1,13 @@
1
- const RESOLUTION_PRESETS = Object.freeze({
2
- '720p': { width: 1280, height: 720 },
3
- '1080p': { width: 1920, height: 1080 },
4
- '1440p': { width: 2560, height: 1440 },
5
- '2160p': { width: 3840, height: 2160 },
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(RESOLUTION_PRESETS);
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 (!RESOLUTION_PRESETS[normalized]) {
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 preset = RESOLUTION_PRESETS[normalized];
50
- return { width: preset.width, height: preset.height };
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: SLIDE_WIDTH_INCHES,
43
- height: SLIDE_HEIGHT_INCHES,
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 TARGET_SLIDE_SIZE_IN = { width: 13.33, height: 7.5 };
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 targetResolution = getResolutionSize(resolution);
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: DEFAULT_CAPTURE_VIEWPORT.width,
33
- height: DEFAULT_CAPTURE_VIEWPORT.height,
38
+ width: framePx.width,
39
+ height: framePx.height,
34
40
  },
35
41
  deviceScaleFactor: targetResolution
36
- ? targetResolution.height / DEFAULT_CAPTURE_VIEWPORT.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(TARGET_SLIDE_SIZE_IN.width * TARGET_RASTER_DPI),
49
- height: Math.round(TARGET_SLIDE_SIZE_IN.height * TARGET_RASTER_DPI),
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, DEFAULT_CAPTURE_VIEWPORT.width),
158
- height: normalizeDimension(bodyDimensions.height, DEFAULT_CAPTURE_VIEWPORT.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
- pres.layout = 'LAYOUT_WIDE';
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 = fs.readdirSync(slidesDir)
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
+ };
@@ -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');