@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.
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -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: `` 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
|
+
* `` 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|