@lutrin/core 1.0.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,1379 @@
1
+ /**
2
+ * PowerPoint renderer: scenes → .pptx via PptxGenJS.
3
+ *
4
+ * All the geometry comes from the layout engine; here we only translate
5
+ * placed elements into PptxGenJS calls, applying the design tokens of the
6
+ * active theme (see tokens.mjs):
7
+ * - `primary` = the only accent;
8
+ * - flat system: hairline rules and recessed fills, no shadows;
9
+ * - titles bold 700 in neutral-primary; theme fonts embedded in the
10
+ * .pptx when it provides them (fonts.mjs) — fallback on the installed
11
+ * font in viewers that ignore embedded fonts (Keynote, LibreOffice).
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import JSZip from 'jszip';
18
+ import PptxGenJS from 'pptxgenjs';
19
+ import {
20
+ CHROME,
21
+ COLORS,
22
+ FONTS,
23
+ LOGOS,
24
+ TYPE,
25
+ SPACE,
26
+ PAGE,
27
+ SEMANTIC,
28
+ TREND_INK,
29
+ panelStyle,
30
+ px,
31
+ LINE_HEIGHT,
32
+ } from '../deck/tokens.mjs';
33
+ import { ALERT_BLOCK_TYPES, runsToText } from '../deck/parse.mjs';
34
+ import {
35
+ fetchRemoteImage,
36
+ imageDims,
37
+ renderIcon,
38
+ renderMath,
39
+ renderMermaidCached,
40
+ rasterAvailable,
41
+ resolveLocalImage,
42
+ svgToPng,
43
+ vendorRemoteAssets,
44
+ writeTmpPng,
45
+ } from '../deck/assets.mjs';
46
+ import { chartSvg } from '../deck/chart.mjs';
47
+ import { highlightLine } from '../deck/highlight.mjs';
48
+ import { embedFonts } from './fonts.mjs';
49
+ import { embedAnimations } from './anim.mjs';
50
+ import { embedMorph } from './morph.mjs';
51
+
52
+ /** addImage options for a logo at an imposed height, width at its native ratio
53
+ * (the paths come from LOGOS — themable, so the ratio is never presumed).
54
+ * Dimensions that could not be read → null: better to omit the logo than to
55
+ * stretch it to an invented ratio. */
56
+ function logoImage(file, h, x, y) {
57
+ const dims = imageDims(file);
58
+ if (!dims?.w || !dims?.h) return null;
59
+ // altText is mandatory: without it, the path of the kit's logo — absolute
60
+ // after resolveTheme() — lands in the `descr` of the .pptx (see altOf)
61
+ return {
62
+ path: file,
63
+ altText: 'Logo',
64
+ objectName: 'Logo',
65
+ x: px(x),
66
+ y: px(y),
67
+ h: px(h),
68
+ w: px(h * (dims.w / dims.h)),
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Text: IR runs → PptxGenJS runs
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function toRuns(runs, base = {}) {
77
+ return runs.map((r) => ({
78
+ text: r.text,
79
+ options: {
80
+ bold: r.bold || base.bold || false,
81
+ italic: r.italic || base.italic || false,
82
+ fontFace: r.code ? FONTS.mono : (base.fontFace ?? FONTS.body),
83
+ color: r.code ? COLORS.primaryDarker : (base.color ?? COLORS.neutralPrimary),
84
+ ...(r.link ? { hyperlink: { url: r.link } } : {}),
85
+ },
86
+ }));
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Images: "contain" framing (native dimensions: imageDims, assets.mjs)
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Alt text for an image, for the `descr` attribute of the OOXML.
95
+ *
96
+ * PptxGenJS, when it is given no `altText`, copies the PATH of the file into
97
+ * `descr` — that is to say the author's local directory tree, username
98
+ * included, embedded in a deliverable that goes out by email. Every image
99
+ * written here therefore goes through this function, and it never returns an
100
+ * empty string (an empty string would trigger the PptxGenJS fallback again).
101
+ *
102
+ * With no alt, we fall back on the FILE NAME alone: `![](/Users/firstname/
103
+ * Desktop/x.png)` is a commonplace way of inserting an image, and the path it
104
+ * carries has no more business leaking than the one we resolved. The file
105
+ * name, on the other hand, stays useful — it is what lets one recognize the
106
+ * image in PowerPoint's accessibility pane.
107
+ */
108
+ function altOf(alt, src = '') {
109
+ const text = (alt ?? '').trim();
110
+ if (text) return text;
111
+ // query string dropped (remote image), then DECODE BEFORE SPLIT: a path can
112
+ // arrive already percent-encoded (a copy-pasted file URL, a drag-and-drop
113
+ // from some browsers), and it then contains no literal `/` to split on —
114
+ // splitting first would put the whole path, the author's directories
115
+ // included, into a deliverable that circulates.
116
+ const bare = String(src).split(/[?#]/)[0];
117
+ let plain;
118
+ try {
119
+ plain = decodeURIComponent(bare);
120
+ } catch {
121
+ plain = bare; // invalid % sequence: the raw name is authoritative
122
+ }
123
+ const clean = plain.replace(/[\\/]+$/, '');
124
+ const base = clean.slice(clean.search(/[^\\/]*$/));
125
+ return base || 'Image';
126
+ }
127
+
128
+ /** "contain" box: the image fits inside the region, ratio preserved, centered.
129
+ * PptxGenJS does not read native dimensions — we impose them ourselves,
130
+ * otherwise the visual is stretched to the proportions of the slot. */
131
+ function containRect(dims, r) {
132
+ if (!dims || !dims.w || !dims.h) return r;
133
+ const scale = Math.min(r.w / dims.w, r.h / dims.h);
134
+ const w = dims.w * scale;
135
+ const h = dims.h * scale;
136
+ return { x: r.x + (r.w - w) / 2, y: r.y + (r.h - h) / 2, w, h };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Block rendering
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function addPara(slide, block, r) {
144
+ slide.addText(toRuns(block.runs, { color: block.color }), {
145
+ x: px(r.x),
146
+ y: px(r.y),
147
+ w: px(r.w),
148
+ h: px(r.h),
149
+ fontSize: TYPE.body,
150
+ fontFace: FONTS.body,
151
+ color: block.color ?? COLORS.neutralPrimary,
152
+ align: 'left',
153
+ valign: 'top',
154
+ lineSpacingMultiple: LINE_HEIGHT,
155
+ });
156
+ }
157
+
158
+ function addHeading(slide, block, r) {
159
+ // `size` (pt) and `align`: key message of the focus layout — otherwise a slot title
160
+ slide.addText(toRuns(block.runs, { bold: true, color: block.color }), {
161
+ x: px(r.x),
162
+ y: px(r.y),
163
+ w: px(r.w),
164
+ h: px(r.h),
165
+ fontSize: block.size ?? TYPE.sectionHeading,
166
+ fontFace: FONTS.body,
167
+ color: block.color ?? COLORS.neutralPrimary,
168
+ bold: true,
169
+ valign: 'top',
170
+ ...(block.align ? { align: block.align } : {}),
171
+ // multi-line message: same line height as the CSS .slot-heading (1.3)
172
+ ...(block.size ? { lineSpacingMultiple: 1.3 } : {}),
173
+ });
174
+ }
175
+
176
+ function addBullets(slide, block, r) {
177
+ const runs = [];
178
+ // `startAt`: a chunk of a numbered list split by pagination. In OOXML,
179
+ // `buAutoNum/@startAt` RESTARTS the counter at the paragraph that carries
180
+ // it: it only goes on the first top-level item, otherwise all the following
181
+ // bullets would resume at the same rank.
182
+ let rankToPlace = block.ordered && block.startAt > 1 ? block.startAt : 0;
183
+ block.items.forEach((it) => {
184
+ // A bullet whose only content is an image has NO run: the parser keeps
185
+ // only the text of a list item. With no support on which to place the
186
+ // marker, the formatting of the bullet used to collapse — and with it the
187
+ // whole export. We keep the empty line rather than skipping it: it exists
188
+ // in the HTML (an empty <li>) and the layout engine has already reserved
189
+ // its height — spiriting it away would shift everything that follows.
190
+ const itemRuns = toRuns(it.runs, { color: block.color });
191
+ if (!itemRuns.length) {
192
+ itemRuns.push({
193
+ text: '',
194
+ options: { fontFace: FONTS.body, color: block.color ?? COLORS.neutralPrimary },
195
+ });
196
+ }
197
+ itemRuns[0] = {
198
+ ...itemRuns[0],
199
+ options: {
200
+ ...itemRuns[0].options,
201
+ bullet: block.ordered
202
+ ? {
203
+ type: 'number',
204
+ indent: 16,
205
+ ...(rankToPlace && !it.level ? { startAt: rankToPlace } : {}),
206
+ }
207
+ : { code: '2022', indent: 16 },
208
+ indentLevel: it.level,
209
+ breakLine: false,
210
+ },
211
+ };
212
+ if (!it.level) rankToPlace = 0;
213
+ itemRuns[itemRuns.length - 1].options.breakLine = true;
214
+ runs.push(...itemRuns);
215
+ });
216
+ slide.addText(runs, {
217
+ x: px(r.x),
218
+ y: px(r.y),
219
+ w: px(r.w),
220
+ h: px(r.h),
221
+ fontSize: TYPE.bullet,
222
+ fontFace: FONTS.body,
223
+ color: block.color ?? COLORS.neutralPrimary,
224
+ valign: 'top',
225
+ lineSpacingMultiple: 1.25,
226
+ paraSpaceAfter: 6,
227
+ });
228
+ }
229
+
230
+ function addCode(slide, block, r) {
231
+ slide.addShape('roundRect', {
232
+ x: px(r.x),
233
+ y: px(r.y),
234
+ w: px(r.w),
235
+ h: px(r.h),
236
+ fill: { color: COLORS.underground1 },
237
+ line: { color: COLORS.neutralStroke, width: 0.75 },
238
+ rectRadius: px(8),
239
+ });
240
+ const lines = block.source.split('\n');
241
+ const runs = lines.flatMap((line, k) => {
242
+ const hl = highlightLine(line, block.lang).map((seg) => ({
243
+ text: seg.text,
244
+ options: {
245
+ fontFace: FONTS.mono,
246
+ color: seg.color ?? COLORS.neutralPrimary,
247
+ bold: seg.bold ?? false,
248
+ italic: seg.italic ?? false,
249
+ breakLine: false,
250
+ },
251
+ }));
252
+ hl[hl.length - 1].options.breakLine = true;
253
+ return hl;
254
+ });
255
+ slide.addText(runs, {
256
+ x: px(r.x + SPACE.sm),
257
+ y: px(r.y + SPACE.xs),
258
+ w: px(r.w - 2 * SPACE.sm),
259
+ h: px(r.h - 2 * SPACE.xs),
260
+ fontSize: TYPE.code,
261
+ valign: 'top',
262
+ lineSpacingMultiple: 1.25,
263
+ });
264
+ }
265
+
266
+ function addTable(slide, block, r) {
267
+ const border = [
268
+ { type: 'none' },
269
+ { type: 'none' },
270
+ { pt: 0.75, color: COLORS.neutralStroke },
271
+ { type: 'none' },
272
+ ];
273
+ const headerRow = block.header.map((cell) => ({
274
+ text: toRuns(cell, { bold: true }),
275
+ options: {
276
+ bold: true,
277
+ fill: { color: COLORS.underground1 },
278
+ border,
279
+ color: COLORS.neutralPrimary,
280
+ },
281
+ }));
282
+ const bodyRows = block.rows.map((row) =>
283
+ row.map((cell) => ({ text: toRuns(cell), options: { border, color: COLORS.neutralPrimary } })),
284
+ );
285
+ slide.addTable([...(headerRow.length ? [headerRow] : []), ...bodyRows], {
286
+ x: px(r.x),
287
+ y: px(r.y),
288
+ w: px(r.w),
289
+ fontSize: TYPE.tableBody,
290
+ fontFace: FONTS.body,
291
+ valign: 'middle',
292
+ margin: 6,
293
+ autoPage: false,
294
+ });
295
+ }
296
+
297
+ function addAlert(slide, block, r) {
298
+ const sem = SEMANTIC[block.kind] ?? SEMANTIC.info;
299
+ slide.addShape('roundRect', {
300
+ x: px(r.x),
301
+ y: px(r.y),
302
+ w: px(r.w),
303
+ h: px(r.h),
304
+ fill: { color: sem.fill },
305
+ line: { type: 'none' },
306
+ rectRadius: px(4),
307
+ });
308
+ const runs = [
309
+ {
310
+ text: sem.label,
311
+ options: { bold: true, fontSize: TYPE.small, color: sem.text, breakLine: true },
312
+ },
313
+ ];
314
+ // outside ALERT_BLOCK_TYPES: ignored (height not reserved by blockHeight,
315
+ // reported to the author by the ALERT_CONTENT_DROPPED diagnostic)
316
+ for (const b of block.blocks) {
317
+ if (!ALERT_BLOCK_TYPES.has(b.type)) continue;
318
+ if (b.type === 'para') {
319
+ const rr = toRuns(b.runs, { color: sem.text });
320
+ rr[rr.length - 1].options.breakLine = true;
321
+ runs.push(...rr);
322
+ } else if (b.type === 'bullets') {
323
+ for (const it of b.items) {
324
+ const rr = toRuns(it.runs, { color: sem.text });
325
+ rr[0].options.bullet = { code: '2022', indent: 12 };
326
+ rr[rr.length - 1].options.breakLine = true;
327
+ runs.push(...rr);
328
+ }
329
+ }
330
+ }
331
+ slide.addText(runs, {
332
+ x: px(r.x + SPACE.sm),
333
+ y: px(r.y + SPACE.xs),
334
+ w: px(r.w - 2 * SPACE.sm),
335
+ h: px(r.h - 2 * SPACE.xs),
336
+ fontSize: TYPE.body,
337
+ fontFace: FONTS.body,
338
+ valign: 'top',
339
+ lineSpacingMultiple: 1.3,
340
+ });
341
+ }
342
+
343
+ /** Canonical arrow of the trend (the glyph that was typed is not kept). */
344
+ const TREND_GLYPH = { up: '↑', down: '↓', flat: '→' };
345
+
346
+ function addMetric(slide, block, r) {
347
+ slide.addShape('roundRect', {
348
+ x: px(r.x),
349
+ y: px(r.y),
350
+ w: px(r.w),
351
+ h: px(r.h),
352
+ fill: { color: COLORS.ground },
353
+ line: { color: COLORS.neutralStroke, width: 1 },
354
+ rectRadius: px(8),
355
+ });
356
+ // with a trend, the card tightens up to make room for it
357
+ const t = block.trend;
358
+ slide.addText(block.value, {
359
+ x: px(r.x),
360
+ y: px(r.y + (t ? SPACE.xs : SPACE.sm)),
361
+ w: px(r.w),
362
+ h: px(r.h * (t ? 0.48 : 0.55)),
363
+ fontSize: TYPE.metricValue,
364
+ bold: true,
365
+ color: COLORS.primary,
366
+ fontFace: FONTS.body,
367
+ align: 'center',
368
+ valign: 'middle',
369
+ fit: 'shrink',
370
+ });
371
+ slide.addText(block.label, {
372
+ x: px(r.x + SPACE.xs),
373
+ y: px(r.y + r.h * (t ? 0.54 : 0.62)),
374
+ w: px(r.w - 2 * SPACE.xs),
375
+ h: px(r.h * (t ? 0.22 : 0.3)),
376
+ fontSize: TYPE.metricLabel,
377
+ color: COLORS.neutralSecondary,
378
+ fontFace: FONTS.body,
379
+ align: 'center',
380
+ valign: 'top',
381
+ });
382
+ if (t) {
383
+ slide.addText(`${TREND_GLYPH[t.dir]} ${t.text}`.trim(), {
384
+ x: px(r.x + SPACE.xs),
385
+ y: px(r.y + r.h * 0.76),
386
+ w: px(r.w - 2 * SPACE.xs),
387
+ h: px(r.h * 0.18),
388
+ fontSize: TYPE.small,
389
+ bold: true,
390
+ color: TREND_INK[t.sentiment],
391
+ fontFace: FONTS.body,
392
+ align: 'center',
393
+ valign: 'middle',
394
+ });
395
+ }
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Blocks synthesized by the structured layouts (comparison, pillars,
400
+ // timeline, layers, swot) — never produced directly by the DSL
401
+ // ---------------------------------------------------------------------------
402
+
403
+ function addPanel(slide, block, r) {
404
+ const style = panelStyle(block);
405
+ slide.addShape('roundRect', {
406
+ x: px(r.x),
407
+ y: px(r.y),
408
+ w: px(r.w),
409
+ h: px(r.h),
410
+ fill: { color: style.fill },
411
+ line: style.line ? { color: style.line.color, width: style.line.width } : { type: 'none' },
412
+ rectRadius: px(
413
+ block.variant === 'accent'
414
+ ? 2
415
+ : block.variant === 'layer' || block.variant === 'semantic'
416
+ ? 4
417
+ : 8,
418
+ ),
419
+ });
420
+ if (block.variant === 'pillar' && block.accent !== false) {
421
+ // accent at the head of the pillar — the only use of green in the panel
422
+ slide.addShape('rect', {
423
+ x: px(r.x + SPACE.xs),
424
+ y: px(r.y),
425
+ w: px(r.w - 2 * SPACE.xs),
426
+ h: px(4),
427
+ fill: { color: COLORS.primary },
428
+ line: { type: 'none' },
429
+ });
430
+ }
431
+ }
432
+
433
+ function addTimelineAxis(slide, block, r) {
434
+ const arrow = block.arrow !== false;
435
+ if (block.vertical) {
436
+ // vertical axis (roadmap in a column): time runs downwards
437
+ slide.addShape('rect', {
438
+ x: px(r.x),
439
+ y: px(r.y),
440
+ w: px(r.w),
441
+ h: px(r.h - (arrow ? 14 : 0)),
442
+ fill: { color: COLORS.neutralStroke },
443
+ line: { type: 'none' },
444
+ });
445
+ if (arrow) {
446
+ slide.addShape('triangle', {
447
+ x: px(r.x + r.w / 2 - 7),
448
+ y: px(r.y + r.h - 14),
449
+ w: px(14),
450
+ h: px(14),
451
+ fill: { color: COLORS.neutralStroke },
452
+ line: { type: 'none' },
453
+ rotate: 180,
454
+ });
455
+ }
456
+ return;
457
+ }
458
+ slide.addShape('rect', {
459
+ x: px(r.x),
460
+ y: px(r.y),
461
+ w: px(r.w - (arrow ? 14 : 0)),
462
+ h: px(r.h),
463
+ fill: { color: COLORS.neutralStroke },
464
+ line: { type: 'none' },
465
+ });
466
+ if (arrow) {
467
+ // arrowhead: time flows towards the right
468
+ slide.addShape('triangle', {
469
+ x: px(r.x + r.w - 14),
470
+ y: px(r.y + r.h / 2 - 7),
471
+ w: px(14),
472
+ h: px(14),
473
+ fill: { color: COLORS.neutralStroke },
474
+ line: { type: 'none' },
475
+ rotate: 90,
476
+ });
477
+ }
478
+ }
479
+
480
+ function addTimelineDot(slide, block, r) {
481
+ slide.addShape('ellipse', {
482
+ x: px(r.x),
483
+ y: px(r.y),
484
+ w: px(r.w),
485
+ h: px(r.h),
486
+ fill: { color: COLORS.primary },
487
+ line: { color: COLORS.ground, width: 2 },
488
+ });
489
+ if (block.numbered === false) return; // solid dot, with no number
490
+ slide.addText(String(block.index), {
491
+ x: px(r.x),
492
+ y: px(r.y),
493
+ w: px(r.w),
494
+ h: px(r.h),
495
+ fontSize: TYPE.metricLabel,
496
+ bold: true,
497
+ color: COLORS.ground,
498
+ fontFace: FONTS.body,
499
+ align: 'center',
500
+ valign: 'middle',
501
+ });
502
+ }
503
+
504
+ function addQuote(slide, block, r) {
505
+ slide.addText('“', {
506
+ x: px(r.x),
507
+ y: px(r.y),
508
+ w: px(80),
509
+ h: px(96),
510
+ fontSize: 72,
511
+ bold: true,
512
+ color: COLORS.primary,
513
+ fontFace: FONTS.body,
514
+ });
515
+ slide.addText(toRuns(block.runs, { italic: true }), {
516
+ x: px(r.x + 96),
517
+ y: px(r.y),
518
+ w: px(r.w - 128),
519
+ h: px(r.h - 64),
520
+ fontSize: TYPE.quote,
521
+ italic: true,
522
+ color: COLORS.neutralPrimary,
523
+ fontFace: FONTS.body,
524
+ valign: 'middle',
525
+ lineSpacingMultiple: LINE_HEIGHT,
526
+ });
527
+ if (block.cite) {
528
+ slide.addText(`— ${block.cite}`, {
529
+ x: px(r.x + 96),
530
+ y: px(r.y + r.h - 56),
531
+ w: px(r.w - 128),
532
+ h: px(40),
533
+ fontSize: TYPE.body,
534
+ color: COLORS.neutralSecondary,
535
+ fontFace: FONTS.body,
536
+ align: 'right',
537
+ });
538
+ }
539
+ }
540
+
541
+ function addImage(slide, block, r, ctx) {
542
+ const src = /^https?:/.test(block.src)
543
+ ? (ctx.remote.get(block.src) ?? null) // local copy downloaded in the pre-pass
544
+ : resolveLocalImage(ctx.imageRoots, block.src);
545
+ const alt = altOf(block.alt, block.src);
546
+ if (src && fs.existsSync(src)) {
547
+ const fit =
548
+ block.role === 'background' || block.role === 'cover' ? r : containRect(imageDims(src), r);
549
+ slide.addImage({
550
+ path: src,
551
+ altText: alt,
552
+ x: px(fit.x),
553
+ y: px(fit.y),
554
+ w: px(fit.w),
555
+ h: px(fit.h),
556
+ });
557
+ return;
558
+ }
559
+ // not found or remote: a placeholder, never a broken slide
560
+ slide.addShape('roundRect', {
561
+ x: px(r.x),
562
+ y: px(r.y),
563
+ w: px(r.w),
564
+ h: px(r.h),
565
+ fill: { color: COLORS.underground1 },
566
+ line: { color: COLORS.neutralStroke, width: 0.75, dashType: 'dash' },
567
+ rectRadius: px(8),
568
+ });
569
+ // same rule for the placeholder: that text is VISIBLE on the slide, an
570
+ // absolute path there would be a leak in full view
571
+ slide.addText(`[image: ${alt}]`, {
572
+ x: px(r.x),
573
+ y: px(r.y),
574
+ w: px(r.w),
575
+ h: px(r.h),
576
+ fontSize: TYPE.small,
577
+ color: COLORS.neutralSecondary,
578
+ fontFace: FONTS.body,
579
+ align: 'center',
580
+ valign: 'middle',
581
+ });
582
+ }
583
+
584
+ function addMermaid(slide, block, r, ctx) {
585
+ const png = ctx.mermaid.get(block);
586
+ if (png) {
587
+ const fit = containRect(imageDims(png), r);
588
+ // the PNG comes from the user cache (~/…): altText mandatory (altOf)
589
+ slide.addImage({
590
+ path: png,
591
+ altText: 'Mermaid diagram',
592
+ x: px(fit.x),
593
+ y: px(fit.y),
594
+ w: px(fit.w),
595
+ h: px(fit.h),
596
+ });
597
+ return;
598
+ }
599
+ // faithful fallback: source shown as a code block plus a caption
600
+ addCode(slide, { type: 'code', lang: 'mermaid', source: block.source }, { ...r, h: r.h - 24 });
601
+ slide.addText('Mermaid diagram — install @mermaid-js/mermaid-cli for graphical rendering', {
602
+ x: px(r.x),
603
+ y: px(r.y + r.h - 22),
604
+ w: px(r.w),
605
+ h: px(20),
606
+ fontSize: TYPE.caption,
607
+ italic: true,
608
+ color: COLORS.neutralSecondary,
609
+ fontFace: FONTS.body,
610
+ });
611
+ }
612
+
613
+ /**
614
+ * Icon name made readable for a message or an alt text.
615
+ * markdown-it percent-encodes the source of an image, so much so that
616
+ * `![](lucide:café-emoji)` arrives here in the form "caf%c3%a9-emoji":
617
+ * a diagnostic that copied that out as is would be unreadable for the author,
618
+ * who never wrote that string.
619
+ */
620
+ function iconLabel(name) {
621
+ try {
622
+ return decodeURIComponent(name);
623
+ } catch {
624
+ return name; // invalid % sequence: the raw name is authoritative
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Icon name reduced to what can serve as a temporary FILE name.
630
+ * The name comes from the DSL, hence from the author: nothing stops a `/`
631
+ * from lingering in it ("lucide:coffee/"). Written as is into a path, it
632
+ * designates a directory that does not exist and used to bring the whole
633
+ * export down with an ENOENT — even though the icon lookup itself already
634
+ * sanitizes the name on its own side.
635
+ */
636
+ const iconSlug = (name) => name.toLowerCase().replace(/[^a-z0-9-]/g, '') || 'icon';
637
+
638
+ function addIcon(slide, block, r, ctx) {
639
+ const asset = ctx.icons.get(block);
640
+ if (!asset) return; // icon not found (diagnostic emitted in the pre-pass): nothing rather than a broken slab
641
+ const size = Math.min(r.w, r.h, 160);
642
+ // aligned on the left edge, like the text (the brand is a left-aligned
643
+ // system — a centered icon breaks the grid of the column)
644
+ slide.addImage({
645
+ path: asset,
646
+ altText: `Icon ${iconLabel(block.name)}`,
647
+ x: px(r.x),
648
+ y: px(r.y + (r.h - size) / 2),
649
+ w: px(size),
650
+ h: px(size),
651
+ });
652
+ }
653
+
654
+ function addMath(slide, block, r, ctx) {
655
+ const asset = ctx.math.get(block);
656
+ if (asset) {
657
+ // natural size of the equation, centered; shrunk only if it overflows
658
+ const scale = Math.min(1, r.w / asset.displayW, r.h / asset.displayH);
659
+ const w = asset.displayW * scale;
660
+ const h = asset.displayH * scale;
661
+ slide.addImage({
662
+ path: asset.path,
663
+ // the LaTeX source is the best possible alt text for an equation
664
+ // rendered as an image (and avoids leaking the path, see altOf)
665
+ altText: `Equation: ${block.source}`,
666
+ x: px(r.x + (r.w - w) / 2),
667
+ y: px(r.y + (r.h - h) / 2),
668
+ w: px(w),
669
+ h: px(h),
670
+ });
671
+ return;
672
+ }
673
+ // faithful fallback: LaTeX source as a code block plus a caption
674
+ addCode(slide, { type: 'code', lang: 'latex', source: block.source }, { ...r, h: r.h - 24 });
675
+ slide.addText('LaTeX equation — install mathjax-full for graphical rendering', {
676
+ x: px(r.x),
677
+ y: px(r.y + r.h - 22),
678
+ w: px(r.w),
679
+ h: px(20),
680
+ fontSize: TYPE.caption,
681
+ italic: true,
682
+ color: COLORS.neutralSecondary,
683
+ fontFace: FONTS.body,
684
+ });
685
+ }
686
+
687
+ /** Charts: PNG pre-rendered in the pre-pass (in-house SVG → resvg), at the
688
+ * exact dimensions of the slot. Native OOXML charts are invisible in Keynote
689
+ * and QuickLook — an image, for its part, displays everywhere. */
690
+ function addChartBlock(slide, block, r, ctx) {
691
+ const png = ctx.charts.get(block);
692
+ if (png) {
693
+ // a chart rendered as an image is mute for a screen reader: the `descr`
694
+ // carries what the figure shows (and never the path, see altOf)
695
+ slide.addImage({
696
+ path: png,
697
+ altText: `Chart ${block.chartType}: ${block.categories.join(', ')}`,
698
+ x: px(r.x),
699
+ y: px(r.y),
700
+ w: px(r.w),
701
+ h: px(r.h),
702
+ });
703
+ return;
704
+ }
705
+ // faithful fallback (resvg absent): the specification as a code block
706
+ const src = [`type: ${block.chartType}`, `categories: ${block.categories.join(', ')}`]
707
+ .concat(block.series.map((s) => `${s.name}: ${s.values.join(', ')}`))
708
+ .join('\n');
709
+ addCode(slide, { type: 'code', lang: 'chart', source: src }, r);
710
+ }
711
+
712
+ /** Shape label by block type. That name is not decorative: it is what the
713
+ * "Reading Order" pane of PowerPoint displays, and what a screen reader
714
+ * announces when the shape has no text of its own (image, chart). The
715
+ * PptxGenJS default — "Text 3", "Image 1" — teaches nobody anything. */
716
+ const SHAPE_LABELS = {
717
+ para: 'Paragraph',
718
+ heading: 'Subheading',
719
+ bullets: 'List',
720
+ code: 'Code',
721
+ table: 'Table',
722
+ alert: 'Callout',
723
+ metric: 'Key figure',
724
+ quote: 'Quotation',
725
+ image: 'Image',
726
+ mermaid: 'Diagram',
727
+ icon: 'Icon',
728
+ math: 'Equation',
729
+ chart: 'Chart',
730
+ panel: 'Panel',
731
+ 'timeline-axis': 'Timeline axis',
732
+ 'timeline-dot': 'Milestone',
733
+ };
734
+
735
+ /**
736
+ * Slide facade which, on each shape written:
737
+ * - gives it a meaningful name (`label()` + rank, see SHAPE_LABELS) when
738
+ * the caller has not imposed one;
739
+ * - records an entry in `rec` (one per call, in the exact order of the
740
+ * future spTree) — that log is what lets anim.mjs find, after the fact,
741
+ * the ids of the shapes to animate. `rec` null = slide not animated.
742
+ *
743
+ * The options are always the LAST argument of the four methods used
744
+ * (`addImage` takes only one); that is where `objectName` is set.
745
+ */
746
+ function wrapSlide(slide, { label, rec = null, current = null }) {
747
+ const ranks = new Map();
748
+ const nextName = () => {
749
+ const base = label();
750
+ const n = (ranks.get(base) ?? 0) + 1;
751
+ ranks.set(base, n);
752
+ return `${base} ${n}`;
753
+ };
754
+ const wrap =
755
+ (name) =>
756
+ (...args) => {
757
+ if (rec) rec.push(current());
758
+ const opts = args[args.length - 1];
759
+ // an array would be the content (runs, lines), not options
760
+ if (opts && typeof opts === 'object' && !Array.isArray(opts) && !opts.objectName)
761
+ opts.objectName = nextName();
762
+ return slide[name](...args);
763
+ };
764
+ return {
765
+ addText: wrap('addText'),
766
+ addShape: wrap('addShape'),
767
+ addImage: wrap('addImage'),
768
+ addTable: wrap('addTable'),
769
+ };
770
+ }
771
+
772
+ /** Exported for the parity test with the HTML renderer: the two tables must
773
+ * cover exactly the same block types. */
774
+ export const BLOCK_RENDERERS = {
775
+ para: addPara,
776
+ heading: addHeading,
777
+ bullets: addBullets,
778
+ code: addCode,
779
+ table: addTable,
780
+ alert: addAlert,
781
+ metric: addMetric,
782
+ quote: addQuote,
783
+ image: addImage,
784
+ mermaid: addMermaid,
785
+ icon: addIcon,
786
+ math: addMath,
787
+ chart: addChartBlock,
788
+ panel: addPanel,
789
+ 'timeline-axis': addTimelineAxis,
790
+ 'timeline-dot': addTimelineDot,
791
+ };
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // Slide chrome (masters)
795
+ // ---------------------------------------------------------------------------
796
+
797
+ // ---- Titles: an OOXML placeholder rather than a floating text box ---------
798
+ //
799
+ // A title placed with an ordinary `addText` is, for PowerPoint, only one text
800
+ // box among others: the accessibility checker reports "missing slide title" on
801
+ // EVERY slide, Outline view stays empty (so no navigation and no reordering by
802
+ // title) and screen readers lose the main mechanism for announcing a slide.
803
+ // The title must therefore be a real `<p:ph type="title"/>` placeholder,
804
+ // declared in the master and filled by `placeholder: 'title'`.
805
+ //
806
+ // PptxGenJS makes the text INHERIT the geometry of the placeholder: the boxes
807
+ // below are therefore the single source, shared word for word between the
808
+ // declaration in the master and the call that fills it — that is what
809
+ // guarantees that the move to the placeholder does not shift the title by a
810
+ // single pixel. They are computed at call time, never frozen at module load:
811
+ // the design tokens of the theme (PAGE, SPACE, CHROME) are living objects that
812
+ // resolveTheme() rewrites.
813
+
814
+ const contentTitleBox = () => ({
815
+ x: px(PAGE.margin),
816
+ y: px(SPACE.lg),
817
+ w: px(PAGE.width - 2 * PAGE.margin),
818
+ h: px(PAGE.titleHeight - SPACE.lg - 8),
819
+ });
820
+
821
+ const coverTitleBox = () => ({
822
+ x: px(PAGE.margin),
823
+ y: px(CHROME.cover.titleY),
824
+ w: px(PAGE.width - 2 * PAGE.margin),
825
+ h: px(CHROME.cover.titleH),
826
+ });
827
+
828
+ const sectionTitleBox = () => ({
829
+ x: px(PAGE.margin),
830
+ y: px(CHROME.section.titleY),
831
+ w: px(PAGE.width - 2 * PAGE.margin),
832
+ h: px(CHROME.section.titleH),
833
+ });
834
+
835
+ /** Declaration of the title placeholder of a master. `name` is the key that
836
+ * `addText({ placeholder: 'title' })` comes looking for; `type: 'title'` is
837
+ * what produces the `<p:ph type="title"/>`. No typographic property here:
838
+ * the options of the placeholder OVERRIDE those of the caller in PptxGenJS,
839
+ * and it is the theme, at call time, that must decide the font and color. */
840
+ const titlePlaceholder = (box) => ({
841
+ placeholder: { options: { name: 'title', type: 'title', objectName: 'Title', ...box }, text: '' },
842
+ });
843
+
844
+ function defineMasters(pptx, meta) {
845
+ const footerText = meta.footer ?? meta.title ?? '';
846
+ pptx.defineSlideMaster({
847
+ title: 'DECK_CONTENT',
848
+ background: { color: COLORS.ground },
849
+ objects: [
850
+ titlePlaceholder(contentTitleBox()),
851
+ // title rule: green segment (the single accent) then a neutral rule
852
+ {
853
+ rect: {
854
+ x: px(PAGE.margin),
855
+ y: px(PAGE.titleHeight),
856
+ w: px(CHROME.title.accentW),
857
+ h: px(CHROME.title.accentH),
858
+ fill: { color: COLORS.primary },
859
+ },
860
+ },
861
+ {
862
+ rect: {
863
+ x: px(PAGE.margin + CHROME.title.accentW),
864
+ y: px(PAGE.titleHeight + 1.5),
865
+ w: px(PAGE.width - 2 * PAGE.margin - CHROME.title.accentW),
866
+ h: px(1),
867
+ fill: { color: COLORS.neutralStroke },
868
+ },
869
+ },
870
+ {
871
+ text: {
872
+ text: footerText,
873
+ options: {
874
+ x: px(PAGE.margin),
875
+ y: px(PAGE.height - PAGE.footerHeight),
876
+ w: px(CHROME.footer.textW),
877
+ h: px(CHROME.footer.h),
878
+ fontSize: TYPE.caption,
879
+ color: COLORS.neutralSecondary,
880
+ fontFace: FONTS.body,
881
+ valign: 'middle',
882
+ },
883
+ },
884
+ },
885
+ ],
886
+ slideNumber: {
887
+ x: px(PAGE.width - PAGE.margin - CHROME.footer.numW),
888
+ y: px(PAGE.height - PAGE.footerHeight),
889
+ w: px(CHROME.footer.numW),
890
+ h: px(CHROME.footer.h),
891
+ fontSize: TYPE.caption,
892
+ color: COLORS.neutralSecondary,
893
+ fontFace: FONTS.body,
894
+ align: 'right',
895
+ },
896
+ });
897
+ pptx.defineSlideMaster({
898
+ title: 'DECK_COVER',
899
+ background: { color: COLORS.ground },
900
+ objects: [titlePlaceholder(coverTitleBox())],
901
+ });
902
+ pptx.defineSlideMaster({
903
+ title: 'DECK_SECTION',
904
+ background: { color: COLORS.primary },
905
+ objects: [titlePlaceholder(sectionTitleBox())],
906
+ });
907
+ }
908
+
909
+ function renderCover(pptx, scene) {
910
+ const s = pptx.addSlide({ masterName: 'DECK_COVER' });
911
+ const c = CHROME.cover;
912
+ // The title is written FIRST, as on any other slide: the order of the
913
+ // spTree is the reading order of screen readers (and the one assumed by the
914
+ // `!!title-N` renaming in morph.mjs, which renames the first shape). The
915
+ // logo and the rule, decorative, come afterwards; neither of them covers the
916
+ // title (rule at 280..286, title from 304 on), so the z rank changes nothing
917
+ // in the image.
918
+ s.addText(scene.title ?? '', {
919
+ placeholder: 'title',
920
+ ...coverTitleBox(),
921
+ fontSize: TYPE.coverTitle,
922
+ bold: true,
923
+ color: COLORS.neutralPrimary,
924
+ fontFace: FONTS.body,
925
+ valign: 'top',
926
+ });
927
+ if (LOGOS.cover && fs.existsSync(LOGOS.cover)) {
928
+ const img = logoImage(LOGOS.cover, c.logoH, PAGE.margin, PAGE.margin);
929
+ if (img) s.addImage(img);
930
+ }
931
+ s.addShape('rect', {
932
+ x: px(PAGE.margin),
933
+ y: px(c.barY),
934
+ w: px(c.barW),
935
+ h: px(c.barH),
936
+ fill: { color: COLORS.primary },
937
+ objectName: 'Accent rule',
938
+ });
939
+ if (scene.subtitle) {
940
+ s.addText(scene.subtitle, {
941
+ x: px(PAGE.margin),
942
+ y: px(c.subtitleY),
943
+ w: px(PAGE.width - 2 * PAGE.margin),
944
+ h: px(c.subtitleH),
945
+ fontSize: TYPE.coverSubtitle,
946
+ color: COLORS.neutralSecondary,
947
+ fontFace: FONTS.body,
948
+ valign: 'top',
949
+ objectName: 'Subtitle',
950
+ });
951
+ }
952
+ if (scene.byline) {
953
+ s.addText(scene.byline, {
954
+ x: px(PAGE.margin),
955
+ y: px(PAGE.height - c.bylineBottom),
956
+ w: px(PAGE.width - 2 * PAGE.margin),
957
+ h: px(c.bylineH),
958
+ fontSize: TYPE.small,
959
+ color: COLORS.neutralSecondary,
960
+ fontFace: FONTS.body,
961
+ valign: 'middle',
962
+ objectName: 'Byline',
963
+ });
964
+ }
965
+ return s;
966
+ }
967
+
968
+ function renderSection(pptx, scene) {
969
+ const s = pptx.addSlide({ masterName: 'DECK_SECTION' });
970
+ const c = CHROME.section;
971
+ s.addText(scene.title ?? '', {
972
+ placeholder: 'title',
973
+ ...sectionTitleBox(),
974
+ fontSize: TYPE.sectionTitle,
975
+ bold: true,
976
+ color: COLORS.ground,
977
+ fontFace: FONTS.body,
978
+ valign: 'middle',
979
+ });
980
+ if (LOGOS.section && fs.existsSync(LOGOS.section)) {
981
+ const img = logoImage(LOGOS.section, c.logoH, PAGE.margin, PAGE.height - PAGE.margin - c.logoH);
982
+ if (img) s.addImage(img);
983
+ }
984
+ return s;
985
+ }
986
+
987
+ // ---------------------------------------------------------------------------
988
+ // Canonical form of the title placeholder (post-processing)
989
+ // ---------------------------------------------------------------------------
990
+
991
+ /**
992
+ * Brings the title placeholder back to the form PowerPoint itself writes.
993
+ *
994
+ * PptxGenJS produces `<p:ph idx="100" type="title" hasCustomPrompt="1"/>` in
995
+ * a `<p:sp>` whose `<p:cNvSpPr/>` is empty. Three departures from an authentic
996
+ * title, none of which can be caught on the API side — genXmlPlaceholder()
997
+ * serializes `_placeholderIdx` (set to `100 + rank` for EVERY master object)
998
+ * and infers `hasCustomPrompt` from the mere presence of a runs array, whether
999
+ * that array is empty or not. Hence this pass over the zip.
1000
+ *
1001
+ * 1. `idx` — ECMA-376 (ISO/IEC 29500-1, §19.3.1.36): "Specifies the
1002
+ * placeholder index. This is used when applying templates or changing
1003
+ * layouts to match a placeholder on one template/master to another."
1004
+ * That is the mechanism of BODY placeholders, which come in numbers and
1005
+ * therefore have to be numbered; the title, a singleton, is matched by its
1006
+ * `type` and an absent `idx` counts as 0. No file written by PowerPoint
1007
+ * carries an `idx` on a title — including the notesMaster that PptxGenJS
1008
+ * ships as is, copied from a .pptx of PowerPoint origin, where the first
1009
+ * placeholder of each type is `<p:ph type="hdr" sz="quarter"/>`, with no
1010
+ * `idx`. The attribute remains legal per the schema: what we are fixing is
1011
+ * the use of the wrong mechanism, not an invalid file.
1012
+ * 2. `<a:spLocks noGrp="1"/>` — that same notesMaster carries it in the
1013
+ * `<p:cNvSpPr>` of EACH of its six placeholders. It forbids grouping the
1014
+ * shape, which would take it out of its role as a placeholder.
1015
+ * 3. `hasCustomPrompt="1"` announces a custom prompt ("Click to add title"
1016
+ * replaced). Our masters declare none: the `txBody` of the placeholder is
1017
+ * empty. The attribute lies, so it goes.
1018
+ *
1019
+ * Slides AND layouts are processed: matching is now done by `type` on both
1020
+ * sides, and leaving the `idx` on one side alone would reopen the question.
1021
+ *
1022
+ * Unexpected structure → the part is left as is, and we say so.
1023
+ *
1024
+ * @param {string} pptxPath path of the .pptx to modify in place
1025
+ * @returns {Promise<{count:number, warnings:string[]}>} normalized placeholders
1026
+ */
1027
+ async function canonicalizeTitlePlaceholders(pptxPath) {
1028
+ const warnings = [];
1029
+ const zip = await JSZip.loadAsync(fs.readFileSync(pptxPath));
1030
+ // the `<p:ph>` never contains a `>` before it closes: `[^>]*` is enough and
1031
+ // avoids swallowing the next shape
1032
+ const pattern = /<p:cNvSpPr\s*\/>(\s*<p:nvPr>\s*)<p:ph\b[^>]*\stype="title"[^>]*\/>/g;
1033
+ // replacer as a function (we have to count): `$1` no longer means anything
1034
+ // here — the captured group comes in as the callback argument
1035
+ const canonical = (nvPr) =>
1036
+ `<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>${nvPr}<p:ph type="title"/>`;
1037
+
1038
+ let count = 0;
1039
+ const parts = Object.keys(zip.files).filter((n) =>
1040
+ /^ppt\/(slides\/slide|slideLayouts\/slideLayout)\d+\.xml$/.test(n),
1041
+ );
1042
+ for (const part of parts) {
1043
+ const xml = await zip.file(part).async('string');
1044
+ // a part with no title is normal (the slide of a layout with no
1045
+ // placeholder): only a title we DO NOT KNOW how to normalize is worth
1046
+ // giving up on
1047
+ const titles = (xml.match(/<p:ph\b[^>]*\stype="title"[^>]*\/>/g) ?? []).length;
1048
+ let normalized = 0;
1049
+ const fresh = xml.replace(pattern, (_all, nvPr) => {
1050
+ normalized += 1;
1051
+ return canonical(nvPr);
1052
+ });
1053
+ if (normalized < titles) {
1054
+ warnings.push(`${part}: title placeholder with an unexpected structure — left as is`);
1055
+ continue; // atomic edit per part: we write none of them by halves
1056
+ }
1057
+ count += normalized;
1058
+ if (fresh !== xml) zip.file(part, fresh);
1059
+ }
1060
+ if (count)
1061
+ fs.writeFileSync(
1062
+ pptxPath,
1063
+ await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
1064
+ );
1065
+ return { count, warnings };
1066
+ }
1067
+
1068
+ // ---------------------------------------------------------------------------
1069
+ // Outline titles in docProps/app.xml (post-processing)
1070
+ // ---------------------------------------------------------------------------
1071
+
1072
+ const escXml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1073
+
1074
+ /**
1075
+ * Writes the real titles into `docProps/app.xml`.
1076
+ *
1077
+ * PptxGenJS hard-codes "Slide 1", "Slide 2"… in there, whatever the slides
1078
+ * actually carry. Yet THAT cache is the one PowerPoint reads to present a deck
1079
+ * without opening it (preview, properties, the slide list behind a link):
1080
+ * correct titles on the slides but numeric ones here, and the deck still
1081
+ * presents itself as a run of "Slide N". A slide with no title keeps the
1082
+ * default entry — we do not invent a title in its place.
1083
+ *
1084
+ * Unexpected structure → the file is left as is, and we say so.
1085
+ *
1086
+ * @param {string} pptxPath path of the .pptx to modify in place
1087
+ * @param {Array<string|null>} titles title of each slide, in order
1088
+ * @returns {Promise<{count:number, warnings:string[]}>}
1089
+ */
1090
+ async function embedSlideTitles(pptxPath, titles) {
1091
+ const warnings = [];
1092
+ const named = titles.filter(Boolean).length;
1093
+ if (!named) return { count: 0, warnings };
1094
+
1095
+ const zip = await JSZip.loadAsync(fs.readFileSync(pptxPath));
1096
+ const part = 'docProps/app.xml';
1097
+ const file = zip.file(part);
1098
+ if (!file)
1099
+ return { count: 0, warnings: [`${part} absent from the .pptx — outline titles unchanged`] };
1100
+
1101
+ const xml = await file.async('string');
1102
+ const block = xml.match(/<TitlesOfParts>[\s\S]*?<\/TitlesOfParts>/);
1103
+ const entries = block
1104
+ ? [...block[0].matchAll(/<vt:lpstr>[\s\S]*?<\/vt:lpstr>/g)].map((m) => m[0])
1105
+ : [];
1106
+ // the slides occupy the END of the list; the head (fonts, theme) is not ours
1107
+ // and goes back out as it came
1108
+ if (entries.length < titles.length) {
1109
+ return {
1110
+ count: 0,
1111
+ warnings: [`${part}: unexpected structure — outline titles unchanged`],
1112
+ };
1113
+ }
1114
+ const head = entries.slice(0, entries.length - titles.length);
1115
+ const tail = titles.map((t, k) =>
1116
+ t ? `<vt:lpstr>${escXml(t)}</vt:lpstr>` : entries[head.length + k],
1117
+ );
1118
+ const fresh = `<TitlesOfParts><vt:vector size="${entries.length}" baseType="lpstr">${[...head, ...tail].join('')}</vt:vector></TitlesOfParts>`;
1119
+
1120
+ zip.file(part, xml.replace(block[0], fresh));
1121
+ fs.writeFileSync(
1122
+ pptxPath,
1123
+ await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
1124
+ );
1125
+ return { count: named, warnings };
1126
+ }
1127
+
1128
+ // ---------------------------------------------------------------------------
1129
+ // Entry point
1130
+ // ---------------------------------------------------------------------------
1131
+
1132
+ /**
1133
+ * @param {Array} scenes scenes produced by buildScenes()
1134
+ * @param {object} meta frontmatter of the deck
1135
+ * @param {string} baseDir directory of the source file (image resolution)
1136
+ * @param {string} outPath path of the .pptx
1137
+ * @param {object} [opts] `vendor` forces remote images to be copied into the
1138
+ * project (CLI flag; otherwise `assets:` in the frontmatter)
1139
+ */
1140
+ export async function renderDeck(scenes, meta, baseDir, outPath, opts = {}) {
1141
+ let tmpDir = null;
1142
+ const tmp = () => (tmpDir ??= fs.mkdtempSync(path.join(os.tmpdir(), 'lutrin-')));
1143
+ try {
1144
+ return await renderDeckTo(scenes, meta, baseDir, outPath, tmp, opts);
1145
+ } finally {
1146
+ // the temporary PNGs (icons, equations, charts) are read at the moment the
1147
+ // .pptx is written: we only clean up afterwards — but we always clean up,
1148
+ // even on error (otherwise every export leaks a directory)
1149
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
1150
+ }
1151
+ }
1152
+
1153
+ async function renderDeckTo(scenes, meta, baseDir, outPath, tmp, opts = {}) {
1154
+ const vendor = vendorRemoteAssets(meta, opts.vendor);
1155
+ const pptx = new PptxGenJS();
1156
+ pptx.layout = 'LAYOUT_WIDE'; // 13.33 × 7.5 in = 1280 × 720 px
1157
+ pptx.author = meta.author ?? '';
1158
+ pptx.title = meta.title ?? '';
1159
+ pptx.theme = { headFontFace: FONTS.body, bodyFontFace: FONTS.body };
1160
+ defineMasters(pptx, meta);
1161
+
1162
+ // ------ pre-pass: everything that requires asynchronous work --------------
1163
+ // (Mermaid, downloading the remote images, Lucide icons, equations)
1164
+ const allBlocks = scenes.flatMap((sc) => [
1165
+ ...sc.elements.map((e) => e.block),
1166
+ ...(sc.image ? [sc.image] : []), // image of the hero layout, outside the elements
1167
+ ]);
1168
+ const ofType = (t) => allBlocks.filter((b) => b.type === t);
1169
+
1170
+ // Mermaid (optional, persistent cache)
1171
+ const mermaidBlocks = ofType('mermaid');
1172
+ const mermaid = new Map();
1173
+ for (const b of mermaidBlocks) {
1174
+ const png = renderMermaidCached(b.source, { baseDir });
1175
+ if (png) mermaid.set(b, png);
1176
+ }
1177
+
1178
+ // Remote images → user cache, or assets/remote/ if the deck vendors them
1179
+ const remote = new Map();
1180
+ const remoteUrls = [
1181
+ ...new Set(
1182
+ ofType('image')
1183
+ .map((b) => b.src)
1184
+ .filter((s) => /^https?:/.test(s)),
1185
+ ),
1186
+ ];
1187
+ await Promise.all(
1188
+ remoteUrls.map(async (url) => {
1189
+ const local = await fetchRemoteImage(url, baseDir, { vendor });
1190
+ if (local) remote.set(url, local);
1191
+ }),
1192
+ );
1193
+
1194
+ // Lucide icons → recolored PNG. A missing icon does not break the slide
1195
+ // (it is simply omitted), but the author has to find out: with no
1196
+ // diagnostic, a typo in an icon name only shows up when the .pptx is
1197
+ // proofread, once it has already gone out.
1198
+ const icons = new Map();
1199
+ const iconBlocks = ofType('icon');
1200
+ // filed by index, not piled up as they come: these renders finish in an
1201
+ // arbitrary order, and diagnostics that change order from one export to the
1202
+ // next are a plague — for the author as much as for the tests
1203
+ const iconWarnings = new Array(iconBlocks.length);
1204
+ await Promise.all(
1205
+ iconBlocks.map(async (b, k) => {
1206
+ const out = await renderIcon(b.name, { color: b.color });
1207
+ if (!out) {
1208
+ iconWarnings[k] =
1209
+ `Icon "${iconLabel(b.name)}" not found — name unknown to Lucide, or the lucide-static package is absent and there is no network. The slide is rendered without it.`;
1210
+ return;
1211
+ }
1212
+ icons.set(b, writeTmpPng(tmp(), `icon-${k}-${iconSlug(b.name)}`, out.png));
1213
+ }),
1214
+ );
1215
+ const assetWarnings = iconWarnings.filter(Boolean);
1216
+
1217
+ // LaTeX equations → PNG (MathJax, code fallback if absent)
1218
+ const math = new Map();
1219
+ await Promise.all(
1220
+ ofType('math').map(async (b, k) => {
1221
+ const out = await renderMath(b.source);
1222
+ if (out)
1223
+ math.set(b, {
1224
+ path: writeTmpPng(tmp(), `math-${k}`, out.png),
1225
+ displayW: out.displayW,
1226
+ displayH: out.displayH,
1227
+ });
1228
+ }),
1229
+ );
1230
+
1231
+ // Charts → SVG at the dimensions of the slot, rasterized at 2× for sharpness
1232
+ const charts = new Map();
1233
+ const chartEls = scenes.flatMap((sc) => sc.elements.filter((e) => e.block.type === 'chart'));
1234
+ await Promise.all(
1235
+ chartEls.map(async (e, k) => {
1236
+ const svg = chartSvg(e.block, e.region.w, e.region.h);
1237
+ const out = await svgToPng(svg, e.region.w * 2);
1238
+ if (out) charts.set(e.block, writeTmpPng(tmp(), `chart-${k}`, out.png));
1239
+ }),
1240
+ );
1241
+
1242
+ // Rasterizer absent while the deck depends on it: SAY SO.
1243
+ //
1244
+ // Without this diagnostic, the export stays an apparent success — exit
1245
+ // code 0, "✓ N slides" — and the charts, equations and icons are replaced
1246
+ // by their specification in text, which the author only discovers in the
1247
+ // meeting. The case is not theoretical: @resvg/resvg-js ships its binaries
1248
+ // as twelve optionalDependencies and npm installs only the one for the
1249
+ // current platform, so a VSIX built on macOS embeds a truncated
1250
+ // `dist/core` once it is installed under Windows.
1251
+ //
1252
+ // Severity `error`: the deliverable is truncated, not merely imperfect. The
1253
+ // fallback itself stays in place (a readable slide beats a hole) — what is
1254
+ // fixed here is the silence.
1255
+ const diagnostics = [];
1256
+ const rasterBlocks = chartEls.length + ofType('math').length + iconBlocks.length;
1257
+ if (rasterBlocks && !(await rasterAvailable())) {
1258
+ diagnostics.push({
1259
+ severity: 'error',
1260
+ code: 'RASTER_UNAVAILABLE',
1261
+ message: `Rasterizer @resvg/resvg-js unavailable — ${rasterBlocks} chart(s), equation(s) or icon(s) are replaced by their specification in text in the .pptx. Reinstall the dependencies on this platform (\`npm install\` in the lutrin package) to restore graphical rendering.`,
1262
+ });
1263
+ }
1264
+
1265
+ // trust roots of the local images: directory of the deck + project/vault
1266
+ // roots declared by the host (containment — assets.mjs)
1267
+ const imageRoots = [baseDir, ...(opts.imageRoots ?? [])];
1268
+ const ctx = { baseDir, imageRoots, mermaid, remote, icons, math, charts };
1269
+
1270
+ const slideAnims = new Map(); // slide no. (1-based) → log of the shapes
1271
+ scenes.forEach((scene, sceneIdx) => {
1272
+ let slide;
1273
+ if (scene.master === 'cover') slide = renderCover(pptx, scene);
1274
+ else if (scene.master === 'section') slide = renderSection(pptx, scene);
1275
+ else {
1276
+ slide = pptx.addSlide({ masterName: 'DECK_CONTENT' });
1277
+ // animated slide: log every shape written (chrome included, as null)
1278
+ const rec = scene.animSteps ? [] : null;
1279
+ let cur = null;
1280
+ let shapeLabel = 'Content';
1281
+ const target = wrapSlide(slide, { label: () => shapeLabel, rec, current: () => cur });
1282
+ if (scene.master === 'hero' && scene.image) {
1283
+ addImage(target, scene.image, { x: 0, y: 0, w: PAGE.width, h: PAGE.height }, ctx);
1284
+ }
1285
+ // The title placeholder is written EVEN on a slide with no title:
1286
+ // failing that PptxGenJS adds it itself, empty, at the END of the
1287
+ // spTree — and that shape, absent from the `rec` log, would shift the
1288
+ // shapes ↔ animations pairing of anim.mjs (which demands an exact count
1289
+ // and would give up).
1290
+ //
1291
+ // ACCEPTED LIMITATION. On those slides the placeholder goes out with an
1292
+ // empty `txBody`, and PowerPoint's accessibility checker counts an empty
1293
+ // title as a MISSING title: the benefit is obtained only on the slides
1294
+ // that are actually titled. The two ways out have been weighed and ruled
1295
+ // out: inventing a substitute title ("Slide 4") lies to the screen
1296
+ // reader as much as to Outline view, and that is exactly what
1297
+ // embedSlideTitles() already refuses to do for docProps/app.xml; marking
1298
+ // the shape decorative (`adec:decorative`) takes it out of the reading
1299
+ // order without thereby satisfying the checker's "slide title" rule,
1300
+ // which questions the placeholder, not the reading order. A slide with
1301
+ // no title therefore stays reported — which is the truth: it has no
1302
+ // title. The remedy is in the Markdown source, not in the export.
1303
+ shapeLabel = 'Title';
1304
+ target.addText(
1305
+ scene.titleRuns ? toRuns(scene.titleRuns, { bold: true }) : (scene.title ?? ''),
1306
+ {
1307
+ placeholder: 'title',
1308
+ ...contentTitleBox(),
1309
+ fontSize: TYPE.slideTitle,
1310
+ bold: true,
1311
+ color: COLORS.neutralPrimary,
1312
+ fontFace: FONTS.body,
1313
+ valign: 'middle',
1314
+ },
1315
+ );
1316
+ for (const el of scene.elements) {
1317
+ // kind → choice of the entrance effect (anim.mjs, PRESET_BY_KIND)
1318
+ cur = el.step != null ? { step: el.step, paras: el.stepCount, kind: el.block.type } : null;
1319
+ shapeLabel = SHAPE_LABELS[el.block.type] ?? 'Content';
1320
+ const fn = BLOCK_RENDERERS[el.block.type];
1321
+ if (fn) fn(target, el.block, el.region, ctx);
1322
+ }
1323
+ if (rec?.some(Boolean))
1324
+ slideAnims.set(sceneIdx + 1, { entries: rec, preset: scene.animPreset ?? null });
1325
+ }
1326
+ if (scene.notes?.length) slide.addNotes(scene.notes.join('\n'));
1327
+ });
1328
+
1329
+ // pagination chains: [original slide, …(cont.)] as 1-based numbers — each
1330
+ // "(cont.)" gets the Morph transition
1331
+ const chains = [];
1332
+ scenes.forEach((s, i) => {
1333
+ // with no title, the first shape is not a title: the !!title renaming
1334
+ // would pair two different content blocks — no Morph in that case
1335
+ if (!s.continued || !s.title) return;
1336
+ const last = chains[chains.length - 1];
1337
+ if (last && last[last.length - 1] === i) last.push(i + 1);
1338
+ else chains.push([i, i + 1]);
1339
+ });
1340
+
1341
+ await pptx.writeFile({ fileName: outPath });
1342
+ const phTitles = await canonicalizeTitlePlaceholders(outPath);
1343
+ const titles = await embedSlideTitles(
1344
+ outPath,
1345
+ scenes.map((s) => s.title ?? null),
1346
+ );
1347
+ const fonts = await embedFonts(outPath);
1348
+ const morph = await embedMorph(outPath, chains);
1349
+ const anims = await embedAnimations(outPath, slideAnims);
1350
+ return {
1351
+ slideCount: scenes.length,
1352
+ titledSlides: titles.count,
1353
+ fontsEmbedded: fonts.count,
1354
+ animatedSlides: anims.count,
1355
+ morphSlides: morph.count,
1356
+ // the structured diagnostics ALSO travel as warnings: that is the only
1357
+ // channel the CLI prints today, and a diagnostic we do not display is no
1358
+ // better than the silence it corrects
1359
+ diagnostics,
1360
+ warnings: [
1361
+ ...diagnostics.map((d) => d.message),
1362
+ ...assetWarnings,
1363
+ ...phTitles.warnings,
1364
+ ...titles.warnings,
1365
+ ...fonts.warnings,
1366
+ ...morph.warnings,
1367
+ ...anims.warnings,
1368
+ ],
1369
+ mermaidRendered: mermaid.size,
1370
+ mermaidTotal: mermaidBlocks.length,
1371
+ remoteFetched: remote.size,
1372
+ remoteTotal: remoteUrls.length,
1373
+ remoteVendored: vendor,
1374
+ iconsRendered: icons.size,
1375
+ iconsTotal: iconBlocks.length,
1376
+ mathRendered: math.size,
1377
+ mathTotal: ofType('math').length,
1378
+ };
1379
+ }