@deckspec/cli 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deckspec/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "files": [
5
5
  "dist",
6
6
  "src",
@@ -18,9 +18,9 @@
18
18
  "react": "^19.0.0",
19
19
  "react-dom": "^19.0.0",
20
20
  "zod": "^3.23.0",
21
- "@deckspec/dsl": "0.1.3",
22
- "@deckspec/schema": "0.1.3",
23
- "@deckspec/renderer": "0.1.3"
21
+ "@deckspec/dsl": "0.1.5",
22
+ "@deckspec/schema": "0.1.5",
23
+ "@deckspec/renderer": "0.1.5"
24
24
  },
25
25
  "license": "Apache-2.0",
26
26
  "repository": {
@@ -22,7 +22,7 @@ npx deckspec render decks/<deck-name>/deck.yaml -o output/<deck-name>
22
22
  ### 2. Run the screenshot script
23
23
 
24
24
  ```bash
25
- node scripts/screenshot-deck.mjs output/<deck-name>/index.html output/<deck-name>-slides
25
+ node .claude/skills/deckspec-screenshot/screenshot-deck.mjs output/<deck-name>/index.html output/<deck-name>-slides
26
26
  ```
27
27
 
28
28
  This will:
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import { chromium } from "playwright";
3
+ import { resolve } from "node:path";
4
+ import { mkdirSync } from "node:fs";
5
+
6
+ const args = process.argv.slice(2);
7
+ const htmlPath = resolve(args[0] ?? "output/hashimotoya-fuel.html");
8
+ const outDir = resolve(args[1] ?? "output/hashimotoya-fuel-slides");
9
+ mkdirSync(outDir, { recursive: true });
10
+
11
+ const browser = await chromium.launch({ headless: true });
12
+ // Use the native slide resolution for pixel-perfect screenshots
13
+ const page = await browser.newPage({ viewport: { width: 1200, height: 675 } });
14
+ await page.goto(`file://${htmlPath}`);
15
+ await page.waitForTimeout(1000);
16
+
17
+ // Force slide to render at native 1200x675 without viewer scaling
18
+ await page.evaluate(() => {
19
+ // Hide navigation
20
+ const nav = document.querySelector(".nav-controls");
21
+ if (nav) nav.style.display = "none";
22
+
23
+ // Remove body flex centering — render slide flush at top-left
24
+ document.body.style.display = "block";
25
+ document.body.style.overflow = "hidden";
26
+ document.body.style.background = "transparent";
27
+
28
+ // Make active slide-outer fill viewport exactly at native size
29
+ const outers = document.querySelectorAll(".slide-outer");
30
+ outers.forEach((el) => {
31
+ el.style.width = "1200px";
32
+ el.style.height = "675px";
33
+ // Remove scaling — render at 1:1
34
+ const slide = el.querySelector(".slide, .slide-pad, .slide-stack, .slide-center, .slide-white");
35
+ if (slide) {
36
+ slide.style.transform = "none";
37
+ }
38
+ });
39
+ });
40
+
41
+ const total = await page.evaluate(() =>
42
+ document.querySelectorAll(".slide-outer").length
43
+ );
44
+ console.log(`Found ${total} slides`);
45
+
46
+ for (let i = 0; i < total; i++) {
47
+ if (i > 0) {
48
+ await page.keyboard.press("ArrowRight");
49
+ await page.waitForTimeout(300);
50
+ }
51
+
52
+ // Screenshot just the active slide-outer element for pixel-perfect capture
53
+ const slideEl = await page.$(".slide-outer.active");
54
+ if (slideEl) {
55
+ const filename = `${outDir}/slide-${String(i + 1).padStart(2, "0")}.png`;
56
+ await slideEl.screenshot({ path: filename });
57
+ console.log(`✓ ${filename}`);
58
+ }
59
+ }
60
+
61
+ await browser.close();
62
+ console.log(`\n✓ Saved ${total} slides to ${outDir}/`);
@@ -7,6 +7,12 @@ description: "Use this skill when the user asks to convert a DeckSpec deck to Po
7
7
 
8
8
  Convert a DeckSpec deck.yaml to a PowerPoint (.pptx) file.
9
9
 
10
+ ## How It Works
11
+
12
+ The conversion script (`deck-to-pptx.mjs`) reads `deck.yaml`, looks up each slide's `file:` pattern name in a `renderers` registry, and calls the matching renderer function to build the slide using pptxgenjs.
13
+
14
+ **Patterns without a registered renderer produce blank slides with a warning.** When you encounter this, you need to add a renderer function for that pattern.
15
+
10
16
  ## Prerequisites
11
17
 
12
18
  - pptxgenjs installed: `npm install -D pptxgenjs`
@@ -16,21 +22,227 @@ Convert a DeckSpec deck.yaml to a PowerPoint (.pptx) file.
16
22
  ### 1. Run the conversion
17
23
 
18
24
  ```bash
19
- node scripts/deck-to-pptx.mjs decks/<deck-name>/deck.yaml -o output/<deck-name>.pptx
25
+ node .claude/skills/deckspec-to-pptx/deck-to-pptx.mjs decks/<deck-name>/deck.yaml -o output/<deck-name>.pptx
20
26
  ```
21
27
 
22
28
  ### 2. Check for warnings
23
29
 
24
- The script warns for unregistered patterns: `⚠ No pptx renderer for pattern "<name>" — blank slide`
30
+ ```
31
+ ⚠ No pptx renderer for pattern "my-pattern" — blank slide
32
+ ```
25
33
 
26
- To fix, add a renderer function in `scripts/deck-to-pptx.mjs`.
34
+ If you see this, add a renderer for that pattern (see "Adding a Renderer" below).
27
35
 
28
36
  ### 3. Open and verify
29
37
 
30
- Open the .pptx in PowerPoint or Google Slides to verify.
38
+ Open the .pptx in PowerPoint, Keynote, or Google Slides to verify.
39
+
40
+ ## Adding a Renderer
41
+
42
+ When a pattern has no pptx renderer, you need to add one in `deck-to-pptx.mjs`.
43
+
44
+ ### Step 1: Read the pattern's React source
45
+
46
+ Look at `themes/<theme>/patterns/<name>/index.tsx` to understand:
47
+ - The Zod schema (what `vars` are available)
48
+ - The layout structure (CSS classes, grid, cards, etc.)
49
+ - What content goes where
50
+
51
+ ### Step 2: Write the renderer function
52
+
53
+ Add a function in the `// ─── Slide renderers ───` section of `deck-to-pptx.mjs`:
54
+
55
+ ```javascript
56
+ function renderMyPattern(slide, vars) {
57
+ const padX = px(64); // horizontal padding
58
+ const padY = px(48); // vertical padding
59
+
60
+ // Label (small uppercase text at top)
61
+ if (vars.label) {
62
+ slide.addText(vars.label.toUpperCase(), {
63
+ x: padX, y: padY, w: W - 2 * padX, h: 0.2,
64
+ fontSize: 9, fontFace: FONT_B, bold: true,
65
+ color: C.primary, charSpacing: 1.5, margin: 0,
66
+ });
67
+ }
68
+
69
+ // Heading
70
+ slide.addText(vars.heading, {
71
+ x: padX, y: padY + 0.25, w: W - 2 * padX, h: 0.35,
72
+ fontSize: 18, fontFace: FONT_H, bold: true,
73
+ color: C.fg, margin: 0,
74
+ });
75
+
76
+ // ... add more elements
77
+ }
78
+ ```
79
+
80
+ ### Step 3: Register the renderer
81
+
82
+ Add it to the `renderers` object at the bottom of the file:
83
+
84
+ ```javascript
85
+ const renderers = {
86
+ // ... existing renderers
87
+ "my-pattern": renderMyPattern,
88
+ };
89
+ ```
90
+
91
+ ### Step 4: Re-run and verify
92
+
93
+ ```bash
94
+ node .claude/skills/deckspec-to-pptx/deck-to-pptx.mjs decks/<deck>/deck.yaml -o output/<deck>.pptx
95
+ ```
96
+
97
+ ## PptxGenJS API Reference
98
+
99
+ ### Available Constants
100
+
101
+ | Constant | Description |
102
+ |----------|-------------|
103
+ | `C.primary` | Primary color (hex without #) |
104
+ | `C.fg` | Foreground/text color |
105
+ | `C.bg` | Background color |
106
+ | `C.muted` | Muted text color |
107
+ | `C.border` | Border color |
108
+ | `C.card` | Card background color |
109
+ | `FONT_H` | Heading font (Noto Sans JP) |
110
+ | `FONT_B` | Body font (Noto Sans JP) |
111
+ | `W` | Slide width in inches (10) |
112
+ | `H` | Slide height in inches (5.625) |
113
+ | `px(v)` | Convert CSS pixels to inches (1200px = 10in) |
114
+
115
+ ### Core Methods
116
+
117
+ ```javascript
118
+ // Text
119
+ slide.addText("Hello", {
120
+ x: px(64), y: px(48), w: 5, h: 0.4,
121
+ fontSize: 18, fontFace: FONT_H, bold: true,
122
+ color: C.fg, align: "center", valign: "middle",
123
+ margin: 0,
124
+ });
125
+
126
+ // Rich text (multiple styles in one box)
127
+ slide.addText([
128
+ { text: "Bold part", options: { bold: true, fontSize: 14, color: C.fg } },
129
+ { text: " normal part", options: { fontSize: 12, color: C.muted } },
130
+ ], { x: 1, y: 1, w: 8, h: 0.5, margin: 0 });
131
+
132
+ // Shape
133
+ slide.addShape(pres.shapes.RECTANGLE, {
134
+ x: 1, y: 1, w: 3, h: 2,
135
+ fill: { color: C.card },
136
+ line: { color: C.border, width: 1 },
137
+ });
138
+
139
+ // Line
140
+ slide.addShape(pres.shapes.LINE, {
141
+ x: 1, y: 1, w: 5, h: 0, // horizontal line (h=0)
142
+ line: { color: C.border, width: 0.5 },
143
+ });
144
+
145
+ // Circle/Oval
146
+ slide.addShape(pres.shapes.OVAL, {
147
+ x: 1, y: 1, w: 1, h: 1,
148
+ fill: { color: C.bg },
149
+ line: { color: C.fg, width: 1.5 },
150
+ });
151
+
152
+ // Table
153
+ slide.addTable(rows, {
154
+ x: px(64), y: 1, w: W - 2 * px(64),
155
+ colW: [1.5, 3, 3],
156
+ border: { type: "solid", pt: 0.5, color: C.border },
157
+ rowH: [0.4, 0.35, 0.35],
158
+ margin: [4, 8, 4, 8],
159
+ autoPage: false,
160
+ });
161
+
162
+ // Image (base64)
163
+ slide.addImage({
164
+ data: "image/png;base64,...",
165
+ x: 1, y: 1, w: 4, h: 3,
166
+ });
167
+ ```
168
+
169
+ ### Important Rules
170
+
171
+ 1. **Colors are 6-char hex WITHOUT `#`** — `"E5001F"` not `"#E5001F"`
172
+ 2. **Use `charSpacing` not `letterSpacing`** — pptxgenjs uses its own property name
173
+ 3. **Always set `margin: 0`** on text boxes for precise alignment
174
+ 4. **Never reuse option objects** between `addText`/`addShape` calls — pptxgenjs mutates them
175
+ 5. **`breakLine: true`** in rich text options to force line break after that segment
176
+ 6. **`strike: true`** for strikethrough text (useful for "before" prices)
177
+ 7. **`highlight: "FF0000"`** to add text highlight/background color on rich text segments
178
+
179
+ ### Common Layout Patterns
180
+
181
+ **Label + Heading + Content** (most common):
182
+ ```javascript
183
+ // Top: small label
184
+ // Below: large heading
185
+ // Rest: content area
186
+ const padX = px(64);
187
+ const padY = px(48);
188
+ const contentTop = padY + 0.85; // below heading
189
+ const contentBot = H - padY;
190
+ ```
191
+
192
+ **Card Grid**:
193
+ ```javascript
194
+ const items = vars.items;
195
+ const gap = px(1);
196
+ const cardW = (W - 2 * padX - gap * (items.length - 1)) / items.length;
197
+
198
+ items.forEach((item, i) => {
199
+ const cx = padX + i * (cardW + gap);
200
+ slide.addShape(pres.shapes.RECTANGLE, {
201
+ x: cx, y: cardTop, w: cardW, h: cardH,
202
+ fill: { color: C.card },
203
+ });
204
+ // Add text inside card...
205
+ });
206
+ ```
207
+
208
+ **Two-Column Layout**:
209
+ ```javascript
210
+ const leftW = (W - 2 * padX) * 0.5;
211
+ const rightX = padX + leftW + px(48);
212
+ const rightW = W - rightX - padX;
213
+ ```
214
+
215
+ ### Helper Functions
216
+
217
+ ```javascript
218
+ // resolveImage(filename) — converts local file to base64 data URI
219
+ const imgData = resolveImage(vars.image);
220
+ if (imgData) {
221
+ slide.addImage({ data: imgData, x: 1, y: 1, w: 4, h: 3 });
222
+ }
223
+
224
+ // getImageSize(filename) — returns { w, h } in pixels (macOS sips)
225
+ const size = getImageSize(vars.image);
226
+
227
+ // containImage(imgW, imgH, boxW, boxH) — fit preserving aspect ratio
228
+ const fit = containImage(size.w, size.h, boxW, boxH);
229
+ slide.addImage({
230
+ data: imgData,
231
+ x: boxX + fit.offX, y: boxY + fit.offY,
232
+ w: fit.w, h: fit.h,
233
+ });
234
+ ```
235
+
236
+ ## Registered Renderers
237
+
238
+ The following patterns have pptx renderers pre-built:
31
239
 
32
- ## Notes
240
+ | Pattern | Layout |
241
+ |---------|--------|
242
+ | `price-before-after` | Before→After price comparison cards |
243
+ | `comparison-table` | Multi-column table with icon indicators |
244
+ | `image-full` | Header + full-width image |
245
+ | `conclusion-summary` | Logo + heading + summary points |
246
+ | `closing-triad` | Two-column: message + triangle diagram |
33
247
 
34
- - Colors and fonts are loaded from `themes/<theme>/tokens.json`
35
- - Local images are embedded as base64
36
- - Not all patterns have pptx renderers yet — unregistered patterns produce blank slides
248
+ All other patterns will produce blank slides until you add a renderer.
@@ -0,0 +1,421 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deck-to-pptx.mjs — Convert a DeckSpec deck.yaml to PowerPoint (.pptx)
4
+ *
5
+ * Usage: node .claude/skills/deckspec-to-pptx/deck-to-pptx.mjs decks/my-deck/deck.yaml -o output/my-deck.pptx
6
+ */
7
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { resolve, dirname, join } from "node:path";
9
+ import { execSync } from "node:child_process";
10
+ import { load as loadYaml } from "js-yaml";
11
+ import PptxGenJS from "pptxgenjs";
12
+
13
+ // ─── Args ───────────────────────────────────────────────────────────
14
+ const args = process.argv.slice(2);
15
+ const inputIdx = args.findIndex((a) => !a.startsWith("-"));
16
+ const outputIdx = args.indexOf("-o");
17
+ if (inputIdx < 0 || outputIdx < 0 || !args[outputIdx + 1]) {
18
+ console.error("Usage: node deck-to-pptx.mjs <deck.yaml> -o <output.pptx>");
19
+ process.exit(1);
20
+ }
21
+ const inputPath = resolve(args[inputIdx]);
22
+ const outputPath = resolve(args[outputIdx + 1]);
23
+ const basePath = dirname(inputPath);
24
+
25
+ // ─── Load deck.yaml & tokens ────────────────────────────────────────
26
+ const deck = loadYaml(readFileSync(inputPath, "utf-8"));
27
+ const themeName = deck.meta?.theme ?? "noir-display";
28
+ const themeDir = resolve("themes", themeName);
29
+ const tokens = JSON.parse(readFileSync(join(themeDir, "tokens.json"), "utf-8"));
30
+
31
+ // ─── Color / Font helpers ───────────────────────────────────────────
32
+ const hex = (c) => c.replace(/^#/, "");
33
+ const C = {
34
+ primary: hex(tokens.colors.primary),
35
+ fg: hex(tokens.colors.foreground),
36
+ bg: hex(tokens.colors.background),
37
+ muted: hex(tokens.colors["muted-foreground"]),
38
+ border: hex(tokens.colors.border),
39
+ card: hex(tokens.colors["card-background"] ?? "#ffffff"),
40
+ };
41
+ const FONT_H = "Noto Sans JP";
42
+ const FONT_B = "Noto Sans JP";
43
+
44
+ // Slide inches
45
+ const W = 10;
46
+ const H = 5.625;
47
+
48
+ // px→inch (1200px = 10in)
49
+ const px = (v) => v / 120;
50
+
51
+ // ─── Image helper (local file → base64 data URI) ───────────────────
52
+ function resolveImage(filename) {
53
+ if (!filename) return null;
54
+ if (filename.startsWith("data:") || filename.startsWith("http")) return filename;
55
+ const abs = resolve(basePath, filename);
56
+ const buf = readFileSync(abs);
57
+ const ext = filename.split(".").pop().toLowerCase();
58
+ const mime = ext === "png" ? "image/png" : ext === "svg" ? "image/svg+xml" : "image/jpeg";
59
+ return `${mime};base64,${buf.toString("base64")}`;
60
+ }
61
+
62
+ /** Get image pixel dimensions via sips (macOS) */
63
+ function getImageSize(filename) {
64
+ if (!filename || filename.startsWith("data:") || filename.startsWith("http")) return null;
65
+ const abs = resolve(basePath, filename);
66
+ try {
67
+ const out = execSync(`sips -g pixelWidth -g pixelHeight "${abs}"`, { encoding: "utf-8" });
68
+ const w = parseInt(out.match(/pixelWidth:\s*(\d+)/)?.[1] ?? "0");
69
+ const h = parseInt(out.match(/pixelHeight:\s*(\d+)/)?.[1] ?? "0");
70
+ return w > 0 && h > 0 ? { w, h } : null;
71
+ } catch { return null; }
72
+ }
73
+
74
+ /** Fit image within box preserving aspect ratio, centered */
75
+ function containImage(imgW, imgH, boxW, boxH) {
76
+ const imgRatio = imgW / imgH;
77
+ const boxRatio = boxW / boxH;
78
+ let w, h;
79
+ if (imgRatio > boxRatio) {
80
+ w = boxW;
81
+ h = boxW / imgRatio;
82
+ } else {
83
+ h = boxH;
84
+ w = boxH * imgRatio;
85
+ }
86
+ const x = (boxW - w) / 2;
87
+ const y = (boxH - h) / 2;
88
+ return { w, h, offX: x, offY: y };
89
+ }
90
+
91
+ // ─── Icon color map ─────────────────────────────────────────────────
92
+ const iconColors = { good: "2563EB", bad: C.primary, warn: "D97706" };
93
+ const iconSymbol = { good: "●", bad: "✕", warn: "▲" };
94
+
95
+ // ─── Create presentation ────────────────────────────────────────────
96
+ const pres = new PptxGenJS();
97
+ pres.layout = "LAYOUT_16x9";
98
+ pres.title = deck.meta?.title ?? "Untitled";
99
+ pres.author = "DeckSpec";
100
+
101
+ // ─── Slide renderers ────────────────────────────────────────────────
102
+
103
+ function renderPriceBeforeAfter(slide, vars) {
104
+ const padX = px(64);
105
+ const padY = px(48);
106
+
107
+ slide.addText(vars.label.toUpperCase(), {
108
+ x: padX, y: padY, w: W - 2 * padX, h: 0.2,
109
+ fontSize: 9, fontFace: FONT_B, bold: true,
110
+ color: C.primary, charSpacing: 1.5, margin: 0,
111
+ });
112
+
113
+ slide.addText(vars.heading, {
114
+ x: padX, y: padY + 0.25, w: W - 2 * padX, h: 0.35,
115
+ fontSize: 18, fontFace: FONT_H, bold: true,
116
+ color: C.fg, margin: 0,
117
+ });
118
+
119
+ const items = vars.items;
120
+ const cardTop = padY + 0.85;
121
+ const cardBot = H - padY - (vars.footnote ? 0.35 : 0);
122
+ const cardH = cardBot - cardTop;
123
+ const gap = px(1);
124
+ const cardW = (W - 2 * padX - gap * (items.length - 1)) / items.length;
125
+
126
+ items.forEach((item, i) => {
127
+ const cx = padX + i * (cardW + gap);
128
+ slide.addShape(pres.shapes.RECTANGLE, {
129
+ x: cx, y: cardTop, w: cardW, h: cardH, fill: { color: C.card },
130
+ });
131
+ slide.addText(item.name, {
132
+ x: cx, y: cardTop + px(32), w: cardW, h: 0.35,
133
+ fontSize: 15, fontFace: FONT_H, bold: true, color: C.fg, align: "center", margin: 0,
134
+ });
135
+ const priceY = cardTop + cardH * 0.35;
136
+ slide.addText("BEFORE", {
137
+ x: cx, y: priceY, w: cardW * 0.38, h: 0.18,
138
+ fontSize: 8, fontFace: FONT_B, bold: true, color: C.muted, align: "center", charSpacing: 1, margin: 0,
139
+ });
140
+ slide.addText(item.before, {
141
+ x: cx, y: priceY + 0.18, w: cardW * 0.38, h: 0.35,
142
+ fontSize: 20, fontFace: FONT_H, bold: true, color: C.muted, align: "center", strike: true, margin: 0,
143
+ });
144
+ slide.addText("→", {
145
+ x: cx + cardW * 0.38, y: priceY + 0.12, w: cardW * 0.24, h: 0.35,
146
+ fontSize: 22, color: C.primary, align: "center", margin: 0,
147
+ });
148
+ slide.addText("AFTER", {
149
+ x: cx + cardW * 0.62, y: priceY, w: cardW * 0.38, h: 0.18,
150
+ fontSize: 8, fontFace: FONT_B, bold: true, color: C.primary, align: "center", charSpacing: 1, margin: 0,
151
+ });
152
+ slide.addText(item.after, {
153
+ x: cx + cardW * 0.62, y: priceY + 0.18, w: cardW * 0.38, h: 0.35,
154
+ fontSize: 26, fontFace: FONT_H, bold: true, color: C.fg, align: "center", margin: 0,
155
+ });
156
+ if (item.note) {
157
+ slide.addText(item.note, {
158
+ x: cx + px(8), y: cardTop + cardH - px(48), w: cardW - px(16), h: 0.3,
159
+ fontSize: 9, fontFace: FONT_B, color: C.muted, align: "center", margin: 0,
160
+ });
161
+ }
162
+ });
163
+
164
+ if (vars.footnote) {
165
+ slide.addText(vars.footnote, {
166
+ x: padX, y: H - padY - 0.25, w: W - 2 * padX, h: 0.25,
167
+ fontSize: 9, fontFace: FONT_B, color: C.muted, align: "center", margin: 0,
168
+ });
169
+ }
170
+ }
171
+
172
+ function renderComparisonTable(slide, vars) {
173
+ const padX = px(64);
174
+ const padY = px(40);
175
+
176
+ slide.addText(vars.label.toUpperCase(), {
177
+ x: padX, y: padY, w: W - 2 * padX, h: 0.2,
178
+ fontSize: 9, fontFace: FONT_B, bold: true,
179
+ color: C.primary, charSpacing: 1.5, margin: 0,
180
+ });
181
+
182
+ slide.addText(vars.heading, {
183
+ x: padX, y: padY + 0.22, w: W - 2 * padX, h: 0.35,
184
+ fontSize: 16, fontFace: FONT_H, bold: true, color: C.fg, margin: 0,
185
+ });
186
+
187
+ const colCount = vars.columns.length;
188
+ const rowLabelW = 1.5;
189
+ const dataW = (W - 2 * padX - rowLabelW) / colCount;
190
+ const colWidths = [rowLabelW, ...Array(colCount).fill(dataW)];
191
+ const badgeColor = { red: C.primary, blue: "2563EB", green: "16A34A" };
192
+ const tableRows = [];
193
+
194
+ const headerRow = [{ text: "", options: { fill: { color: C.fg }, color: C.card } }];
195
+ vars.columns.forEach((col) => {
196
+ const cellText = [
197
+ { text: col.name, options: { bold: true, fontSize: 12, color: "FFFFFF", breakLine: true } },
198
+ ];
199
+ if (col.status) {
200
+ cellText.push({
201
+ text: ` ${col.status}`,
202
+ options: { fontSize: 8, bold: true, color: "FFFFFF", highlight: badgeColor[col.statusColor ?? "red"] },
203
+ });
204
+ }
205
+ headerRow.push({ text: cellText, options: { fill: { color: C.fg }, color: "FFFFFF", valign: "middle" } });
206
+ });
207
+ tableRows.push(headerRow);
208
+
209
+ vars.rows.forEach((rowLabel, ri) => {
210
+ const rowBg = ri % 2 === 0 ? C.card : C.bg;
211
+ const row = [{
212
+ text: rowLabel,
213
+ options: { fontSize: 9, bold: true, color: C.muted, fill: { color: rowBg }, valign: "middle" },
214
+ }];
215
+ vars.columns.forEach((col) => {
216
+ const cell = col.values[ri];
217
+ const isObj = cell != null && typeof cell === "object";
218
+ const text = isObj ? cell.text : (cell ?? "—");
219
+ const icon = isObj ? cell.icon : undefined;
220
+ const cellContent = [];
221
+ if (icon) {
222
+ cellContent.push({ text: iconSymbol[icon] + " ", options: { color: iconColors[icon], fontSize: 9, bold: true } });
223
+ }
224
+ cellContent.push({ text, options: { fontSize: 10, color: C.fg } });
225
+ row.push({ text: cellContent, options: { fill: { color: rowBg }, valign: "middle" } });
226
+ });
227
+ tableRows.push(row);
228
+ });
229
+
230
+ const tableY = padY + 0.75;
231
+ const tableH = H - tableY - (vars.footnote ? px(44) : px(32));
232
+
233
+ slide.addTable(tableRows, {
234
+ x: padX, y: tableY, w: W - 2 * padX, h: tableH,
235
+ colW: colWidths,
236
+ border: { type: "solid", pt: 0.5, color: C.border },
237
+ rowH: [0.42, ...Array(vars.rows.length).fill((tableH - 0.42) / vars.rows.length)],
238
+ margin: [4, 8, 4, 8],
239
+ autoPage: false,
240
+ });
241
+
242
+ if (vars.footnote) {
243
+ slide.addText(vars.footnote, {
244
+ x: padX, y: H - px(36), w: W - 2 * padX, h: 0.25,
245
+ fontSize: 9, fontFace: FONT_B, color: C.muted, margin: 0,
246
+ });
247
+ }
248
+ }
249
+
250
+ function renderImageFull(slide, vars) {
251
+ const padX = px(48);
252
+ const padY = px(32);
253
+
254
+ slide.addText(vars.label.toUpperCase(), {
255
+ x: padX, y: padY, w: W - 2 * padX, h: 0.2,
256
+ fontSize: 9, fontFace: FONT_B, bold: true,
257
+ color: C.primary, charSpacing: 1.5, margin: 0,
258
+ });
259
+
260
+ slide.addText(vars.heading, {
261
+ x: padX, y: padY + 0.22, w: W - 2 * padX, h: 0.32,
262
+ fontSize: 16, fontFace: FONT_H, bold: true, color: C.fg, margin: 0,
263
+ });
264
+
265
+ let imgTop = padY + 0.6;
266
+ if (vars.subtitle) {
267
+ slide.addText(vars.subtitle, {
268
+ x: padX, y: padY + 0.56, w: W - 2 * padX, h: 0.22,
269
+ fontSize: 10, fontFace: FONT_B, color: C.muted, margin: 0,
270
+ });
271
+ imgTop = padY + 0.82;
272
+ }
273
+
274
+ const imgData = resolveImage(vars.image);
275
+ if (imgData) {
276
+ const imgBot = H - padY - (vars.footnote ? 0.3 : 0);
277
+ const boxH = imgBot - imgTop;
278
+ const boxW = W - 2 * padX;
279
+ const size = getImageSize(vars.image);
280
+ if (size) {
281
+ const fit = containImage(size.w, size.h, boxW, boxH);
282
+ slide.addImage({ data: imgData, x: padX + fit.offX, y: imgTop + fit.offY, w: fit.w, h: fit.h });
283
+ } else {
284
+ slide.addImage({ data: imgData, x: padX, y: imgTop, w: boxW, h: boxH });
285
+ }
286
+ }
287
+
288
+ if (vars.footnote) {
289
+ slide.addText(vars.footnote, {
290
+ x: padX, y: H - padY - 0.22, w: W - 2 * padX, h: 0.22,
291
+ fontSize: 9, fontFace: FONT_B, color: C.muted, margin: 0,
292
+ });
293
+ }
294
+ }
295
+
296
+ function renderConclusionSummary(slide, vars) {
297
+ const padX = px(64);
298
+
299
+ const logoData = resolveImage(vars.logo);
300
+ if (logoData) {
301
+ const logoBoxW = 2.5;
302
+ const logoBoxH = 0.7;
303
+ const logoSize = getImageSize(vars.logo);
304
+ if (logoSize) {
305
+ const fit = containImage(logoSize.w, logoSize.h, logoBoxW, logoBoxH);
306
+ slide.addImage({ data: logoData, x: (W - logoBoxW) / 2 + fit.offX, y: 0.5 + fit.offY, w: fit.w, h: fit.h });
307
+ } else {
308
+ slide.addImage({ data: logoData, x: (W - logoBoxW) / 2, y: 0.5, w: logoBoxW, h: logoBoxH });
309
+ }
310
+ }
311
+
312
+ slide.addText(vars.heading, {
313
+ x: padX, y: 1.35, w: W - 2 * padX, h: 0.45,
314
+ fontSize: 16, fontFace: FONT_H, bold: true, color: C.fg, align: "center", margin: 0,
315
+ });
316
+
317
+ const pointsStartY = 2.0;
318
+ const pointW = 6;
319
+ const pointX = (W - pointW) / 2;
320
+ const pointH = 0.55;
321
+
322
+ vars.points.forEach((p, i) => {
323
+ const py = pointsStartY + i * pointH;
324
+ slide.addShape(pres.shapes.LINE, {
325
+ x: pointX, y: py + pointH, w: pointW, h: 0,
326
+ line: { color: C.border, width: 0.5 },
327
+ });
328
+ slide.addText(p.label.toUpperCase(), {
329
+ x: pointX, y: py + 0.06, w: 0.8, h: pointH - 0.12,
330
+ fontSize: 9, fontFace: FONT_B, bold: true, color: C.primary, charSpacing: 1, margin: 0, valign: "top",
331
+ });
332
+ slide.addText(p.text, {
333
+ x: pointX + 0.9, y: py + 0.06, w: pointW - 0.9, h: pointH - 0.12,
334
+ fontSize: 11, fontFace: FONT_B, color: C.fg, margin: 0, valign: "top", lineSpacingMultiple: 1.3,
335
+ });
336
+ });
337
+ }
338
+
339
+ function renderClosingTriad(slide, vars) {
340
+ const padX = px(64);
341
+ const leftW = (W - 2 * padX) * 0.5;
342
+
343
+ slide.addText(vars.heading, {
344
+ x: padX, y: 1.2, w: leftW, h: 0.8,
345
+ fontSize: 20, fontFace: FONT_H, bold: true, color: C.fg, margin: 0, valign: "top", lineSpacingMultiple: 1.4,
346
+ });
347
+
348
+ slide.addText(vars.description, {
349
+ x: padX, y: 2.1, w: leftW, h: 2.8,
350
+ fontSize: 11, fontFace: FONT_B, bold: true, color: C.fg, margin: 0, valign: "top", lineSpacingMultiple: 1.9,
351
+ });
352
+
353
+ const rightX = padX + leftW + px(48);
354
+ const rightW = W - rightX - padX;
355
+ const centerX = rightX + rightW / 2;
356
+ const circR = 0.6;
357
+ const topCX = centerX;
358
+ const topCY = 1.5;
359
+ const botLCX = centerX - 1.2;
360
+ const botRCX = centerX + 1.2;
361
+ const botCY = 3.8;
362
+
363
+ [[topCX, topCY, botLCX, botCY], [topCX, topCY, botRCX, botCY], [botLCX, botCY, botRCX, botCY]].forEach(([x1, y1, x2, y2]) => {
364
+ slide.addShape(pres.shapes.LINE, {
365
+ x: x1, y: y1, w: x2 - x1, h: y2 - y1,
366
+ line: { color: C.border, width: 1.5 },
367
+ });
368
+ });
369
+
370
+ const drawEntity = (cx, cy, entity) => {
371
+ slide.addShape(pres.shapes.OVAL, {
372
+ x: cx - circR, y: cy - circR, w: circR * 2, h: circR * 2,
373
+ fill: { color: C.bg }, line: { color: C.fg, width: 1.5 },
374
+ });
375
+ slide.addText(entity.name, {
376
+ x: cx - circR, y: cy - 0.15, w: circR * 2, h: 0.25,
377
+ fontSize: 13, fontFace: FONT_H, bold: true, color: C.fg, align: "center", valign: "middle", margin: 0,
378
+ });
379
+ if (entity.role) {
380
+ slide.addText(entity.role, {
381
+ x: cx - circR, y: cy + 0.12, w: circR * 2, h: 0.2,
382
+ fontSize: 8, fontFace: FONT_B, color: C.muted, align: "center", valign: "middle", margin: 0,
383
+ });
384
+ }
385
+ };
386
+
387
+ drawEntity(topCX, topCY, vars.top);
388
+ drawEntity(botLCX, botCY, vars.bottomLeft);
389
+ drawEntity(botRCX, botCY, vars.bottomRight);
390
+ }
391
+
392
+ // ─── Pattern registry ───────────────────────────────────────────────
393
+ const renderers = {
394
+ "price-before-after": renderPriceBeforeAfter,
395
+ "comparison-table": renderComparisonTable,
396
+ "image-full": renderImageFull,
397
+ "conclusion-summary": renderConclusionSummary,
398
+ "closing-triad": renderClosingTriad,
399
+ };
400
+
401
+ // ─── Main ───────────────────────────────────────────────────────────
402
+ let rendered = 0;
403
+ for (const slideData of deck.slides) {
404
+ const slide = pres.addSlide();
405
+ slide.background = { color: C.bg };
406
+
407
+ const renderer = renderers[slideData.file];
408
+ if (!renderer) {
409
+ console.warn(`⚠ No pptx renderer for pattern "${slideData.file}" — blank slide`);
410
+ continue;
411
+ }
412
+
413
+ renderer(slide, slideData.vars ?? {});
414
+ rendered++;
415
+ }
416
+
417
+ console.log(`✓ Rendered ${rendered}/${deck.slides.length} slide(s)`);
418
+
419
+ mkdirSync(dirname(outputPath), { recursive: true });
420
+ await pres.writeFile({ fileName: outputPath });
421
+ console.log(`✓ Written to ${outputPath}`);