@entropicwarrior/sdoc 0.2.18 → 0.2.20

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,599 @@
1
+ // SDOC Slides — PowerPoint / Google Slides export.
2
+ //
3
+ // Turns harvested slide geometry (src/slide-geometry.js) into a .pptx. Drive
4
+ // imports a .pptx as a native Google Slides deck, which is why this is the
5
+ // export route rather than the Slides REST API: no OAuth in the toolchain, no
6
+ // per-shape API calls, and the output is a useful artefact on its own.
7
+ //
8
+ // Fidelity comes from the harvest, not from this file. The browser has already
9
+ // decided where every box, line and run of text sits; here each of those
10
+ // becomes one absolutely positioned shape. That is also how a designed deck is
11
+ // built by hand — every slide is loose shapes on one blank layout — so the
12
+ // exported file matches the kind of file a designer would hand over, and stays
13
+ // editable in Slides.
14
+ //
15
+ // Zero dependencies: the OOXML is written out directly and src/zip.js packages
16
+ // it with Node's zlib.
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const { zip, crc32 } = require("./zip");
21
+
22
+ const EMU_PER_PX = 9525; // 914400 EMU per inch / 96 px per inch
23
+ const PT_PER_PX = 0.75; // 72 pt per inch / 96 px per inch
24
+
25
+ const NS =
26
+ 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
27
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
28
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
29
+
30
+ function esc(text) {
31
+ return String(text)
32
+ .replace(/&/g, "&")
33
+ .replace(/</g, "&lt;")
34
+ .replace(/>/g, "&gt;")
35
+ .replace(/"/g, "&quot;")
36
+ // Control characters are not legal in XML 1.0 and will not open.
37
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
38
+ }
39
+
40
+ const px = (v) => Math.round(v * EMU_PER_PX);
41
+ const pt100 = (v) => Math.round(v * PT_PER_PX * 100);
42
+
43
+ // Generic font-family keywords never name a real face; fall back to something
44
+ // PowerPoint and Slides both resolve.
45
+ const GENERIC_FAMILIES = new Set([
46
+ "serif", "sans-serif", "monospace", "cursive", "fantasy",
47
+ "system-ui", "ui-monospace", "ui-sans-serif", "ui-serif", "ui-rounded",
48
+ "-apple-system", "BlinkMacSystemFont",
49
+ ]);
50
+
51
+ function fontName(name, fallback) {
52
+ if (!name || GENERIC_FAMILIES.has(name)) return fallback;
53
+ return name;
54
+ }
55
+
56
+ function solidFill(hex, alpha) {
57
+ const colour = `<a:srgbClr val="${(hex || "FFFFFF").replace("#", "").toUpperCase()}"${
58
+ alpha !== undefined && alpha < 1 ? `><a:alpha val="${Math.round(alpha * 100000)}"/></a:srgbClr>` : "/>"
59
+ }`;
60
+ return `<a:solidFill>${colour}</a:solidFill>`;
61
+ }
62
+
63
+ function xfrm(box) {
64
+ return (
65
+ `<a:xfrm><a:off x="${px(box.x)}" y="${px(box.y)}"/>` +
66
+ `<a:ext cx="${px(Math.max(box.w, 1))}" cy="${px(Math.max(box.h, 1))}"/></a:xfrm>`
67
+ );
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Shapes
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function rectShape(id, name, box, opts = {}) {
75
+ const geom = opts.radius
76
+ ? `<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val ${Math.min(
77
+ 50000,
78
+ Math.round((opts.radius / Math.max(1, Math.min(box.w, box.h))) * 100000)
79
+ )}"/></a:avLst></a:prstGeom>`
80
+ : `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>`;
81
+
82
+ const fill = opts.fill ? solidFill(opts.fill, opts.fillAlpha) : "<a:noFill/>";
83
+ const line = opts.line
84
+ ? `<a:ln w="${Math.max(1, Math.round(opts.line.width * PT_PER_PX * 12700))}">${solidFill(
85
+ opts.line.colour,
86
+ opts.line.alpha
87
+ )}</a:ln>`
88
+ : "<a:ln><a:noFill/></a:ln>";
89
+
90
+ return (
91
+ `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${esc(name)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
92
+ `<p:spPr>${xfrm(box)}${geom}${fill}${line}</p:spPr>` +
93
+ `<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>`
94
+ );
95
+ }
96
+
97
+ const ALIGN = { left: "l", start: "l", center: "ctr", right: "r", end: "r", justify: "just" };
98
+
99
+ function textShape(id, name, atom, fallbacks) {
100
+ const runs = atom.runs
101
+ .map((run) => {
102
+ const props =
103
+ `<a:rPr lang="en-US" sz="${Math.round(run.size * PT_PER_PX * 100)}"` +
104
+ (run.weight >= 600 ? ' b="1"' : "") +
105
+ (run.italic ? ' i="1"' : "") +
106
+ (run.spacing ? ` spc="${Math.round(run.spacing * PT_PER_PX * 100)}"` : "") +
107
+ ` dirty="0">` +
108
+ solidFill(run.color) +
109
+ `<a:latin typeface="${esc(fontName(run.font, fallbacks.body))}"/>` +
110
+ `<a:cs typeface="${esc(fontName(run.font, fallbacks.body))}"/>` +
111
+ `</a:rPr>`;
112
+ return `<a:r>${props}<a:t>${esc(run.text)}</a:t></a:r>`;
113
+ })
114
+ .join("");
115
+
116
+ // Exact line spacing, so a line box in the export is the line box the
117
+ // browser laid out. Autofit is off: the harvest already sized the box.
118
+ const spacing = atom.lineHeight ? `<a:lnSpc><a:spcPts val="${pt100(atom.lineHeight)}"/></a:lnSpc>` : "";
119
+ const align = ALIGN[atom.align] ? ` algn="${ALIGN[atom.align]}"` : "";
120
+
121
+ return (
122
+ `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${esc(name)}"/>` +
123
+ `<p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>` +
124
+ `<p:spPr>${xfrm(atom.box)}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>` +
125
+ `<p:txBody>` +
126
+ `<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"><a:noAutofit/></a:bodyPr>` +
127
+ `<a:lstStyle/>` +
128
+ `<a:p><a:pPr${align}>${spacing}</a:pPr>${runs}</a:p>` +
129
+ `</p:txBody></p:sp>`
130
+ );
131
+ }
132
+
133
+ function pictureShape(id, name, box, relId) {
134
+ return (
135
+ `<p:pic><p:nvPicPr><p:cNvPr id="${id}" name="${esc(name)}"/>` +
136
+ `<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>` +
137
+ `<p:blipFill><a:blip r:embed="${relId}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>` +
138
+ `<p:spPr>${xfrm(box)}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`
139
+ );
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Images
144
+ // ---------------------------------------------------------------------------
145
+
146
+ const IMAGE_TYPES = {
147
+ ".png": "image/png",
148
+ ".jpg": "image/jpeg",
149
+ ".jpeg": "image/jpeg",
150
+ ".gif": "image/gif",
151
+ ".svg": "image/svg+xml",
152
+ ".webp": "image/webp",
153
+ };
154
+
155
+ // Resolves an <img src> to bytes: a data: URI, or a file beside the deck.
156
+ function readImage(src, baseDir) {
157
+ if (!src) return null;
158
+ const dataMatch = src.match(/^data:([^;,]+)(;base64)?,(.*)$/s);
159
+ if (dataMatch) {
160
+ const mime = dataMatch[1];
161
+ const data = dataMatch[2]
162
+ ? Buffer.from(dataMatch[3], "base64")
163
+ : Buffer.from(decodeURIComponent(dataMatch[3]), "utf-8");
164
+ const ext = Object.keys(IMAGE_TYPES).find((e) => IMAGE_TYPES[e] === mime) || ".png";
165
+ return { data, ext, mime };
166
+ }
167
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return null; // remote: nothing to embed
168
+ const filePath = path.resolve(baseDir, decodeURIComponent(src.replace(/[?#].*$/, "")));
169
+ if (!fs.existsSync(filePath)) return null;
170
+ const ext = path.extname(filePath).toLowerCase();
171
+ if (!IMAGE_TYPES[ext]) return null;
172
+ return { data: fs.readFileSync(filePath), ext, mime: IMAGE_TYPES[ext] };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Package boilerplate
177
+ // ---------------------------------------------------------------------------
178
+
179
+ const XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
180
+
181
+ // The OOXML colour scheme. Every visible colour in an exported deck is written
182
+ // literally on its own shape, harvested from the CSS, so this scheme is only
183
+ // what PowerPoint offers in its colour picker once the file is open. It is
184
+ // deliberately neutral: a theme's palette belongs in the theme's CSS, not
185
+ // baked into the exporter.
186
+ const SCHEME_COLOURS = {
187
+ dk2: "44546A",
188
+ lt2: "E7E6E6",
189
+ accent1: "4472C4",
190
+ accent2: "ED7D31",
191
+ accent3: "A5A5A5",
192
+ accent4: "FFC000",
193
+ accent5: "5B9BD5",
194
+ accent6: "70AD47",
195
+ hlink: "0563C1",
196
+ folHlink: "954F72",
197
+ };
198
+
199
+ function themeXml(fonts) {
200
+ const scheme = (name, hex) => `<a:${name}><a:srgbClr val="${hex}"/></a:${name}>`;
201
+ const fill =
202
+ '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>';
203
+ const fillStyles = `<a:fillStyleLst>${fill}${fill}${fill}</a:fillStyleLst>`;
204
+ const lineStyle =
205
+ '<a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>';
206
+ return (
207
+ XML_DECL +
208
+ `<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="SDOC">` +
209
+ `<a:themeElements><a:clrScheme name="SDOC">` +
210
+ `<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>` +
211
+ `<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>` +
212
+ Object.entries(SCHEME_COLOURS)
213
+ .map(([name, hex]) => scheme(name, hex))
214
+ .join("") +
215
+ `</a:clrScheme>` +
216
+ `<a:fontScheme name="SDOC">` +
217
+ `<a:majorFont><a:latin typeface="${esc(fonts.display)}"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>` +
218
+ `<a:minorFont><a:latin typeface="${esc(fonts.body)}"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>` +
219
+ `</a:fontScheme>` +
220
+ `<a:fmtScheme name="SDOC">${fillStyles}` +
221
+ `<a:lnStyleLst>${lineStyle}${lineStyle}${lineStyle}</a:lnStyleLst>` +
222
+ `<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>` +
223
+ `<a:bgFillStyleLst>${fill}${fill}${fill}</a:bgFillStyleLst>` +
224
+ `</a:fmtScheme></a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>`
225
+ );
226
+ }
227
+
228
+ function emptyTree() {
229
+ return (
230
+ `<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>` +
231
+ `<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>` +
232
+ `<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree>`
233
+ );
234
+ }
235
+
236
+ const CLR_MAP =
237
+ '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" ' +
238
+ 'accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>';
239
+
240
+ function slideMasterXml(background) {
241
+ return (
242
+ XML_DECL +
243
+ `<p:sldMaster ${NS}><p:cSld>` +
244
+ `<p:bg><p:bgPr>${solidFill(background)}<a:effectLst/></p:bgPr></p:bg>` +
245
+ emptyTree() +
246
+ `</p:cSld>${CLR_MAP}` +
247
+ `<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>` +
248
+ `</p:sldMaster>`
249
+ );
250
+ }
251
+
252
+ function slideLayoutXml(background) {
253
+ return (
254
+ XML_DECL +
255
+ `<p:sldLayout ${NS} type="blank" preserve="1"><p:cSld name="DEFAULT">` +
256
+ `<p:bg><p:bgPr>${solidFill(background)}<a:effectLst/></p:bgPr></p:bg>` +
257
+ emptyTree() +
258
+ `</p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" ` +
259
+ `accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" ` +
260
+ `accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sldLayout>`
261
+ );
262
+ }
263
+
264
+ function notesMasterXml() {
265
+ return (
266
+ XML_DECL +
267
+ `<p:notesMaster ${NS}><p:cSld>${emptyTree()}</p:cSld>${CLR_MAP}` +
268
+ `<p:notesStyle><a:lvl1pPr><a:defRPr sz="1200"/></a:lvl1pPr></p:notesStyle></p:notesMaster>`
269
+ );
270
+ }
271
+
272
+ function notesSlideXml(text) {
273
+ const paragraphs = String(text)
274
+ .split(/\n{2,}/)
275
+ .map((p) => `<a:p><a:r><a:rPr lang="en-US" sz="1200" dirty="0"/><a:t>${esc(p.trim())}</a:t></a:r></a:p>`)
276
+ .join("");
277
+ return (
278
+ XML_DECL +
279
+ `<p:notes ${NS}><p:cSld><p:spTree>` +
280
+ `<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>` +
281
+ `<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>` +
282
+ `<p:sp><p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/>` +
283
+ `<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>` +
284
+ `<p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/>${paragraphs}</p:txBody></p:sp>` +
285
+ `</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>`
286
+ );
287
+ }
288
+
289
+ function relsXml(rels) {
290
+ return (
291
+ XML_DECL +
292
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
293
+ rels
294
+ .map(
295
+ (rel) =>
296
+ `<Relationship Id="${rel.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/${rel.type}" Target="${rel.target}"/>`
297
+ )
298
+ .join("") +
299
+ `</Relationships>`
300
+ );
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Slide assembly
305
+ // ---------------------------------------------------------------------------
306
+
307
+ // A box with a border on only some sides exports as a filled bar per side
308
+ // rather than as an outlined rectangle: a one-pixel underline under a heading
309
+ // should be a hairline in Slides, not a box with three invisible edges.
310
+ function edgeBars(atom) {
311
+ const bars = [];
312
+ const { box, border } = atom;
313
+ const w = border.width;
314
+ for (const side of border.sides) {
315
+ if (side === "top") bars.push({ x: box.x, y: box.y, w: box.w, h: w });
316
+ else if (side === "bottom") bars.push({ x: box.x, y: box.y + box.h - w, w: box.w, h: w });
317
+ else if (side === "left") bars.push({ x: box.x, y: box.y, w: w, h: box.h });
318
+ else if (side === "right") bars.push({ x: box.x + box.w - w, y: box.y, w: w, h: box.h });
319
+ }
320
+ return bars;
321
+ }
322
+
323
+ function buildSlideXml(slide, context) {
324
+ const shapes = [];
325
+ let id = context.nextId;
326
+
327
+ for (const atom of slide.atoms) {
328
+ if (atom.kind === "box") {
329
+ // Fill first, then any partial edges, so a wash sits under its rules.
330
+ if (atom.fill) {
331
+ const n = id++;
332
+ shapes.push(
333
+ rectShape(n, `box-${n}`, atom.box, {
334
+ fill: atom.fill,
335
+ fillAlpha: atom.fillAlpha,
336
+ radius: atom.radius,
337
+ })
338
+ );
339
+ }
340
+ if (atom.border) {
341
+ if (atom.border.sides === "all") {
342
+ const n = id++;
343
+ shapes.push(
344
+ rectShape(n, `outline-${n}`, atom.box, {
345
+ radius: atom.radius,
346
+ line: atom.border,
347
+ })
348
+ );
349
+ } else {
350
+ for (const bar of edgeBars(atom)) {
351
+ const n = id++;
352
+ shapes.push(
353
+ rectShape(n, `rule-${n}`, bar, {
354
+ fill: atom.border.colour,
355
+ fillAlpha: atom.border.alpha,
356
+ })
357
+ );
358
+ }
359
+ }
360
+ }
361
+ } else if (atom.kind === "text") {
362
+ const n = id++;
363
+ shapes.push(textShape(n, `text-${n}`, atom, context.fonts));
364
+ } else if (atom.kind === "image") {
365
+ const image = readImage(atom.src, context.baseDir);
366
+ if (image) {
367
+ const relId = context.addImage(image);
368
+ const n = id++;
369
+ shapes.push(pictureShape(n, atom.alt || `image-${n}`, atom.box, relId));
370
+ } else {
371
+ context.skippedImages.push(atom.src || "(no src)");
372
+ }
373
+ }
374
+ }
375
+
376
+ context.nextId = id;
377
+ const background = slide.background ? slide.background.hex : context.background;
378
+
379
+ return (
380
+ XML_DECL +
381
+ `<p:sld ${NS}><p:cSld>` +
382
+ `<p:bg><p:bgPr>${solidFill(background)}<a:effectLst/></p:bgPr></p:bg>` +
383
+ `<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>` +
384
+ `<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>` +
385
+ `<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>` +
386
+ shapes.join("") +
387
+ `</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`
388
+ );
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Entry point
393
+ // ---------------------------------------------------------------------------
394
+
395
+ // geometry — the output of harvestGeometry()
396
+ // options.baseDir — directory relative <img src> values resolve against
397
+ // options.fonts — { display, body } fallback typefaces
398
+ // options.title — deck title, for docProps
399
+ function buildPptx(geometry, options = {}) {
400
+ const width = geometry.box.w;
401
+ const height = geometry.box.h;
402
+ // A face named here is only reached when a CSS stack resolved to a generic
403
+ // family, so it has to be one every viewer already has. A theme names its
404
+ // own with `fonts` in theme.json.
405
+ const fonts = { display: "Helvetica", body: "Helvetica", ...(options.fonts || {}) };
406
+ const baseDir = options.baseDir || process.cwd();
407
+ const title = options.title || "Slides";
408
+
409
+ const images = [];
410
+ const imageByKey = new Map();
411
+ const skippedImages = [];
412
+
413
+ const files = [];
414
+ const slideEntries = [];
415
+
416
+ const slides = geometry.slides;
417
+ const background =
418
+ (slides[0] && slides[0].background && slides[0].background.hex) || "FFFFFF";
419
+
420
+ slides.forEach((slide, index) => {
421
+ const n = index + 1;
422
+ const slideImages = [];
423
+
424
+ const context = {
425
+ nextId: 2,
426
+ fonts,
427
+ baseDir,
428
+ background,
429
+ skippedImages,
430
+ addImage(image) {
431
+ const key = crc32(image.data) + ":" + image.data.length;
432
+ let entry = imageByKey.get(key);
433
+ if (!entry) {
434
+ entry = { index: images.length + 1, ...image };
435
+ images.push(entry);
436
+ imageByKey.set(key, entry);
437
+ }
438
+ const relId = `rId${slideImages.length + 2}`; // rId1 is the layout
439
+ slideImages.push({ relId, entry });
440
+ return relId;
441
+ },
442
+ };
443
+
444
+ files.push({ name: `ppt/slides/slide${n}.xml`, data: buildSlideXml(slide, context) });
445
+
446
+ const rels = [{ id: "rId1", type: "slideLayout", target: "../slideLayouts/slideLayout1.xml" }];
447
+ for (const { relId, entry } of slideImages) {
448
+ rels.push({ id: relId, type: "image", target: `../media/image${entry.index}${entry.ext}` });
449
+ }
450
+ const hasNotes = slide.notes && slide.notes.trim().length > 0;
451
+ if (hasNotes) {
452
+ rels.push({
453
+ id: `rId${slideImages.length + 2}`,
454
+ type: "notesSlide",
455
+ target: `../notesSlides/notesSlide${n}.xml`,
456
+ });
457
+ files.push({ name: `ppt/notesSlides/notesSlide${n}.xml`, data: notesSlideXml(slide.notes) });
458
+ files.push({
459
+ name: `ppt/notesSlides/_rels/notesSlide${n}.xml.rels`,
460
+ data: relsXml([
461
+ { id: "rId1", type: "notesMaster", target: "../notesMasters/notesMaster1.xml" },
462
+ { id: "rId2", type: "slide", target: `../slides/slide${n}.xml` },
463
+ ]),
464
+ });
465
+ }
466
+ files.push({ name: `ppt/slides/_rels/slide${n}.xml.rels`, data: relsXml(rels) });
467
+ slideEntries.push({ n, hasNotes });
468
+ });
469
+
470
+ for (const image of images) {
471
+ files.push({ name: `ppt/media/image${image.index}${image.ext}`, data: image.data, store: true });
472
+ }
473
+
474
+ // presentation.xml and its relationships
475
+ const presRels = [{ id: "rId1", type: "slideMaster", target: "slideMasters/slideMaster1.xml" }];
476
+ const sldIds = slideEntries
477
+ .map((entry, i) => {
478
+ const rid = `rId${i + 2}`;
479
+ presRels.push({ id: rid, type: "slide", target: `slides/slide${entry.n}.xml` });
480
+ return `<p:sldId id="${256 + i}" r:id="${rid}"/>`;
481
+ })
482
+ .join("");
483
+ const notesMasterRid = `rId${slideEntries.length + 2}`;
484
+ presRels.push({ id: notesMasterRid, type: "notesMaster", target: "notesMasters/notesMaster1.xml" });
485
+ presRels.push({ id: `rId${slideEntries.length + 3}`, type: "theme", target: "theme/theme1.xml" });
486
+
487
+ files.push({
488
+ name: "ppt/presentation.xml",
489
+ data:
490
+ XML_DECL +
491
+ `<p:presentation ${NS} saveSubsetFonts="1">` +
492
+ `<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>` +
493
+ `<p:notesMasterIdLst><p:notesMasterId r:id="${notesMasterRid}"/></p:notesMasterIdLst>` +
494
+ `<p:sldIdLst>${sldIds}</p:sldIdLst>` +
495
+ `<p:sldSz cx="${px(width)}" cy="${px(height)}"/>` +
496
+ `<p:notesSz cx="6858000" cy="9144000"/>` +
497
+ `</p:presentation>`,
498
+ });
499
+ files.push({ name: "ppt/_rels/presentation.xml.rels", data: relsXml(presRels) });
500
+
501
+ files.push({ name: "ppt/slideMasters/slideMaster1.xml", data: slideMasterXml(background) });
502
+ files.push({
503
+ name: "ppt/slideMasters/_rels/slideMaster1.xml.rels",
504
+ data: relsXml([
505
+ { id: "rId1", type: "slideLayout", target: "../slideLayouts/slideLayout1.xml" },
506
+ { id: "rId2", type: "theme", target: "../theme/theme1.xml" },
507
+ ]),
508
+ });
509
+ files.push({ name: "ppt/slideLayouts/slideLayout1.xml", data: slideLayoutXml(background) });
510
+ files.push({
511
+ name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels",
512
+ data: relsXml([{ id: "rId1", type: "slideMaster", target: "../slideMasters/slideMaster1.xml" }]),
513
+ });
514
+ files.push({ name: "ppt/notesMasters/notesMaster1.xml", data: notesMasterXml() });
515
+ files.push({
516
+ name: "ppt/notesMasters/_rels/notesMaster1.xml.rels",
517
+ data: relsXml([{ id: "rId1", type: "theme", target: "../theme/theme1.xml" }]),
518
+ });
519
+ files.push({ name: "ppt/theme/theme1.xml", data: themeXml(fonts) });
520
+
521
+ // Package plumbing
522
+ const imageTypes = [...new Set(images.map((i) => i.ext.replace(".", "")))];
523
+ files.push({
524
+ name: "[Content_Types].xml",
525
+ data:
526
+ XML_DECL +
527
+ `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
528
+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
529
+ `<Default Extension="xml" ContentType="application/xml"/>` +
530
+ imageTypes
531
+ .map((ext) => {
532
+ const mime = IMAGE_TYPES["." + ext] || "image/png";
533
+ return `<Default Extension="${ext}" ContentType="${mime}"/>`;
534
+ })
535
+ .join("") +
536
+ `<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>` +
537
+ `<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>` +
538
+ `<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>` +
539
+ `<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>` +
540
+ `<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>` +
541
+ slideEntries
542
+ .map(
543
+ (entry) =>
544
+ `<Override PartName="/ppt/slides/slide${entry.n}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>` +
545
+ (entry.hasNotes
546
+ ? `<Override PartName="/ppt/notesSlides/notesSlide${entry.n}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>`
547
+ : "")
548
+ )
549
+ .join("") +
550
+ `<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>` +
551
+ `<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>` +
552
+ `</Types>`,
553
+ });
554
+
555
+ files.push({
556
+ name: "_rels/.rels",
557
+ data: relsXml([
558
+ { id: "rId1", type: "officeDocument", target: "ppt/presentation.xml" },
559
+ { id: "rId2", type: "metadata/core-properties", target: "docProps/core.xml" },
560
+ { id: "rId3", type: "extended-properties", target: "docProps/app.xml" },
561
+ ]),
562
+ });
563
+
564
+ files.push({
565
+ name: "docProps/core.xml",
566
+ data:
567
+ XML_DECL +
568
+ `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ` +
569
+ `xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ` +
570
+ `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
571
+ `<dc:title>${esc(title)}</dc:title><cp:revision>1</cp:revision></cp:coreProperties>`,
572
+ });
573
+
574
+ files.push({
575
+ name: "docProps/app.xml",
576
+ data:
577
+ XML_DECL +
578
+ `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ` +
579
+ `xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">` +
580
+ `<Application>SDOC Slides</Application><Slides>${slideEntries.length}</Slides></Properties>`,
581
+ });
582
+
583
+ return { buffer: zip(files), skippedImages };
584
+ }
585
+
586
+ // Measures a built HTML deck and writes the .pptx beside it.
587
+ async function exportSlidePptx(htmlPath, pptxPath, options = {}) {
588
+ const { harvestGeometry } = require("./slide-geometry");
589
+ const geometry = await harvestGeometry(htmlPath, options);
590
+ const { buffer, skippedImages } = buildPptx(geometry, {
591
+ baseDir: path.dirname(path.resolve(htmlPath)),
592
+ ...options,
593
+ });
594
+ fs.mkdirSync(path.dirname(path.resolve(pptxPath)), { recursive: true });
595
+ fs.writeFileSync(path.resolve(pptxPath), buffer);
596
+ return { path: path.resolve(pptxPath), slides: geometry.slides.length, skippedImages, geometry };
597
+ }
598
+
599
+ module.exports = { buildPptx, exportSlidePptx };