squidclaw 2.8.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,686 @@
1
+ /**
2
+ * 🦑 PPTX PRO — Smart Presentation Engine
3
+ *
4
+ * Takes raw content and auto-generates professional multi-slide decks.
5
+ * AI just says what it wants → engine handles layout, design, charts, animations.
6
+ *
7
+ * Smart features:
8
+ * - Auto-detects content type (stats, table, chart, quote, timeline, comparison)
9
+ * - Markdown → proper slides conversion
10
+ * - Professional layouts with consistent branding
11
+ * - Master slide designs with gradients, shapes, accents
12
+ * - Auto icon assignment
13
+ * - Smart color palettes
14
+ */
15
+
16
+ import PptxGenJS from 'pptxgenjs';
17
+ import { mkdirSync, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { logger } from '../core/logger.js';
20
+
21
+ const OUTPUT_DIR = '/tmp/squidclaw-pptx';
22
+
23
+ // ── Professional Themes ──
24
+
25
+ const THEMES = {
26
+ executive: {
27
+ bg: '0D1117', accent: '58A6FF', accent2: '3FB950', text: 'FFFFFF', subtle: '8B949E',
28
+ gradStart: '0D1117', gradEnd: '161B22', cardBg: '161B22', border: '30363D',
29
+ },
30
+ corporate: {
31
+ bg: 'FFFFFF', accent: '1B4F72', accent2: '2E86C1', text: '2C3E50', subtle: '7F8C8D',
32
+ gradStart: '1B4F72', gradEnd: '2E86C1', cardBg: 'F8F9FA', border: 'DEE2E6',
33
+ },
34
+ saudi: {
35
+ bg: 'FFFFFF', accent: '006C35', accent2: 'C8A951', text: '1A1A2E', subtle: '6B7280',
36
+ gradStart: '006C35', gradEnd: '004D25', cardBg: 'F0FDF4', border: 'BBF7D0',
37
+ },
38
+ modern: {
39
+ bg: '1A1A2E', accent: 'E94560', accent2: '0F3460', text: 'EAEAEA', subtle: '9CA3AF',
40
+ gradStart: '16213E', gradEnd: '1A1A2E', cardBg: '16213E', border: '374151',
41
+ },
42
+ ocean: {
43
+ bg: '0A192F', accent: '64FFDA', accent2: '5EEAD4', text: 'CCD6F6', subtle: '8892B0',
44
+ gradStart: '0A192F', gradEnd: '112240', cardBg: '112240', border: '1E3A5F',
45
+ },
46
+ gradient: {
47
+ bg: '0F0C29', accent: 'F7971E', accent2: 'FFD200', text: 'FFFFFF', subtle: 'B0B0B0',
48
+ gradStart: '302B63', gradEnd: '24243E', cardBg: '1E1E3F', border: '4A4A7A',
49
+ },
50
+ minimal: {
51
+ bg: 'FAFAFA', accent: '111111', accent2: '555555', text: '111111', subtle: '999999',
52
+ gradStart: '111111', gradEnd: '333333', cardBg: 'FFFFFF', border: 'E5E5E5',
53
+ },
54
+ fire: {
55
+ bg: '1A0000', accent: 'FF4500', accent2: 'FF8C00', text: 'FFFFFF', subtle: 'CC9999',
56
+ gradStart: '8B0000', gradEnd: '1A0000', cardBg: '2D0000', border: '661111',
57
+ },
58
+ };
59
+
60
+ // ── Auto Icons ──
61
+ const TOPIC_ICONS = {
62
+ revenue: '💰', money: '💰', gdp: '💰', profit: '💰', income: '💰', cost: '💰', price: '💰', budget: '💰', finance: '💰',
63
+ growth: '📈', increase: '📈', rise: '📈', trend: '📈', up: '📈',
64
+ users: '👥', people: '👥', team: '👥', employees: '👥', population: '👥', workforce: '👥',
65
+ time: '⏱️', speed: '⚡', fast: '⚡', performance: '⚡',
66
+ global: '🌍', world: '🌍', international: '🌍', country: '🌍',
67
+ tech: '💻', software: '💻', digital: '💻', ai: '🤖', data: '📊',
68
+ security: '🔒', safe: '🔒', protect: '🛡️',
69
+ health: '🏥', medical: '🏥', hospital: '🏥',
70
+ education: '🎓', school: '🎓', university: '🎓', training: '🎓',
71
+ oil: '⛽', energy: '⚡', power: '⚡',
72
+ tourism: '✈️', travel: '✈️', visitors: '✈️',
73
+ construction: '🏗️', building: '🏗️', infrastructure: '🏗️',
74
+ investment: '📊', fdi: '📊', invest: '📊',
75
+ target: '🎯', goal: '🎯', objective: '🎯',
76
+ success: '✅', complete: '✅', done: '✅', achievement: '🏆',
77
+ };
78
+
79
+ function autoIcon(text) {
80
+ const lower = text.toLowerCase();
81
+ for (const [key, icon] of Object.entries(TOPIC_ICONS)) {
82
+ if (lower.includes(key)) return icon;
83
+ }
84
+ return '📌';
85
+ }
86
+
87
+ // ── Smart Content Parser ──
88
+
89
+ function parseMarkdownToSlides(content) {
90
+ const slides = [];
91
+ const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
92
+
93
+ let currentSlide = null;
94
+ let currentSection = null;
95
+
96
+ for (let i = 0; i < lines.length; i++) {
97
+ const line = lines[i];
98
+
99
+ // H1/H2 = new slide
100
+ if (line.startsWith('# ') || line.startsWith('## ')) {
101
+ if (currentSlide) slides.push(currentSlide);
102
+ const title = line.replace(/^#+\s*/, '').replace(/\*\*/g, '');
103
+ currentSlide = { title, type: 'content', body: [], stats: [], table: [], chart: [], bullets: [], timeline: [], comparisons: [], quote: null };
104
+ currentSection = 'body';
105
+ continue;
106
+ }
107
+
108
+ if (!currentSlide) {
109
+ currentSlide = { title: 'Overview', type: 'content', body: [], stats: [], table: [], chart: [], bullets: [], timeline: [], comparisons: [], quote: null };
110
+ }
111
+
112
+ // [stats] marker
113
+ if (line.match(/^\[stats?\]/i)) { currentSection = 'stats'; continue; }
114
+ if (line.match(/^\[table\]/i)) { currentSection = 'table'; continue; }
115
+ if (line.match(/^\[chart\]/i)) { currentSection = 'chart'; continue; }
116
+ if (line.match(/^\[timeline\]/i)) { currentSection = 'timeline'; continue; }
117
+ if (line.match(/^\[compare|comparison\]/i)) { currentSection = 'comparison'; continue; }
118
+ if (line.match(/^\[quote\]/i)) { currentSection = 'quote'; continue; }
119
+
120
+ // Stats: - icon value — label OR - label: value
121
+ if (currentSection === 'stats' && line.startsWith('-')) {
122
+ const m = line.match(/^-\s*(.+?)\s+(.+?)\s*[—–-]\s*(.+)/) || line.match(/^-\s*(.+?):\s*(.+)/);
123
+ if (m) {
124
+ if (m[3]) {
125
+ currentSlide.stats.push({ icon: m[1].trim(), value: m[2].trim(), label: m[3].trim() });
126
+ } else {
127
+ currentSlide.stats.push({ icon: autoIcon(m[1]), value: m[2].trim(), label: m[1].trim() });
128
+ }
129
+ }
130
+ continue;
131
+ }
132
+
133
+ // Table: | col | col |
134
+ if (line.startsWith('|') && line.endsWith('|')) {
135
+ if (line.match(/^\|[\s-:|]+\|$/)) continue; // separator row
136
+ const cells = line.split('|').filter(c => c.trim()).map(c => c.trim());
137
+ currentSlide.table.push(cells);
138
+ currentSection = 'table';
139
+ continue;
140
+ }
141
+
142
+ // Chart: - label: value
143
+ if (currentSection === 'chart' && line.startsWith('-')) {
144
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
145
+ if (m) currentSlide.chart.push({ label: m[1].trim(), value: parseInt(m[2]) });
146
+ continue;
147
+ }
148
+
149
+ // Timeline: N. Title — description
150
+ if (currentSection === 'timeline' && line.match(/^\d+\./)) {
151
+ const m = line.match(/^(\d+)\.\s*\*?\*?(.+?)\*?\*?\s*[—–-]\s*(.+)/);
152
+ if (m) currentSlide.timeline.push({ year: m[1], title: m[2].trim(), desc: m[3].trim() });
153
+ continue;
154
+ }
155
+
156
+ // Quote
157
+ if (line.startsWith('>') || currentSection === 'quote') {
158
+ const q = line.replace(/^>\s*/, '').replace(/\*\*/g, '');
159
+ if (q) currentSlide.quote = (currentSlide.quote || '') + q + ' ';
160
+ currentSection = 'quote';
161
+ continue;
162
+ }
163
+
164
+ // Bullets
165
+ if (line.startsWith('-') || line.startsWith('•') || line.startsWith('*')) {
166
+ const bullet = line.replace(/^[-•*]\s*/, '').replace(/\*\*/g, '');
167
+ currentSlide.bullets.push(bullet);
168
+ continue;
169
+ }
170
+
171
+ // H3 = subsection title, add as body
172
+ if (line.startsWith('### ')) {
173
+ currentSlide.body.push({ type: 'heading', text: line.replace(/^###\s*/, '') });
174
+ continue;
175
+ }
176
+
177
+ // Plain text
178
+ currentSlide.body.push({ type: 'text', text: line.replace(/\*\*/g, '') });
179
+ }
180
+
181
+ if (currentSlide) slides.push(currentSlide);
182
+
183
+ // Auto-detect best slide type
184
+ for (const slide of slides) {
185
+ if (slide.stats.length >= 3) slide.type = 'stats';
186
+ else if (slide.table.length >= 2) slide.type = 'table';
187
+ else if (slide.chart.length >= 2) slide.type = 'chart';
188
+ else if (slide.timeline.length >= 2) slide.type = 'timeline';
189
+ else if (slide.quote) slide.type = 'quote';
190
+ else if (slide.bullets.length >= 3 && slide.stats.length >= 2) slide.type = 'stats-bullets';
191
+ else if (slide.bullets.length >= 2) slide.type = 'bullets';
192
+ }
193
+
194
+ // Split oversized slides
195
+ const final = [];
196
+ for (const slide of slides) {
197
+ if (slide.stats.length > 6) {
198
+ // Split stats across slides
199
+ for (let j = 0; j < slide.stats.length; j += 4) {
200
+ final.push({ ...slide, stats: slide.stats.slice(j, j + 4), type: 'stats', title: j === 0 ? slide.title : slide.title + ' (cont.)' });
201
+ }
202
+ } else if (slide.type === 'content' && slide.body.length > 8) {
203
+ // Split long content
204
+ const mid = Math.ceil(slide.body.length / 2);
205
+ final.push({ ...slide, body: slide.body.slice(0, mid) });
206
+ final.push({ ...slide, body: slide.body.slice(mid), title: slide.title + ' (cont.)' });
207
+ } else {
208
+ final.push(slide);
209
+ }
210
+
211
+ // Also break out table/chart as separate slides if they exist alongside stats
212
+ if (slide.table.length >= 2 && slide.type !== 'table') {
213
+ final.push({ title: slide.title + ' — Data', type: 'table', table: slide.table, body: [], stats: [], chart: [], bullets: [], timeline: [], comparisons: [], quote: null });
214
+ }
215
+ if (slide.chart.length >= 2 && slide.type !== 'chart') {
216
+ final.push({ title: slide.title + ' — Chart', type: 'chart', chart: slide.chart, body: [], stats: [], table: [], bullets: [], timeline: [], comparisons: [], quote: null });
217
+ }
218
+ }
219
+
220
+ return final;
221
+ }
222
+
223
+ // ── Slide Renderers ──
224
+
225
+ function addTitleSlide(pptx, title, subtitle, theme) {
226
+ const slide = pptx.addSlide();
227
+ const t = THEMES[theme] || THEMES.executive;
228
+
229
+ slide.background = { fill: t.gradStart };
230
+
231
+ // Accent bar top
232
+ slide.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.06, fill: { color: t.accent } });
233
+
234
+ // Decorative circle
235
+ slide.addShape(pptx.ShapeType.ellipse, {
236
+ x: 7.5, y: 2, w: 3.5, h: 3.5,
237
+ fill: { color: t.accent, transparency: 90 },
238
+ line: { color: t.accent, width: 2, transparency: 50 },
239
+ });
240
+ slide.addShape(pptx.ShapeType.ellipse, {
241
+ x: 8, y: 2.5, w: 2.5, h: 2.5,
242
+ fill: { color: t.accent2, transparency: 92 },
243
+ line: { color: t.accent2, width: 1, transparency: 60 },
244
+ });
245
+
246
+ slide.addText(title, {
247
+ x: 0.8, y: 1.5, w: 7, h: 2,
248
+ fontSize: 40, fontFace: 'Arial', color: t.text, bold: true,
249
+ lineSpacingMultiple: 1.1,
250
+ });
251
+
252
+ if (subtitle) {
253
+ slide.addText(subtitle, {
254
+ x: 0.8, y: 3.5, w: 6, h: 0.6,
255
+ fontSize: 16, fontFace: 'Arial', color: t.subtle,
256
+ });
257
+ }
258
+
259
+ // Bottom accent line
260
+ slide.addShape(pptx.ShapeType.rect, { x: 0.8, y: 3.2, w: 2, h: 0.05, fill: { color: t.accent } });
261
+
262
+ // Footer
263
+ addFooter(slide, pptx, t, null, null);
264
+ }
265
+
266
+ function addStatsSlide(pptx, slideData, theme, slideNum, total) {
267
+ const slide = pptx.addSlide();
268
+ const t = THEMES[theme] || THEMES.executive;
269
+
270
+ slide.background = { fill: t.bg };
271
+ addSlideHeader(slide, pptx, slideData.title, t);
272
+
273
+ const stats = slideData.stats.slice(0, 6);
274
+ const cols = Math.min(stats.length, 3);
275
+ const rows = Math.ceil(stats.length / cols);
276
+ const cardW = 2.6;
277
+ const cardH = 1.6;
278
+ const gap = 0.3;
279
+ const startX = (10 - (cols * cardW + (cols - 1) * gap)) / 2;
280
+ const startY = rows > 1 ? 1.8 : 2.2;
281
+
282
+ stats.forEach((stat, i) => {
283
+ const col = i % cols;
284
+ const row = Math.floor(i / cols);
285
+ const x = startX + col * (cardW + gap);
286
+ const y = startY + row * (cardH + gap);
287
+
288
+ // Card background
289
+ slide.addShape(pptx.ShapeType.roundRect, {
290
+ x, y, w: cardW, h: cardH, rectRadius: 0.15,
291
+ fill: { color: t.cardBg },
292
+ line: { color: t.border, width: 1 },
293
+ shadow: { type: 'outer', blur: 6, offset: 2, color: '000000', opacity: 0.15 },
294
+ });
295
+
296
+ // Icon
297
+ slide.addText(stat.icon || '📊', {
298
+ x, y: y + 0.15, w: cardW, h: 0.45,
299
+ fontSize: 24, align: 'center',
300
+ });
301
+
302
+ // Value
303
+ slide.addText(stat.value, {
304
+ x, y: y + 0.55, w: cardW, h: 0.5,
305
+ fontSize: 26, fontFace: 'Arial', color: t.accent, bold: true, align: 'center',
306
+ });
307
+
308
+ // Label
309
+ slide.addText(stat.label, {
310
+ x, y: y + 1.05, w: cardW, h: 0.35,
311
+ fontSize: 11, fontFace: 'Arial', color: t.subtle, align: 'center',
312
+ });
313
+ });
314
+
315
+ // Add bullets below if they exist
316
+ if (slideData.bullets.length > 0) {
317
+ const bulletY = startY + rows * (cardH + gap) + 0.2;
318
+ const bulletText = slideData.bullets.map(b => ({ text: ' • ' + b + '\n', options: { fontSize: 12, color: t.text } }));
319
+ slide.addText(bulletText, {
320
+ x: 0.8, y: bulletY, w: 8.4, h: 5 - bulletY,
321
+ fontFace: 'Arial', valign: 'top', lineSpacingMultiple: 1.4,
322
+ });
323
+ }
324
+
325
+ addFooter(slide, pptx, t, slideNum, total);
326
+ }
327
+
328
+ function addTableSlide(pptx, slideData, theme, slideNum, total) {
329
+ const slide = pptx.addSlide();
330
+ const t = THEMES[theme] || THEMES.executive;
331
+
332
+ slide.background = { fill: t.bg };
333
+ addSlideHeader(slide, pptx, slideData.title, t);
334
+
335
+ if (slideData.table.length < 2) return;
336
+
337
+ const headers = slideData.table[0];
338
+ const rows = slideData.table.slice(1);
339
+ const tableData = [headers, ...rows];
340
+
341
+ const colW = Math.min(8.4 / headers.length, 3);
342
+
343
+ slide.addTable(tableData.map((row, ri) =>
344
+ row.map(cell => ({
345
+ text: cell,
346
+ options: {
347
+ fontSize: ri === 0 ? 12 : 11,
348
+ fontFace: 'Arial',
349
+ color: ri === 0 ? 'FFFFFF' : t.text,
350
+ bold: ri === 0,
351
+ fill: { color: ri === 0 ? t.accent : (ri % 2 === 0 ? t.cardBg : t.bg) },
352
+ border: { type: 'solid', color: t.border, pt: 0.5 },
353
+ valign: 'middle',
354
+ align: 'center',
355
+ margin: [4, 6, 4, 6],
356
+ }
357
+ }))
358
+ ), {
359
+ x: 0.8, y: 1.8, w: 8.4,
360
+ autoPage: true,
361
+ autoPageRepeatHeader: true,
362
+ });
363
+
364
+ addFooter(slide, pptx, t, slideNum, total);
365
+ }
366
+
367
+ function addChartSlide(pptx, slideData, theme, slideNum, total) {
368
+ const slide = pptx.addSlide();
369
+ const t = THEMES[theme] || THEMES.executive;
370
+
371
+ slide.background = { fill: t.bg };
372
+ addSlideHeader(slide, pptx, slideData.title, t);
373
+
374
+ const items = slideData.chart;
375
+ if (items.length === 0) return;
376
+
377
+ const chartColors = [t.accent, t.accent2, 'D29922', 'F85149', 'BC8CFF', 'F778BA', 'A5D6FF', '7EE787'];
378
+
379
+ // Determine chart type
380
+ const useDonut = items.length <= 6;
381
+
382
+ if (useDonut) {
383
+ slide.addChart(pptx.charts.DOUGHNUT, [{
384
+ name: slideData.title,
385
+ labels: items.map(i => i.label),
386
+ values: items.map(i => i.value),
387
+ }], {
388
+ x: 0.8, y: 1.6, w: 5, h: 3.5,
389
+ showLegend: true, legendPos: 'r', legendFontSize: 11, legendColor: t.text,
390
+ chartColors: chartColors.slice(0, items.length),
391
+ dataLabelColor: 'FFFFFF', showPercent: true, dataLabelFontSize: 10,
392
+ holeSize: 55,
393
+ });
394
+ } else {
395
+ slide.addChart(pptx.charts.BAR, [{
396
+ name: slideData.title,
397
+ labels: items.map(i => i.label),
398
+ values: items.map(i => i.value),
399
+ }], {
400
+ x: 0.8, y: 1.6, w: 8.4, h: 3.5,
401
+ showLegend: false,
402
+ chartColors: [t.accent],
403
+ catAxisLabelColor: t.text, valAxisLabelColor: t.subtle,
404
+ catAxisLabelFontSize: 10, valAxisLabelFontSize: 9,
405
+ gridLineColor: t.border,
406
+ dataLabelColor: t.text, showValue: true, dataLabelFontSize: 9,
407
+ barDir: 'bar',
408
+ });
409
+ }
410
+
411
+ addFooter(slide, pptx, t, slideNum, total);
412
+ }
413
+
414
+ function addBulletsSlide(pptx, slideData, theme, slideNum, total) {
415
+ const slide = pptx.addSlide();
416
+ const t = THEMES[theme] || THEMES.executive;
417
+
418
+ slide.background = { fill: t.bg };
419
+ addSlideHeader(slide, pptx, slideData.title, t);
420
+
421
+ const bullets = slideData.bullets;
422
+
423
+ // Two-column layout if > 6 bullets
424
+ if (bullets.length > 6) {
425
+ const mid = Math.ceil(bullets.length / 2);
426
+ const left = bullets.slice(0, mid);
427
+ const right = bullets.slice(mid);
428
+
429
+ [left, right].forEach((col, ci) => {
430
+ const x = ci === 0 ? 0.8 : 5.2;
431
+ col.forEach((b, bi) => {
432
+ const y = 1.8 + bi * 0.55;
433
+ slide.addShape(pptx.ShapeType.roundRect, {
434
+ x, y, w: 0.3, h: 0.3, rectRadius: 0.05,
435
+ fill: { color: t.accent, transparency: 80 },
436
+ });
437
+ slide.addText('✦', { x, y, w: 0.3, h: 0.3, fontSize: 10, align: 'center', color: t.accent });
438
+ slide.addText(b, { x: x + 0.4, y, w: 3.8, h: 0.5, fontSize: 12, fontFace: 'Arial', color: t.text, valign: 'middle' });
439
+ });
440
+ });
441
+ } else {
442
+ bullets.forEach((b, i) => {
443
+ const y = 1.8 + i * 0.65;
444
+
445
+ // Accent dot
446
+ slide.addShape(pptx.ShapeType.ellipse, {
447
+ x: 1, y: y + 0.12, w: 0.16, h: 0.16,
448
+ fill: { color: t.accent },
449
+ });
450
+
451
+ slide.addText(b, {
452
+ x: 1.4, y, w: 7.5, h: 0.5,
453
+ fontSize: 14, fontFace: 'Arial', color: t.text, valign: 'middle',
454
+ });
455
+ });
456
+ }
457
+
458
+ addFooter(slide, pptx, t, slideNum, total);
459
+ }
460
+
461
+ function addQuoteSlide(pptx, slideData, theme, slideNum, total) {
462
+ const slide = pptx.addSlide();
463
+ const t = THEMES[theme] || THEMES.executive;
464
+
465
+ slide.background = { fill: t.gradStart };
466
+
467
+ // Big quote mark
468
+ slide.addText('"', {
469
+ x: 1, y: 1, w: 1.5, h: 1.5,
470
+ fontSize: 120, fontFace: 'Georgia', color: t.accent, transparency: 50,
471
+ });
472
+
473
+ slide.addText(slideData.quote.trim(), {
474
+ x: 1.5, y: 2, w: 7, h: 2.5,
475
+ fontSize: 22, fontFace: 'Georgia', color: t.text, italic: true,
476
+ lineSpacingMultiple: 1.5, valign: 'middle',
477
+ });
478
+
479
+ // Attribution line
480
+ slide.addShape(pptx.ShapeType.rect, { x: 1.5, y: 4.5, w: 1.5, h: 0.04, fill: { color: t.accent } });
481
+
482
+ addFooter(slide, pptx, t, slideNum, total);
483
+ }
484
+
485
+ function addContentSlide(pptx, slideData, theme, slideNum, total) {
486
+ const slide = pptx.addSlide();
487
+ const t = THEMES[theme] || THEMES.executive;
488
+
489
+ slide.background = { fill: t.bg };
490
+ addSlideHeader(slide, pptx, slideData.title, t);
491
+
492
+ let y = 1.8;
493
+ for (const item of slideData.body) {
494
+ if (item.type === 'heading') {
495
+ slide.addText(item.text, {
496
+ x: 0.8, y, w: 8.4, h: 0.5,
497
+ fontSize: 18, fontFace: 'Arial', color: t.accent, bold: true,
498
+ });
499
+ y += 0.6;
500
+ } else {
501
+ slide.addText(item.text, {
502
+ x: 0.8, y, w: 8.4, h: 0.45,
503
+ fontSize: 12, fontFace: 'Arial', color: t.text, lineSpacingMultiple: 1.4,
504
+ });
505
+ y += 0.5;
506
+ }
507
+ if (y > 4.8) break;
508
+ }
509
+
510
+ addFooter(slide, pptx, t, slideNum, total);
511
+ }
512
+
513
+ function addTimelineSlide(pptx, slideData, theme, slideNum, total) {
514
+ const slide = pptx.addSlide();
515
+ const t = THEMES[theme] || THEMES.executive;
516
+
517
+ slide.background = { fill: t.bg };
518
+ addSlideHeader(slide, pptx, slideData.title, t);
519
+
520
+ const items = slideData.timeline.slice(0, 5);
521
+ const stepW = 8 / items.length;
522
+
523
+ // Horizontal line
524
+ slide.addShape(pptx.ShapeType.rect, {
525
+ x: 1, y: 2.8, w: 8, h: 0.04, fill: { color: t.accent },
526
+ });
527
+
528
+ items.forEach((item, i) => {
529
+ const x = 1 + i * stepW + stepW / 2 - 0.5;
530
+
531
+ // Circle node
532
+ slide.addShape(pptx.ShapeType.ellipse, {
533
+ x: x + 0.25, y: 2.6, w: 0.5, h: 0.5,
534
+ fill: { color: t.accent },
535
+ shadow: { type: 'outer', blur: 4, offset: 1, color: t.accent, opacity: 0.3 },
536
+ });
537
+ slide.addText(item.year, {
538
+ x: x + 0.25, y: 2.6, w: 0.5, h: 0.5,
539
+ fontSize: 10, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle',
540
+ });
541
+
542
+ // Title + desc below
543
+ slide.addText(item.title, {
544
+ x: x - 0.2, y: 3.3, w: 1.4, h: 0.4,
545
+ fontSize: 11, fontFace: 'Arial', color: t.accent, bold: true, align: 'center',
546
+ });
547
+ slide.addText(item.desc, {
548
+ x: x - 0.3, y: 3.7, w: 1.6, h: 0.8,
549
+ fontSize: 9, fontFace: 'Arial', color: t.subtle, align: 'center', lineSpacingMultiple: 1.2,
550
+ });
551
+ });
552
+
553
+ addFooter(slide, pptx, t, slideNum, total);
554
+ }
555
+
556
+ function addThankYouSlide(pptx, theme, brandName) {
557
+ const slide = pptx.addSlide();
558
+ const t = THEMES[theme] || THEMES.executive;
559
+
560
+ slide.background = { fill: t.gradStart };
561
+
562
+ // Decorative circles
563
+ slide.addShape(pptx.ShapeType.ellipse, {
564
+ x: 6.5, y: 0.5, w: 4, h: 4,
565
+ fill: { color: t.accent, transparency: 92 },
566
+ line: { color: t.accent, width: 2, transparency: 60 },
567
+ });
568
+
569
+ slide.addText('Thank You', {
570
+ x: 0, y: 1.8, w: '100%', h: 1.2,
571
+ fontSize: 48, fontFace: 'Arial', color: t.text, bold: true, align: 'center',
572
+ });
573
+
574
+ slide.addShape(pptx.ShapeType.rect, { x: 4.2, y: 3.1, w: 1.6, h: 0.05, fill: { color: t.accent } });
575
+
576
+ slide.addText('Created with ' + (brandName || 'Squidclaw AI 🦑'), {
577
+ x: 0, y: 3.5, w: '100%', h: 0.5,
578
+ fontSize: 14, fontFace: 'Arial', color: t.subtle, align: 'center',
579
+ });
580
+ }
581
+
582
+ // ── Helpers ──
583
+
584
+ function addSlideHeader(slide, pptx, title, t) {
585
+ // Top accent bar
586
+ slide.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.05, fill: { color: t.accent } });
587
+
588
+ // Title
589
+ slide.addText(title || '', {
590
+ x: 0.8, y: 0.3, w: 8.4, h: 0.8,
591
+ fontSize: 24, fontFace: 'Arial', color: t.text, bold: true,
592
+ });
593
+
594
+ // Title underline
595
+ slide.addShape(pptx.ShapeType.rect, { x: 0.8, y: 1.15, w: 1.5, h: 0.04, fill: { color: t.accent } });
596
+ }
597
+
598
+ function addFooter(slide, pptx, t, num, total) {
599
+ const footerY = 5.15;
600
+
601
+ // Footer line
602
+ slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: footerY, w: 9, h: 0.01, fill: { color: t.border } });
603
+
604
+ // Brand
605
+ slide.addText('Squidclaw AI 🦑', {
606
+ x: 0.5, y: footerY + 0.05, w: 3, h: 0.35,
607
+ fontSize: 9, fontFace: 'Arial', color: t.subtle,
608
+ });
609
+
610
+ // Page number
611
+ if (num && total) {
612
+ slide.addText(num + ' / ' + total, {
613
+ x: 7.5, y: footerY + 0.05, w: 2, h: 0.35,
614
+ fontSize: 9, fontFace: 'Arial', color: t.subtle, align: 'right',
615
+ });
616
+ }
617
+ }
618
+
619
+ // ── Main Export ──
620
+
621
+ export async function generatePresentation(input) {
622
+ mkdirSync(OUTPUT_DIR, { recursive: true });
623
+
624
+ const {
625
+ title = 'Presentation',
626
+ subtitle = '',
627
+ content = '',
628
+ slides: rawSlides = null,
629
+ theme = 'executive',
630
+ brand = 'Squidclaw AI 🦑',
631
+ } = typeof input === 'string' ? { content: input, title: 'Presentation' } : input;
632
+
633
+ const pptx = new PptxGenJS();
634
+ pptx.layout = 'LAYOUT_WIDE'; // 16:9
635
+ pptx.author = brand;
636
+ pptx.title = title;
637
+
638
+ // Parse content into slides
639
+ const parsedSlides = rawSlides || parseMarkdownToSlides(content);
640
+ const totalSlides = parsedSlides.length + 2; // +title +thankyou
641
+
642
+ // Title slide
643
+ addTitleSlide(pptx, title, subtitle || new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), theme);
644
+
645
+ // Content slides
646
+ parsedSlides.forEach((slideData, i) => {
647
+ const num = i + 2;
648
+ switch (slideData.type) {
649
+ case 'stats':
650
+ case 'stats-bullets':
651
+ addStatsSlide(pptx, slideData, theme, num, totalSlides); break;
652
+ case 'table':
653
+ addTableSlide(pptx, slideData, theme, num, totalSlides); break;
654
+ case 'chart':
655
+ addChartSlide(pptx, slideData, theme, num, totalSlides); break;
656
+ case 'bullets':
657
+ addBulletsSlide(pptx, slideData, theme, num, totalSlides); break;
658
+ case 'quote':
659
+ addQuoteSlide(pptx, slideData, theme, num, totalSlides); break;
660
+ case 'timeline':
661
+ addTimelineSlide(pptx, slideData, theme, num, totalSlides); break;
662
+ default:
663
+ addContentSlide(pptx, slideData, theme, num, totalSlides); break;
664
+ }
665
+ });
666
+
667
+ // Thank you slide
668
+ addThankYouSlide(pptx, theme, brand);
669
+
670
+ const filename = title.replace(/[^a-zA-Z0-9 ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pptx';
671
+ const filepath = join(OUTPUT_DIR, filename);
672
+
673
+ await pptx.writeFile({ fileName: filepath });
674
+
675
+ logger.info('pptx-pro', `Generated: ${filepath} (${parsedSlides.length + 2} slides, theme: ${theme})`);
676
+
677
+ return {
678
+ filepath,
679
+ filename,
680
+ slides: parsedSlides.length + 2,
681
+ theme,
682
+ types: [...new Set(parsedSlides.map(s => s.type))],
683
+ };
684
+ }
685
+
686
+ export { parseMarkdownToSlides, THEMES };