squidclaw 2.2.0 → 2.4.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.
package/lib/tools/pptx.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 🦑 PowerPoint Generator
3
- * Create .pptx presentations from AI-generated content
2
+ * 🦑 PowerPoint Generator PRO
3
+ * Full-featured .pptx with charts, images, tables, layouts
4
4
  */
5
5
 
6
6
  import { logger } from '../core/logger.js';
@@ -13,111 +13,541 @@ export class PptxGenerator {
13
13
  mkdirSync(this.outputDir, { recursive: true });
14
14
  }
15
15
 
16
+ // ── THEMES ──
17
+ static THEMES = {
18
+ dark: { bg: '0d1117', bg2: '161b22', title: 'e6edf3', text: 'b1bac4', accent: '58a6ff', accent2: '3fb950', accent3: 'bc8cff', border: '30363d' },
19
+ light: { bg: 'ffffff', bg2: 'f6f8fa', title: '1f2328', text: '656d76', accent: '0969da', accent2: '1a7f37', accent3: '8250df', border: 'd0d7de' },
20
+ blue: { bg: '0a1628', bg2: '0f2440', title: 'ffffff', text: 'a3c4e8', accent: '4da6ff', accent2: '00d4aa', accent3: 'ff6b9d', border: '1e3a5f' },
21
+ green: { bg: '0d1f0d', bg2: '132a13', title: 'ffffff', text: 'a7d7a7', accent: '4ade80', accent2: 'facc15', accent3: 'fb923c', border: '1a3d1a' },
22
+ corporate: { bg: 'ffffff', bg2: 'f1f5f9', title: '0f172a', text: '475569', accent: '2563eb', accent2: '059669', accent3: 'dc2626', border: 'e2e8f0' },
23
+ red: { bg: '1a0000', bg2: '2d0a0a', title: 'ffffff', text: 'fca5a5', accent: 'ef4444', accent2: 'f59e0b', accent3: '8b5cf6', border: '450a0a' },
24
+ gradient: { bg: '0c0c1d', bg2: '1a1a3e', title: 'ffffff', text: 'c4b5fd', accent: '818cf8', accent2: 'f472b6', accent3: '34d399', border: '312e81' },
25
+ minimal: { bg: 'fafafa', bg2: 'f0f0f0', title: '111111', text: '555555', accent: '111111', accent2: '888888', accent3: 'bbbbbb', border: 'dddddd' },
26
+ saudi: { bg: '003c1f', bg2: '004d28', title: 'ffffff', text: 'c8e6c9', accent: '4caf50', accent2: 'ffd54f', accent3: 'ffffff', border: '1b5e20' },
27
+ ocean: { bg: '0a192f', bg2: '112240', title: 'ccd6f6', text: '8892b0', accent: '64ffda', accent2: 'ffd700', accent3: 'ff6b6b', border: '233554' },
28
+ };
29
+
16
30
  /**
17
- * Create a presentation from structured content
18
- * @param {string} title - Presentation title
19
- * @param {Array} slides - [{title, content, bullets?, notes?}]
20
- * @param {object} options - {theme, author}
31
+ * Create a full presentation
21
32
  */
