@deckspec/cli 0.1.3 → 0.1.4
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
|
+
"version": "0.1.4",
|
|
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.
|
|
22
|
-
"@deckspec/schema": "0.1.
|
|
23
|
-
"@deckspec/renderer": "0.1.
|
|
21
|
+
"@deckspec/dsl": "0.1.4",
|
|
22
|
+
"@deckspec/schema": "0.1.4",
|
|
23
|
+
"@deckspec/renderer": "0.1.4"
|
|
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
|
|
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}/`);
|
|
@@ -16,14 +16,14 @@ Convert a DeckSpec deck.yaml to a PowerPoint (.pptx) file.
|
|
|
16
16
|
### 1. Run the conversion
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
node
|
|
19
|
+
node .claude/skills/deckspec-to-pptx/deck-to-pptx.mjs decks/<deck-name>/deck.yaml -o output/<deck-name>.pptx
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
### 2. Check for warnings
|
|
23
23
|
|
|
24
24
|
The script warns for unregistered patterns: `⚠ No pptx renderer for pattern "<name>" — blank slide`
|
|
25
25
|
|
|
26
|
-
To fix, add a renderer function in
|
|
26
|
+
To fix, add a renderer function in `.claude/skills/deckspec-to-pptx/deck-to-pptx.mjs`.
|
|
27
27
|
|
|
28
28
|
### 3. Open and verify
|
|
29
29
|
|
|
@@ -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}`);
|