22
33
  async create(title, slides, options = {}) {
23
34
  const pptxgen = (await import('pptxgenjs')).default;
24
35
  const pres = new pptxgen();
25
36
 
26
- // Metadata
27
- pres.author = options.author || 'Squidclaw AI';
37
+ pres.author = options.author || 'Squidclaw AI 🦑';
28
38
  pres.title = title;
29
- pres.subject = title;
30
-
31
- // Theme colors
32
- const themes = {
33
- dark: { bg: '1a1a2e', title: 'e94560', text: 'eaeaea', accent: '0f3460' },
34
- light: { bg: 'ffffff', title: '2d3436', text: '636e72', accent: '0984e3' },
35
- blue: { bg: '0f3460', title: 'e94560', text: 'eaeaea', accent: '16213e' },
36
- green: { bg: '1b4332', title: 'b7e4c7', text: 'd8f3dc', accent: '2d6a4f' },
37
- corporate: { bg: 'ffffff', title: '2c3e50', text: '34495e', accent: '3498db' },
38
- red: { bg: '2d0000', title: 'ff6b6b', text: 'ffe0e0', accent: '4a0000' },
39
- };
40
- const theme = themes[options.theme] || themes.corporate;
39
+ pres.subject = options.subtitle || title;
40
+ pres.layout = 'LAYOUT_WIDE'; // 16:9
41
+
42
+ const theme = PptxGenerator.THEMES[options.theme] || PptxGenerator.THEMES.corporate;
43
+
44
+ // ── TITLE SLIDE ──
45
+ this._addTitleSlide(pres, title, options, theme);
46
+
47
+ // ── CONTENT SLIDES ──
48
+ for (let i = 0; i < slides.length; i++) {
49
+ const slide = slides[i];
50
+ const slideNum = i + 2;
51
+ const totalSlides = slides.length + 1;
52
+
53
+ switch (slide.type) {
54
+ case 'chart':
55
+ this._addChartSlide(pres, slide, theme, slideNum, totalSlides);
56
+ break;
57
+ case 'table':
58
+ this._addTableSlide(pres, slide, theme, slideNum, totalSlides);
59
+ break;
60
+ case 'comparison':
61
+ this._addComparisonSlide(pres, slide, theme, slideNum, totalSlides);
62
+ break;
63
+ case 'timeline':
64
+ this._addTimelineSlide(pres, slide, theme, slideNum, totalSlides);
65
+ break;
66
+ case 'stats':
67
+ this._addStatsSlide(pres, slide, theme, slideNum, totalSlides);
68
+ break;
69
+ case 'quote':
70
+ this._addQuoteSlide(pres, slide, theme, slideNum, totalSlides);
71
+ break;
72
+ case 'image':
73
+ this._addImageSlide(pres, slide, theme, slideNum, totalSlides);
74
+ break;
75
+ case 'section':
76
+ this._addSectionSlide(pres, slide, theme, slideNum, totalSlides);
77
+ break;
78
+ default:
79
+ this._addContentSlide(pres, slide, theme, slideNum, totalSlides);
80
+ }
81
+ }
82
+
83
+ // ── THANK YOU SLIDE ──
84
+ if (options.thankYou !== false) {
85
+ this._addThankYouSlide(pres, options, theme);
86
+ }
87
+
88
+ // Save
89
+ const filename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pptx';
90
+ const filepath = join(this.outputDir, filename);
91
+ const data = await pres.write({ outputType: 'nodebuffer' });
92
+ writeFileSync(filepath, data);
93
+
94
+ logger.info('pptx', `Created PRO: ${filepath} (${slides.length + 2} slides)`);
95
+ return { filepath, filename, slideCount: slides.length + 2 };
96
+ }
97
+
98
+ // ── SLIDE BUILDERS ──
99
+
100
+ _addBase(pres, theme, slideNum, totalSlides) {
101
+ const s = pres.addSlide();
102
+ s.background = { color: theme.bg };
103
+
104
+ // Top accent bar
105
+ s.addShape(pres.ShapeType.rect, {
106
+ x: 0, y: 0, w: '100%', h: 0.06, fill: { color: theme.accent },
107
+ });
108
+
109
+ // Footer
110
+ if (slideNum && totalSlides) {
111
+ s.addText(slideNum + ' / ' + totalSlides, {
112
+ x: 11.5, y: 7.1, w: 1.5, h: 0.3,
113
+ fontSize: 9, color: theme.text, align: 'right', italic: true,
114
+ });
115
+ s.addText('Squidclaw AI 🦑', {
116
+ x: 0.3, y: 7.1, w: 2, h: 0.3,
117
+ fontSize: 9, color: theme.border,
118
+ });
119
+ }
120
+ return s;
121
+ }
41
122
 
42
- // Title slide
43
- const titleSlide = pres.addSlide();
44
- titleSlide.background = { color: theme.bg };
45
- titleSlide.addText(title, {
46
- x: 0.5, y: 1.5, w: 9, h: 1.5,
47
- fontSize: 36, bold: true, color: theme.title,
48
- align: 'center',
123
+ _addTitleSlide(pres, title, options, theme) {
124
+ const s = pres.addSlide();
125
+ s.background = { color: theme.bg };
126
+
127
+ // Large accent shape
128
+ s.addShape(pres.ShapeType.rect, {
129
+ x: 0, y: 0, w: '100%', h: 0.08, fill: { color: theme.accent },
130
+ });
131
+ s.addShape(pres.ShapeType.rect, {
132
+ x: 0.5, y: 5.5, w: 3, h: 0.05, fill: { color: theme.accent },
49
133
  });
134
+
135
+ // Title
136
+ s.addText(title, {
137
+ x: 0.5, y: 1.8, w: 12, h: 1.5,
138
+ fontSize: 42, bold: true, color: theme.title,
139
+ fontFace: 'Arial',
140
+ });
141
+
142
+ // Subtitle
50
143
  if (options.subtitle) {
51
- titleSlide.addText(options.subtitle, {
52
- x: 0.5, y: 3.2, w: 9, h: 0.8,
53
- fontSize: 18, color: theme.text, align: 'center',
144
+ s.addText(options.subtitle, {
145
+ x: 0.5, y: 3.4, w: 10, h: 0.8,
146
+ fontSize: 20, color: theme.text, fontFace: 'Arial',
54
147
  });
55
148
  }
56
- titleSlide.addText(new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), {
57
- x: 0.5, y: 4.5, w: 9, h: 0.5,
58
- fontSize: 12, color: theme.text, align: 'center', italic: true,
149
+
150
+ // Date + author
151
+ const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
152
+ s.addText((options.author || '') + (options.author ? ' • ' : '') + date, {
153
+ x: 0.5, y: 5.8, w: 8, h: 0.5,
154
+ fontSize: 14, color: theme.accent, fontFace: 'Arial',
155
+ });
156
+ }
157
+
158
+ _addContentSlide(pres, slide, theme, slideNum, total) {
159
+ const s = this._addBase(pres, theme, slideNum, total);
160
+
161
+ // Title
162
+ s.addText(slide.title || '', {
163
+ x: 0.5, y: 0.3, w: 12, h: 0.8,
164
+ fontSize: 28, bold: true, color: theme.title, fontFace: 'Arial',
59
165
  });
60
166
 
61
- // Content slides
62
- for (const slide of slides) {
63
- const s = pres.addSlide();
64
- s.background = { color: theme.bg };
167
+ // Accent line under title
168
+ s.addShape(pres.ShapeType.rect, {
169
+ x: 0.5, y: 1.15, w: 2.5, h: 0.04, fill: { color: theme.accent },
170
+ });
65
171
 
66
- // Slide title
67
- s.addText(slide.title || '', {
68
- x: 0.5, y: 0.3, w: 9, h: 0.8,
69
- fontSize: 24, bold: true, color: theme.title,
172
+ if (slide.bullets && slide.bullets.length > 0) {
173
+ const rows = slide.bullets.map(b => ({
174
+ text: b,
175
+ options: {
176
+ fontSize: 18, color: theme.text, fontFace: 'Arial',
177
+ bullet: { type: 'bullet', style: '●', color: theme.accent },
178
+ paraSpaceAfter: 10, paraSpaceBefore: 4,
179
+ indentLevel: 0,
180
+ },
181
+ }));
182
+
183
+ s.addText(rows, { x: 0.7, y: 1.5, w: 11.5, h: 5 });
184
+ }
185
+
186
+ if (slide.content) {
187
+ s.addText(slide.content, {
188
+ x: 0.5, y: 1.5, w: 12, h: 5,
189
+ fontSize: 18, color: theme.text, fontFace: 'Arial',
190
+ valign: 'top', wrap: true, lineSpacing: 28,
70
191
  });
192
+ }
193
+ }
71
194
 
72
- // Accent line
73
- s.addShape(pres.ShapeType.rect, {
74
- x: 0.5, y: 1.1, w: 2, h: 0.04, fill: { color: theme.title },
195
+ _addChartSlide(pres, slide, theme, slideNum, total) {
196
+ const s = this._addBase(pres, theme, slideNum, total);
197
+
198
+ s.addText(slide.title || 'Chart', {
199
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
200
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
201
+ });
202
+ s.addShape(pres.ShapeType.rect, {
203
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
204
+ });
205
+
206
+ const chartType = slide.chartType || 'bar';
207
+ const chartTypes = {
208
+ bar: pres.ChartType.bar,
209
+ line: pres.ChartType.line,
210
+ pie: pres.ChartType.pie,
211
+ doughnut: pres.ChartType.doughnut,
212
+ area: pres.ChartType.area,
213
+ bar3d: pres.ChartType.bar3D,
214
+ };
215
+
216
+ const type = chartTypes[chartType] || pres.ChartType.bar;
217
+ const colors = [theme.accent, theme.accent2, theme.accent3, theme.text, theme.border];
218
+
219
+ const chartData = slide.chartData || [
220
+ { name: 'Series 1', labels: ['A', 'B', 'C', 'D'], values: [10, 20, 30, 40] }
221
+ ];
222
+
223
+ s.addChart(type, chartData, {
224
+ x: 0.8, y: 1.4, w: 11.5, h: 5.2,
225
+ showTitle: false,
226
+ showValue: slide.showValues !== false,
227
+ catAxisLabelColor: theme.text,
228
+ valAxisLabelColor: theme.text,
229
+ chartColors: colors,
230
+ legendColor: theme.text,
231
+ showLegend: chartData.length > 1,
232
+ legendPos: 'b',
233
+ dataLabelColor: theme.title,
234
+ dataLabelFontSize: 10,
235
+ valGridLine: { color: theme.border, size: 0.5 },
236
+ });
237
+ }
238
+
239
+ _addTableSlide(pres, slide, theme, slideNum, total) {
240
+ const s = this._addBase(pres, theme, slideNum, total);
241
+
242
+ s.addText(slide.title || 'Table', {
243
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
244
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
245
+ });
246
+ s.addShape(pres.ShapeType.rect, {
247
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
248
+ });
249
+
250
+ const rows = slide.tableData || [['Header 1', 'Header 2'], ['Data 1', 'Data 2']];
251
+ const tableRows = rows.map((row, ri) =>
252
+ row.map(cell => ({
253
+ text: String(cell),
254
+ options: {
255
+ fontSize: ri === 0 ? 14 : 13,
256
+ bold: ri === 0,
257
+ color: ri === 0 ? 'ffffff' : theme.text,
258
+ fill: { color: ri === 0 ? theme.accent : (ri % 2 === 0 ? theme.bg2 : theme.bg) },
259
+ border: [{ color: theme.border, pt: 0.5 }],
260
+ align: 'left',
261
+ valign: 'middle',
262
+ fontFace: 'Arial',
263
+ },
264
+ }))
265
+ );
266
+
267
+ const colW = 11.5 / (rows[0]?.length || 2);
268
+ s.addTable(tableRows, {
269
+ x: 0.5, y: 1.4, w: 12, h: 0.5,
270
+ colW: Array(rows[0]?.length || 2).fill(colW),
271
+ rowH: rows.map((_, i) => i === 0 ? 0.5 : 0.45),
272
+ margin: [5, 8, 5, 8],
273
+ });
274
+ }
275
+
276
+ _addComparisonSlide(pres, slide, theme, slideNum, total) {
277
+ const s = this._addBase(pres, theme, slideNum, total);
278
+
279
+ s.addText(slide.title || 'Comparison', {
280
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
281
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
282
+ });
283
+ s.addShape(pres.ShapeType.rect, {
284
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
285
+ });
286
+
287
+ const left = slide.left || { title: 'Option A', points: ['Point 1'] };
288
+ const right = slide.right || { title: 'Option B', points: ['Point 1'] };
289
+
290
+ // Left column
291
+ s.addShape(pres.ShapeType.roundRect, {
292
+ x: 0.5, y: 1.4, w: 5.8, h: 5.2,
293
+ fill: { color: theme.bg2 }, rectRadius: 0.15,
294
+ line: { color: theme.accent, width: 1.5 },
295
+ });
296
+ s.addText(left.title, {
297
+ x: 0.8, y: 1.6, w: 5.2, h: 0.6,
298
+ fontSize: 20, bold: true, color: theme.accent, fontFace: 'Arial',
299
+ });
300
+ if (left.points) {
301
+ const lpts = left.points.map(p => ({
302
+ text: p,
303
+ options: { fontSize: 15, color: theme.text, bullet: { code: '2022', color: theme.accent }, paraSpaceAfter: 8 },
304
+ }));
305
+ s.addText(lpts, { x: 1, y: 2.3, w: 4.8, h: 4 });
306
+ }
307
+
308
+ // VS
309
+ s.addText('VS', {
310
+ x: 5.8, y: 3.5, w: 1.4, h: 0.6,
311
+ fontSize: 18, bold: true, color: theme.accent, align: 'center',
312
+ });
313
+
314
+ // Right column
315
+ s.addShape(pres.ShapeType.roundRect, {
316
+ x: 6.7, y: 1.4, w: 5.8, h: 5.2,
317
+ fill: { color: theme.bg2 }, rectRadius: 0.15,
318
+ line: { color: theme.accent2, width: 1.5 },
319
+ });
320
+ s.addText(right.title, {
321
+ x: 7, y: 1.6, w: 5.2, h: 0.6,
322
+ fontSize: 20, bold: true, color: theme.accent2, fontFace: 'Arial',
323
+ });
324
+ if (right.points) {
325
+ const rpts = right.points.map(p => ({
326
+ text: p,
327
+ options: { fontSize: 15, color: theme.text, bullet: { code: '2022', color: theme.accent2 }, paraSpaceAfter: 8 },
328
+ }));
329
+ s.addText(rpts, { x: 7.2, y: 2.3, w: 4.8, h: 4 });
330
+ }
331
+ }
332
+
333
+ _addTimelineSlide(pres, slide, theme, slideNum, total) {
334
+ const s = this._addBase(pres, theme, slideNum, total);
335
+
336
+ s.addText(slide.title || 'Timeline', {
337
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
338
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
339
+ });
340
+ s.addShape(pres.ShapeType.rect, {
341
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
342
+ });
343
+
344
+ const events = slide.events || [{ date: '2024', text: 'Event 1' }];
345
+ const count = Math.min(events.length, 6);
346
+ const totalW = 11.5;
347
+ const spacing = totalW / count;
348
+
349
+ // Timeline line
350
+ s.addShape(pres.ShapeType.rect, {
351
+ x: 0.5, y: 3.5, w: totalW, h: 0.04, fill: { color: theme.accent },
352
+ });
353
+
354
+ events.slice(0, 6).forEach((ev, i) => {
355
+ const x = 0.5 + (i * spacing) + (spacing / 2) - 0.8;
356
+ const above = i % 2 === 0;
357
+
358
+ // Circle
359
+ s.addShape(pres.ShapeType.ellipse, {
360
+ x: x + 0.55, y: 3.3, w: 0.4, h: 0.4,
361
+ fill: { color: theme.accent },
75
362
  });
76
363
 
77
- if (slide.bullets && slide.bullets.length > 0) {
78
- // Bullet points
79
- const bulletText = slide.bullets.map(b => ({
80
- text: b,
81
- options: { fontSize: 16, color: theme.text, bullet: { code: '2022' }, paraSpaceAfter: 8 },
82
- }));
83
- s.addText(bulletText, {
84
- x: 0.5, y: 1.4, w: 9, h: 3.5,
85
- });
86
- } else if (slide.content) {
87
- // Paragraph content
88
- s.addText(slide.content, {
89
- x: 0.5, y: 1.4, w: 9, h: 3.5,
90
- fontSize: 16, color: theme.text,
91
- valign: 'top', wrap: true,
364
+ // Date
365
+ s.addText(ev.date || '', {
366
+ x: x, y: above ? 2.2 : 3.9, w: 1.6, h: 0.4,
367
+ fontSize: 13, bold: true, color: theme.accent, align: 'center', fontFace: 'Arial',
368
+ });
369
+
370
+ // Text
371
+ s.addText(ev.text || '', {
372
+ x: x - 0.2, y: above ? 2.6 : 4.3, w: 2, h: 0.8,
373
+ fontSize: 11, color: theme.text, align: 'center', fontFace: 'Arial', wrap: true,
374
+ });
375
+ });
376
+ }
377
+
378
+ _addStatsSlide(pres, slide, theme, slideNum, total) {
379
+ const s = this._addBase(pres, theme, slideNum, total);
380
+
381
+ s.addText(slide.title || 'Key Metrics', {
382
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
383
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
384
+ });
385
+ s.addShape(pres.ShapeType.rect, {
386
+ x: 0.5, y: 1.05, w: 2, h: 0.04, fill: { color: theme.accent },
387
+ });
388
+
389
+ const stats = slide.stats || [{ value: '100', label: 'Stat' }];
390
+ const count = Math.min(stats.length, 4);
391
+ const cardW = 2.5;
392
+ const gap = (12 - (count * cardW)) / (count + 1);
393
+ const colors = [theme.accent, theme.accent2, theme.accent3, theme.text];
394
+
395
+ stats.slice(0, 4).forEach((stat, i) => {
396
+ const x = gap + i * (cardW + gap);
397
+
398
+ // Card bg
399
+ s.addShape(pres.ShapeType.roundRect, {
400
+ x, y: 2, w: cardW, h: 3.5,
401
+ fill: { color: theme.bg2 }, rectRadius: 0.15,
402
+ line: { color: colors[i], width: 1.5 },
403
+ });
404
+
405
+ // Icon/emoji
406
+ if (stat.icon) {
407
+ s.addText(stat.icon, {
408
+ x, y: 2.3, w: cardW, h: 0.8,
409
+ fontSize: 32, align: 'center',
92
410
  });
93
411
  }
94
412
 
95
- // Slide notes
96
- if (slide.notes) {
97
- s.addNotes(slide.notes);
413
+ // Big number
414
+ s.addText(stat.value || '0', {
415
+ x, y: stat.icon ? 3 : 2.5, w: cardW, h: 1.2,
416
+ fontSize: 40, bold: true, color: colors[i], align: 'center', fontFace: 'Arial',
417
+ });
418
+
419
+ // Label
420
+ s.addText(stat.label || '', {
421
+ x, y: stat.icon ? 4.1 : 3.7, w: cardW, h: 0.8,
422
+ fontSize: 14, color: theme.text, align: 'center', fontFace: 'Arial', wrap: true,
423
+ });
424
+
425
+ // Sub text
426
+ if (stat.sub) {
427
+ s.addText(stat.sub, {
428
+ x, y: stat.icon ? 4.7 : 4.3, w: cardW, h: 0.5,
429
+ fontSize: 11, color: theme.border, align: 'center', fontFace: 'Arial', italic: true,
430
+ });
98
431
  }
432
+ });
433
+ }
434
+
435
+ _addQuoteSlide(pres, slide, theme, slideNum, total) {
436
+ const s = this._addBase(pres, theme, slideNum, total);
99
437
 
100
- // Slide number
101
- s.addText(String(slides.indexOf(slide) + 2), {
102
- x: 9, y: 5, w: 0.5, h: 0.3,
103
- fontSize: 10, color: theme.text, align: 'right',
438
+ // Large quote mark
439
+ s.addText('"', {
440
+ x: 1, y: 1.2, w: 2, h: 2,
441
+ fontSize: 120, color: theme.accent, fontFace: 'Georgia', bold: true,
442
+ });
443
+
444
+ // Quote text
445
+ s.addText(slide.quote || slide.content || '', {
446
+ x: 1.5, y: 2.5, w: 10, h: 2.5,
447
+ fontSize: 28, color: theme.title, fontFace: 'Georgia', italic: true,
448
+ lineSpacing: 36, wrap: true,
449
+ });
450
+
451
+ // Attribution
452
+ if (slide.author || slide.attribution) {
453
+ s.addShape(pres.ShapeType.rect, {
454
+ x: 1.5, y: 5.2, w: 1.5, h: 0.03, fill: { color: theme.accent },
455
+ });
456
+ s.addText('— ' + (slide.author || slide.attribution), {
457
+ x: 1.5, y: 5.4, w: 8, h: 0.5,
458
+ fontSize: 16, color: theme.accent, fontFace: 'Arial',
104
459
  });
105
460
  }
461
+ }
106
462
 
107
- // Save
108
- const filename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pptx';
109
- const filepath = join(this.outputDir, filename);
110
-
111
- const data = await pres.write({ outputType: 'nodebuffer' });
112
- writeFileSync(filepath, data);
463
+ _addImageSlide(pres, slide, theme, slideNum, total) {
464
+ const s = this._addBase(pres, theme, slideNum, total);
465
+
466
+ if (slide.title) {
467
+ s.addText(slide.title, {
468
+ x: 0.5, y: 0.3, w: 12, h: 0.7,
469
+ fontSize: 26, bold: true, color: theme.title, fontFace: 'Arial',
470
+ });
471
+ }
472
+
473
+ if (slide.imageUrl) {
474
+ try {
475
+ s.addImage({
476
+ path: slide.imageUrl,
477
+ x: 1, y: 1.5, w: 11, h: 5,
478
+ sizing: { type: 'contain', w: 11, h: 5 },
479
+ });
480
+ } catch {}
481
+ }
482
+
483
+ if (slide.caption) {
484
+ s.addText(slide.caption, {
485
+ x: 0.5, y: 6.5, w: 12, h: 0.5,
486
+ fontSize: 12, color: theme.text, align: 'center', italic: true,
487
+ });
488
+ }
489
+ }
490
+
491
+ _addSectionSlide(pres, slide, theme, slideNum, total) {
492
+ const s = pres.addSlide();
493
+ s.background = { color: theme.bg2 };
494
+
495
+ s.addShape(pres.ShapeType.rect, {
496
+ x: 0, y: 0, w: '100%', h: 0.08, fill: { color: theme.accent },
497
+ });
498
+
499
+ s.addText(slide.title || 'Section', {
500
+ x: 0.5, y: 2.5, w: 12, h: 1.5,
501
+ fontSize: 38, bold: true, color: theme.title, fontFace: 'Arial',
502
+ });
503
+
504
+ if (slide.subtitle) {
505
+ s.addText(slide.subtitle, {
506
+ x: 0.5, y: 4, w: 10, h: 0.8,
507
+ fontSize: 18, color: theme.text, fontFace: 'Arial',
508
+ });
509
+ }
510
+
511
+ s.addShape(pres.ShapeType.rect, {
512
+ x: 0.5, y: 4.9, w: 2.5, h: 0.04, fill: { color: theme.accent },
513
+ });
514
+ }
515
+
516
+ _addThankYouSlide(pres, options, theme) {
517
+ const s = pres.addSlide();
518
+ s.background = { color: theme.bg };
519
+
520
+ s.addShape(pres.ShapeType.rect, {
521
+ x: 0, y: 0, w: '100%', h: 0.08, fill: { color: theme.accent },
522
+ });
523
+
524
+ s.addText('Thank You', {
525
+ x: 0, y: 2, w: 13, h: 1.5,
526
+ fontSize: 48, bold: true, color: theme.title, align: 'center', fontFace: 'Arial',
527
+ });
528
+
529
+ s.addShape(pres.ShapeType.rect, {
530
+ x: 5.5, y: 3.6, w: 2, h: 0.04, fill: { color: theme.accent },
531
+ });
113
532
 
114
- logger.info('pptx', `Created: ${filepath} (${slides.length} slides)`);
115
- return { filepath, filename, slideCount: slides.length + 1 }; // +1 for title slide
533
+ if (options.contact) {
534
+ s.addText(options.contact, {
535
+ x: 0, y: 4, w: 13, h: 0.8,
536
+ fontSize: 16, color: theme.text, align: 'center', fontFace: 'Arial',
537
+ });
538
+ }
539
+
540
+ s.addText('Created with Squidclaw AI 🦑', {
541
+ x: 0, y: 6.5, w: 13, h: 0.5,
542
+ fontSize: 11, color: theme.border, align: 'center', italic: true,
543
+ });
116
544
  }
117
545
 
546
+ // ── PARSER ──
547
+
118
548
  /**
119
- * Parse AI-generated content into slides
120
- * Format: "## Slide Title\n- bullet 1\n- bullet 2\n\n## Next Slide..."
549
+ * Parse AI content into structured slides
550
+ * Supports: ## Title, - bullets, [chart:bar], [table], [stats], [timeline], [quote], [compare], [section]
121
551
  */
122
552
  static parseContent(text) {
123
553
  const slides = [];
@@ -125,22 +555,190 @@ export class PptxGenerator {
125
555
 
126
556
  for (const section of sections) {
127
557
  const lines = section.trim().split('\n');
128
- const title = lines[0].trim();
129
- const bullets = [];
130
- let content = '';
558
+ const titleLine = lines[0].trim();
559
+
560
+ // Detect slide type from markers
561
+ let slide = { title: titleLine, type: 'content', bullets: [], content: '' };
562
+
563
+ // [chart:bar] or [chart:pie] etc
564
+ const chartMatch = titleLine.match(/\[chart:(\w+)\]/i) || lines.some(l => l.match(/\[chart:(\w+)\]/i));
565
+ if (chartMatch || lines.some(l => /\[chart/i.test(l))) {
566
+ slide.type = 'chart';
567
+ slide.title = titleLine.replace(/\[chart:\w+\]/i, '').trim();
568
+ const typeMatch = section.match(/\[chart:(\w+)\]/i);
569
+ slide.chartType = typeMatch ? typeMatch[1] : 'bar';
570
+ slide.chartData = this._parseChartData(lines.slice(1));
571
+ slides.push(slide);
572
+ continue;
573
+ }
574
+
575
+ // [table]
576
+ if (lines.some(l => /\[table\]|^\|/.test(l))) {
577
+ slide.type = 'table';
578
+ slide.title = titleLine.replace(/\[table\]/i, '').trim();
579
+ slide.tableData = this._parseTableData(lines.slice(1));
580
+ slides.push(slide);
581
+ continue;
582
+ }
583
+
584
+ // [stats]
585
+ if (lines.some(l => /\[stats?\]/i.test(l))) {
586
+ slide.type = 'stats';
587
+ slide.title = titleLine.replace(/\[stats?\]/i, '').trim();
588
+ slide.stats = this._parseStats(lines.slice(1));
589
+ slides.push(slide);
590
+ continue;
591
+ }
592
+
593
+ // [timeline]
594
+ if (lines.some(l => /\[timeline\]/i.test(l))) {
595
+ slide.type = 'timeline';
596
+ slide.title = titleLine.replace(/\[timeline\]/i, '').trim();
597
+ slide.events = this._parseTimeline(lines.slice(1));
598
+ slides.push(slide);
599
+ continue;
600
+ }
131
601
 
602
+ // [quote]
603
+ if (lines.some(l => /\[quote\]/i.test(l)) || titleLine.includes('[quote]')) {
604
+ slide.type = 'quote';
605
+ slide.title = titleLine.replace(/\[quote\]/i, '').trim();
606
+ const quoteLines = lines.slice(1).filter(l => !l.includes('[quote]'));
607
+ const authorLine = quoteLines.find(l => l.startsWith('—') || l.startsWith('-—') || l.startsWith('- Author:'));
608
+ slide.quote = quoteLines.filter(l => l !== authorLine).map(l => l.replace(/^[-•*]\s*/, '').trim()).filter(Boolean).join(' ');
609
+ slide.author = authorLine ? authorLine.replace(/^[-—•*\s]*Author:\s*|^[-—]\s*/i, '').trim() : '';
610
+ slides.push(slide);
611
+ continue;
612
+ }
613
+
614
+ // [compare] or [vs]
615
+ if (lines.some(l => /\[compare\]|\[vs\]/i.test(l))) {
616
+ slide.type = 'comparison';
617
+ slide.title = titleLine.replace(/\[compare\]|\[vs\]/i, '').trim();
618
+ const parsed = this._parseComparison(lines.slice(1));
619
+ slide.left = parsed.left;
620
+ slide.right = parsed.right;
621
+ slides.push(slide);
622
+ continue;
623
+ }
624
+
625
+ // [section]
626
+ if (titleLine.includes('[section]') || lines.some(l => /\[section\]/i.test(l))) {
627
+ slide.type = 'section';
628
+ slide.title = titleLine.replace(/\[section\]/i, '').trim();
629
+ slide.subtitle = lines.slice(1).filter(l => !l.includes('[section]')).map(l => l.trim()).filter(Boolean).join(' ');
630
+ slides.push(slide);
631
+ continue;
632
+ }
633
+
634
+ // Default: bullets
132
635
  for (let i = 1; i < lines.length; i++) {
133
636
  const line = lines[i].trim();
134
637
  if (line.startsWith('- ') || line.startsWith('• ') || line.startsWith('* ')) {
135
- bullets.push(line.replace(/^[-•*]\s*/, ''));
638
+ slide.bullets.push(line.replace(/^[-•*]\s*/, ''));
136
639
  } else if (line) {
137
- content += (content ? '\n' : '') + line;
640
+ slide.content += (slide.content ? '\n' : '') + line;
138
641
  }
139
642
  }
140
-
141
- slides.push({ title, bullets: bullets.length > 0 ? bullets : null, content: content || null });
643
+ if (!slide.bullets.length && slide.content) slide.bullets = null;
644
+ slides.push(slide);
142
645
  }
143
646
 
144
647
  return slides;
145
648
  }
649
+
650
+ static _parseChartData(lines) {
651
+ const data = [];
652
+ let current = null;
653
+ const labels = [];
654
+ const values = [];
655
+
656
+ for (const line of lines) {
657
+ const trimmed = line.trim();
658
+ if (!trimmed || trimmed.includes('[chart')) continue;
659
+
660
+ // Format: "Label: value" or "Label | value"
661
+ const match = trimmed.match(/^[-•*]?\s*(.+?)[\s:|\-→]+(\d+[\d,.]*%?)\s*$/);
662
+ if (match) {
663
+ labels.push(match[1].trim());
664
+ values.push(parseFloat(match[2].replace(/[,%]/g, '')));
665
+ }
666
+ }
667
+
668
+ if (labels.length > 0) {
669
+ data.push({ name: 'Data', labels, values });
670
+ }
671
+ return data.length > 0 ? data : [{ name: 'Sample', labels: ['A', 'B', 'C'], values: [30, 50, 20] }];
672
+ }
673
+
674
+ static _parseTableData(lines) {
675
+ const rows = [];
676
+ for (const line of lines) {
677
+ const trimmed = line.trim();
678
+ if (!trimmed || trimmed.includes('[table]') || /^[-|:]+$/.test(trimmed)) continue;
679
+ if (trimmed.startsWith('|')) {
680
+ const cells = trimmed.split('|').filter(c => c.trim()).map(c => c.trim());
681
+ if (cells.length > 0) rows.push(cells);
682
+ } else if (trimmed.includes(',') || trimmed.includes('\t')) {
683
+ rows.push(trimmed.split(/[,\t]+/).map(c => c.trim()));
684
+ }
685
+ }
686
+ return rows.length > 0 ? rows : [['Col 1', 'Col 2'], ['Data', 'Data']];
687
+ }
688
+
689
+ static _parseStats(lines) {
690
+ const stats = [];
691
+ for (const line of lines) {
692
+ const trimmed = line.trim();
693
+ if (!trimmed || trimmed.includes('[stat')) continue;
694
+ // Format: "📊 100M+ — Active users" or "100M: Active users"
695
+ const match = trimmed.match(/^[-•*]?\s*([^\w]*?)?\s*([\d,.]+[%KMBkm+]*)\s*[-:—|]+\s*(.+)/);
696
+ if (match) {
697
+ stats.push({
698
+ icon: match[1]?.trim() || undefined,
699
+ value: match[2].trim(),
700
+ label: match[3].trim(),
701
+ });
702
+ }
703
+ }
704
+ return stats.length > 0 ? stats : [{ value: '?', label: 'No data' }];
705
+ }
706
+
707
+ static _parseTimeline(lines) {
708
+ const events = [];
709
+ for (const line of lines) {
710
+ const trimmed = line.trim();
711
+ if (!trimmed || trimmed.includes('[timeline]')) continue;
712
+ const match = trimmed.match(/^[-•*]?\s*(\d{4}[s]?|Phase \d+|Q\d|Step \d+)\s*[-:—|]+\s*(.+)/i);
713
+ if (match) {
714
+ events.push({ date: match[1].trim(), text: match[2].trim() });
715
+ }
716
+ }
717
+ return events.length > 0 ? events : [{ date: '???', text: 'No events' }];
718
+ }
719
+
720
+ static _parseComparison(lines) {
721
+ const left = { title: 'Option A', points: [] };
722
+ const right = { title: 'Option B', points: [] };
723
+ let side = 'left';
724
+
725
+ for (const line of lines) {
726
+ const trimmed = line.trim();
727
+ if (!trimmed || trimmed.includes('[compare]') || trimmed.includes('[vs]')) continue;
728
+
729
+ if (trimmed.toLowerCase().includes('vs') || trimmed === '---') {
730
+ side = 'right';
731
+ continue;
732
+ }
733
+
734
+ const target = side === 'left' ? left : right;
735
+ if (trimmed.startsWith('###') || trimmed.startsWith('**')) {
736
+ target.title = trimmed.replace(/^[#*\s]+|[*]+$/g, '');
737
+ } else if (trimmed.startsWith('- ') || trimmed.startsWith('• ')) {
738
+ target.points.push(trimmed.replace(/^[-•]\s*/, ''));
739
+ }
740
+ }
741
+
742
+ return { left, right };
743
+ }
146
744
  }