@markdstage/markdstage 0.1.3 → 2.3.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,1088 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ export const PPTX_DIMENSIONS = Object.freeze({
4
+ widthPx: 1280,
5
+ heightPx: 720,
6
+ emusPerPx: 9525,
7
+ widthEmu: 12192000,
8
+ heightEmu: 6858000,
9
+ });
10
+
11
+ const XML =
12
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13
+ const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main";
14
+ const NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main";
15
+ const NS_R =
16
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
17
+ const NS_REL =
18
+ "http://schemas.openxmlformats.org/package/2006/relationships";
19
+
20
+ const REL = {
21
+ officeDocument: `${NS_R}/officeDocument`,
22
+ core: `${NS_REL}/metadata/core-properties`,
23
+ app: `${NS_R}/extended-properties`,
24
+ slide: `${NS_R}/slide`,
25
+ slideMaster: `${NS_R}/slideMaster`,
26
+ slideLayout: `${NS_R}/slideLayout`,
27
+ theme: `${NS_R}/theme`,
28
+ image: `${NS_R}/image`,
29
+ hyperlink: `${NS_R}/hyperlink`,
30
+ };
31
+
32
+ const CONTENT_TYPES = {
33
+ "image/png": { extension: "png", contentType: "image/png" },
34
+ "image/jpeg": { extension: "jpeg", contentType: "image/jpeg" },
35
+ "image/gif": { extension: "gif", contentType: "image/gif" },
36
+ "image/svg+xml": { extension: "svg", contentType: "image/svg+xml" },
37
+ };
38
+
39
+ const CRC_TABLE = new Uint32Array(256);
40
+ for (let i = 0; i < 256; i += 1) {
41
+ let value = i;
42
+ for (let bit = 0; bit < 8; bit += 1) {
43
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
44
+ }
45
+ CRC_TABLE[i] = value >>> 0;
46
+ }
47
+
48
+ function fail(message) {
49
+ throw new TypeError(`Invalid PowerPoint model: ${message}`);
50
+ }
51
+
52
+ function xmlEscape(value) {
53
+ return String(value)
54
+ .replaceAll("&", "&amp;")
55
+ .replaceAll("<", "&lt;")
56
+ .replaceAll(">", "&gt;")
57
+ .replaceAll('"', "&quot;")
58
+ .replaceAll("'", "&apos;");
59
+ }
60
+
61
+ function xmlUnescape(value) {
62
+ return value
63
+ .replaceAll("&apos;", "'")
64
+ .replaceAll("&quot;", '"')
65
+ .replaceAll("&gt;", ">")
66
+ .replaceAll("&lt;", "<")
67
+ .replaceAll("&amp;", "&");
68
+ }
69
+
70
+ function finiteNumber(value, path) {
71
+ if (typeof value !== "number" || !Number.isFinite(value)) {
72
+ fail(`${path} must be a finite number`);
73
+ }
74
+ return value;
75
+ }
76
+
77
+ function positiveNumber(value, path) {
78
+ const number = finiteNumber(value, path);
79
+ if (number <= 0) fail(`${path} must be greater than zero`);
80
+ return number;
81
+ }
82
+
83
+ function nonNegativeNumber(value, path) {
84
+ const number = finiteNumber(value, path);
85
+ if (number < 0) fail(`${path} must be zero or greater`);
86
+ return number;
87
+ }
88
+
89
+ function optionalUnitInterval(value, path, fallback = 1) {
90
+ if (value === undefined) return fallback;
91
+ const number = finiteNumber(value, path);
92
+ if (number < 0 || number > 1) fail(`${path} must be between 0 and 1`);
93
+ return number;
94
+ }
95
+
96
+ function boundsOf(value, path) {
97
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
98
+ fail(`${path} must be an object`);
99
+ }
100
+ return {
101
+ x: finiteNumber(value.x, `${path}.x`),
102
+ y: finiteNumber(value.y, `${path}.y`),
103
+ width: positiveNumber(value.width, `${path}.width`),
104
+ height: positiveNumber(value.height, `${path}.height`),
105
+ };
106
+ }
107
+
108
+ function emu(value) {
109
+ return Math.round(value * PPTX_DIMENSIONS.emusPerPx);
110
+ }
111
+
112
+ function xfrmXml(bounds, tag = "a:xfrm") {
113
+ return `<${tag}><a:off x="${emu(bounds.x)}" y="${emu(bounds.y)}"/><a:ext cx="${emu(bounds.width)}" cy="${emu(bounds.height)}"/></${tag}>`;
114
+ }
115
+
116
+ function parseChannel(value, path) {
117
+ const text = String(value).trim();
118
+ const number = text.endsWith("%")
119
+ ? (Number.parseFloat(text) * 255) / 100
120
+ : Number.parseFloat(text);
121
+ if (!Number.isFinite(number) || number < 0 || number > 255) {
122
+ fail(`${path} contains an invalid RGB channel`);
123
+ }
124
+ return Math.round(number);
125
+ }
126
+
127
+ function parseAlpha(value, path) {
128
+ if (value === undefined) return 1;
129
+ const text = String(value).trim();
130
+ const number = text.endsWith("%")
131
+ ? Number.parseFloat(text) / 100
132
+ : Number.parseFloat(text);
133
+ if (!Number.isFinite(number) || number < 0 || number > 1) {
134
+ fail(`${path} contains an invalid alpha channel`);
135
+ }
136
+ return number;
137
+ }
138
+
139
+ function colorOf(value, path) {
140
+ if (value === null || value === undefined || value === "transparent") return null;
141
+ if (typeof value !== "string") fail(`${path} must be a CSS color string or null`);
142
+ const text = value.trim();
143
+ const hex = /^#([0-9a-f]{3,8})$/i.exec(text);
144
+ if (hex) {
145
+ let digits = hex[1];
146
+ if (digits.length === 3 || digits.length === 4) {
147
+ digits = [...digits].map((digit) => digit + digit).join("");
148
+ }
149
+ if (digits.length !== 6 && digits.length !== 8) {
150
+ fail(`${path} must use #RGB, #RGBA, #RRGGBB, or #RRGGBBAA`);
151
+ }
152
+ return {
153
+ hex: digits.slice(0, 6).toUpperCase(),
154
+ alpha:
155
+ digits.length === 8 ? Number.parseInt(digits.slice(6), 16) / 255 : 1,
156
+ };
157
+ }
158
+ const rgb = /^rgba?\((.*)\)$/i.exec(text);
159
+ if (!rgb) fail(`${path} must be a hex, rgb(), or rgba() color`);
160
+ const inner = rgb[1].trim();
161
+ let channels;
162
+ let alpha;
163
+ if (inner.includes(",")) {
164
+ const parts = inner.split(",").map((part) => part.trim());
165
+ if (parts.length !== 3 && parts.length !== 4) fail(`${path} is invalid`);
166
+ channels = parts.slice(0, 3);
167
+ alpha = parts[3];
168
+ } else {
169
+ const [channelText, alphaText] = inner.split("/").map((part) => part.trim());
170
+ channels = channelText.split(/\s+/);
171
+ alpha = alphaText;
172
+ }
173
+ if (channels.length !== 3) fail(`${path} is invalid`);
174
+ const values = channels.map((part) => parseChannel(part, path));
175
+ return {
176
+ hex: values.map((part) => part.toString(16).padStart(2, "0")).join("").toUpperCase(),
177
+ alpha: parseAlpha(alpha, path),
178
+ };
179
+ }
180
+
181
+ function colorXml(value, path, opacity = 1) {
182
+ const color = colorOf(value, path);
183
+ if (!color) return "<a:noFill/>";
184
+ const alpha = Math.round(color.alpha * opacity * 100000);
185
+ return `<a:solidFill><a:srgbClr val="${color.hex}">${
186
+ alpha < 100000 ? `<a:alpha val="${alpha}"/>` : ""
187
+ }</a:srgbClr></a:solidFill>`;
188
+ }
189
+
190
+ function lineXml(element, path) {
191
+ const width = element.strokeWidth === undefined
192
+ ? 1
193
+ : positiveNumber(element.strokeWidth, `${path}.strokeWidth`);
194
+ const color = colorOf(element.stroke, `${path}.stroke`);
195
+ if (!color) return `<a:ln w="${emu(width)}"><a:noFill/></a:ln>`;
196
+ const opacity = optionalUnitInterval(element.opacity, `${path}.opacity`);
197
+ const alpha = Math.round(color.alpha * opacity * 100000);
198
+ const dash = dashXml(element.dash, `${path}.dash`);
199
+ return `<a:ln w="${emu(width)}"><a:solidFill><a:srgbClr val="${color.hex}">${
200
+ alpha < 100000 ? `<a:alpha val="${alpha}"/>` : ""
201
+ }</a:srgbClr></a:solidFill>${dash}</a:ln>`;
202
+ }
203
+
204
+ function dashXml(value, path) {
205
+ if (value === undefined || value === null || value === "solid") return "";
206
+ const normalized = {
207
+ dash: "dash",
208
+ dashed: "dash",
209
+ dot: "dot",
210
+ dotted: "dot",
211
+ dashDot: "dashDot",
212
+ longDash: "lgDash",
213
+ }[value];
214
+ if (!normalized) fail(`${path} is not a supported dash style`);
215
+ return `<a:prstDash val="${normalized}"/>`;
216
+ }
217
+
218
+ function detectContentType(data) {
219
+ if (
220
+ data.length >= 8 &&
221
+ data.subarray(0, 8).equals(
222
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
223
+ )
224
+ ) {
225
+ return "image/png";
226
+ }
227
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
228
+ return "image/jpeg";
229
+ }
230
+ if (
231
+ data.length >= 6 &&
232
+ (data.subarray(0, 6).toString("ascii") === "GIF87a" ||
233
+ data.subarray(0, 6).toString("ascii") === "GIF89a")
234
+ ) {
235
+ return "image/gif";
236
+ }
237
+ if (/^\s*<svg[\s>]/i.test(data.subarray(0, 512).toString("utf8"))) {
238
+ return "image/svg+xml";
239
+ }
240
+ return null;
241
+ }
242
+
243
+ function bufferOf(value, path) {
244
+ if (Buffer.isBuffer(value)) return Buffer.from(value);
245
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
246
+ if (ArrayBuffer.isView(value)) {
247
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
248
+ }
249
+ fail(`${path} must be a Buffer, ArrayBuffer, or typed array`);
250
+ }
251
+
252
+ function normalizeAssets(assets) {
253
+ if (assets === undefined) return new Map();
254
+ if (!Array.isArray(assets)) fail("assets must be an array");
255
+ const result = new Map();
256
+ for (let index = 0; index < assets.length; index += 1) {
257
+ const asset = assets[index];
258
+ const path = `assets[${index}]`;
259
+ if (!asset || typeof asset !== "object" || Array.isArray(asset)) {
260
+ fail(`${path} must be an object`);
261
+ }
262
+ if (typeof asset.id !== "string" || !asset.id.trim()) {
263
+ fail(`${path}.id must be a non-empty string`);
264
+ }
265
+ if (result.has(asset.id)) fail(`duplicate asset id "${asset.id}"`);
266
+ const data = bufferOf(asset.data, `${path}.data`);
267
+ if (!data.length) fail(`${path}.data must not be empty`);
268
+ const detected = detectContentType(data);
269
+ const contentType = asset.contentType || detected;
270
+ if (!CONTENT_TYPES[contentType]) {
271
+ fail(`${path}.contentType must be PNG, JPEG, GIF, or SVG`);
272
+ }
273
+ if (asset.contentType && detected && asset.contentType !== detected) {
274
+ fail(`${path}.contentType does not match its data`);
275
+ }
276
+ result.set(asset.id, {
277
+ id: asset.id,
278
+ data,
279
+ contentType,
280
+ extension: CONTENT_TYPES[contentType].extension,
281
+ mediaPath: "",
282
+ });
283
+ }
284
+ let mediaIndex = 1;
285
+ for (const asset of result.values()) {
286
+ asset.mediaPath = `ppt/media/image${mediaIndex}.${asset.extension}`;
287
+ mediaIndex += 1;
288
+ }
289
+ return result;
290
+ }
291
+
292
+ function requireAsset(assets, id, path) {
293
+ if (typeof id !== "string" || !id) fail(`${path} must be a non-empty asset id`);
294
+ const asset = assets.get(id);
295
+ if (!asset) fail(`${path} references missing asset "${id}"`);
296
+ return asset;
297
+ }
298
+
299
+ function fontSizeOf(run, path) {
300
+ let value = run.fontSize;
301
+ let unit = run.fontSizeUnit || "px";
302
+ if (run.fontSizePx !== undefined) {
303
+ value = run.fontSizePx;
304
+ unit = "px";
305
+ } else if (run.fontSizePt !== undefined) {
306
+ value = run.fontSizePt;
307
+ unit = "pt";
308
+ }
309
+ if (value === undefined) return 1800;
310
+ if (typeof value === "string") {
311
+ const match = /^(\d+(?:\.\d+)?)\s*(px|pt)$/i.exec(value.trim());
312
+ if (!match) fail(`${path}.fontSize must be a px or pt value`);
313
+ value = Number(match[1]);
314
+ unit = match[2].toLowerCase();
315
+ }
316
+ value = positiveNumber(value, `${path}.fontSize`);
317
+ if (unit !== "px" && unit !== "pt") {
318
+ fail(`${path}.fontSizeUnit must be "px" or "pt"`);
319
+ }
320
+ return Math.round((unit === "px" ? value * 0.75 : value) * 100);
321
+ }
322
+
323
+ function normalizeText(value, path) {
324
+ if (typeof value === "string") {
325
+ return { paragraphs: [{ runs: [{ text: value }] }] };
326
+ }
327
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
328
+ fail(`${path} must be a string or rich text object`);
329
+ }
330
+ if (!Array.isArray(value.paragraphs) || value.paragraphs.length === 0) {
331
+ fail(`${path}.paragraphs must be a non-empty array`);
332
+ }
333
+ return value;
334
+ }
335
+
336
+ function paragraphXml(paragraph, path, relationships) {
337
+ if (!paragraph || typeof paragraph !== "object" || Array.isArray(paragraph)) {
338
+ fail(`${path} must be an object`);
339
+ }
340
+ if (!Array.isArray(paragraph.runs) || paragraph.runs.length === 0) {
341
+ fail(`${path}.runs must be a non-empty array`);
342
+ }
343
+ const align = {
344
+ left: "l",
345
+ center: "ctr",
346
+ right: "r",
347
+ justify: "just",
348
+ }[paragraph.alignment || "left"];
349
+ if (!align) fail(`${path}.alignment is invalid`);
350
+ const level = paragraph.level === undefined ? 0 : paragraph.level;
351
+ if (!Number.isInteger(level) || level < 0 || level > 8) {
352
+ fail(`${path}.level must be an integer between 0 and 8`);
353
+ }
354
+ let bullet = "";
355
+ if (paragraph.bullet) {
356
+ const character =
357
+ typeof paragraph.bullet === "string"
358
+ ? paragraph.bullet
359
+ : paragraph.bullet.character || "•";
360
+ bullet = `<a:buChar char="${xmlEscape(character)}"/>`;
361
+ }
362
+ const runs = paragraph.runs
363
+ .map((run, index) =>
364
+ runXml(run, `${path}.runs[${index}]`, relationships),
365
+ )
366
+ .join("");
367
+ return `<a:p><a:pPr algn="${align}" lvl="${level}">${bullet}</a:pPr>${runs}<a:endParaRPr lang="en-US"/></a:p>`;
368
+ }
369
+
370
+ function runXml(run, path, relationships) {
371
+ if (!run || typeof run !== "object" || Array.isArray(run)) {
372
+ fail(`${path} must be an object`);
373
+ }
374
+ if (typeof run.text !== "string") fail(`${path}.text must be a string`);
375
+ const size = fontSizeOf(run, path);
376
+ const attributes = [
377
+ 'lang="en-US"',
378
+ `sz="${size}"`,
379
+ run.bold ? 'b="1"' : "",
380
+ run.italic ? 'i="1"' : "",
381
+ run.underline ? 'u="sng"' : "",
382
+ ]
383
+ .filter(Boolean)
384
+ .join(" ");
385
+ let properties = colorXml(
386
+ run.color ?? "#000000",
387
+ `${path}.color`,
388
+ optionalUnitInterval(run.opacity, `${path}.opacity`),
389
+ );
390
+ if (run.fontFace !== undefined) {
391
+ if (typeof run.fontFace !== "string" || !run.fontFace) {
392
+ fail(`${path}.fontFace must be a non-empty string`);
393
+ }
394
+ properties += `<a:latin typeface="${xmlEscape(run.fontFace)}"/>`;
395
+ }
396
+ if (run.href !== undefined) {
397
+ if (typeof run.href !== "string" || !run.href) {
398
+ fail(`${path}.href must be a non-empty string`);
399
+ }
400
+ const relationshipId = relationships.hyperlink(run.href);
401
+ properties += `<a:hlinkClick r:id="${relationshipId}"/>`;
402
+ }
403
+ const preserve = /^\s|\s$|\s{2}/.test(run.text) ? ' xml:space="preserve"' : "";
404
+ return `<a:r><a:rPr ${attributes}>${properties}</a:rPr><a:t${preserve}>${xmlEscape(run.text)}</a:t></a:r>`;
405
+ }
406
+
407
+ function textBodyPropertiesXml(options, path) {
408
+ const anchor = {
409
+ top: "t",
410
+ middle: "ctr",
411
+ bottom: "b",
412
+ }[options.verticalAlignment];
413
+ if (options.verticalAlignment !== undefined && !anchor) {
414
+ fail(`${path}.verticalAlignment must be "top", "middle", or "bottom"`);
415
+ }
416
+ const wrap = options.textWrap === undefined ? "square" : options.textWrap;
417
+ if (wrap !== "square" && wrap !== "none") {
418
+ fail(`${path}.textWrap must be "square" or "none"`);
419
+ }
420
+ const insets = { left: 0, top: 0, right: 0, bottom: 0 };
421
+ if (options.textInsets !== undefined) {
422
+ if (
423
+ !options.textInsets ||
424
+ typeof options.textInsets !== "object" ||
425
+ Array.isArray(options.textInsets)
426
+ ) {
427
+ fail(`${path}.textInsets must be an object`);
428
+ }
429
+ for (const side of Object.keys(insets)) {
430
+ if (options.textInsets[side] !== undefined) {
431
+ insets[side] = nonNegativeNumber(
432
+ options.textInsets[side],
433
+ `${path}.textInsets.${side}`,
434
+ );
435
+ }
436
+ }
437
+ }
438
+ const anchorAttribute = anchor ? ` anchor="${anchor}"` : "";
439
+ return `<a:bodyPr wrap="${wrap}" lIns="${emu(insets.left)}" tIns="${emu(insets.top)}" rIns="${emu(insets.right)}" bIns="${emu(insets.bottom)}"${anchorAttribute}/>`;
440
+ }
441
+
442
+ function textBodyXml(
443
+ value,
444
+ path,
445
+ relationships,
446
+ tag = "p:txBody",
447
+ bodyOptions = {},
448
+ bodyPath = path,
449
+ ) {
450
+ const text = normalizeText(value, path);
451
+ const paragraphs = text.paragraphs
452
+ .map((paragraph, index) =>
453
+ paragraphXml(paragraph, `${path}.paragraphs[${index}]`, relationships),
454
+ )
455
+ .join("");
456
+ return `<${tag}>${textBodyPropertiesXml(bodyOptions, bodyPath)}<a:lstStyle/>${paragraphs}</${tag}>`;
457
+ }
458
+
459
+ function shapeBase(id, name, bounds, properties, text = "") {
460
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${xmlEscape(name)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>${xfrmXml(bounds)}${properties}</p:spPr>${text}</p:sp>`;
461
+ }
462
+
463
+ function textShapeXml(element, path, id, relationships) {
464
+ const bounds = boundsOf(element, path);
465
+ const text = { paragraphs: element.paragraphs };
466
+ return shapeBase(
467
+ id,
468
+ `Text ${id}`,
469
+ bounds,
470
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>',
471
+ textBodyXml(text, `${path}`, relationships, "p:txBody", element),
472
+ );
473
+ }
474
+
475
+ function shapeTextOf(element, path) {
476
+ const hasText = element.text !== undefined;
477
+ const hasParagraphs = element.paragraphs !== undefined;
478
+ if (hasText && hasParagraphs) {
479
+ fail(`${path} must specify either text or paragraphs, not both`);
480
+ }
481
+ if (hasParagraphs) {
482
+ return { value: { paragraphs: element.paragraphs }, path };
483
+ }
484
+ if (hasText) {
485
+ return { value: element.text, path: `${path}.text` };
486
+ }
487
+ return null;
488
+ }
489
+
490
+ function nativeShapeXml(element, path, id, relationships) {
491
+ const bounds = boundsOf(element, path);
492
+ const preset = {
493
+ rect: "rect",
494
+ roundedRect: "roundRect",
495
+ ellipse: "ellipse",
496
+ }[element.shape];
497
+ if (!preset) fail(`${path}.shape must be rect, roundedRect, or ellipse`);
498
+ const opacity = optionalUnitInterval(element.opacity, `${path}.opacity`);
499
+ const properties = `${xfrmXml(bounds)}<a:prstGeom prst="${preset}"><a:avLst/></a:prstGeom>${colorXml(
500
+ element.fill,
501
+ `${path}.fill`,
502
+ opacity,
503
+ )}${lineXml(element, path)}`;
504
+ const shapeText = shapeTextOf(element, path);
505
+ if (!shapeText) textBodyPropertiesXml(element, path);
506
+ const text = shapeText
507
+ ? textBodyXml(
508
+ shapeText.value,
509
+ shapeText.path,
510
+ relationships,
511
+ "p:txBody",
512
+ element,
513
+ path,
514
+ )
515
+ : "";
516
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Shape ${id}"/><p:cNvSpPr txBox="0"/><p:nvPr/></p:nvSpPr><p:spPr>${properties}</p:spPr>${text}</p:sp>`;
517
+ }
518
+
519
+ function pictureXml(asset, bounds, id, relationshipId, name = `Image ${id}`) {
520
+ return `<p:pic><p:nvPicPr><p:cNvPr id="${id}" name="${xmlEscape(name)}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="${relationshipId}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr>${xfrmXml(bounds)}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
521
+ }
522
+
523
+ function tableXml(element, path, id, relationships) {
524
+ const bounds = boundsOf(element, path);
525
+ if (!Array.isArray(element.rows) || element.rows.length === 0) {
526
+ fail(`${path}.rows must be a non-empty array`);
527
+ }
528
+ const columnCount = element.rows[0]?.cells?.length;
529
+ if (!Number.isInteger(columnCount) || columnCount === 0) {
530
+ fail(`${path}.rows[0].cells must be a non-empty array`);
531
+ }
532
+ const rowHeight = emu(bounds.height) / element.rows.length;
533
+ const columnWidth = emu(bounds.width) / columnCount;
534
+ const rows = element.rows
535
+ .map((row, rowIndex) => {
536
+ const rowPath = `${path}.rows[${rowIndex}]`;
537
+ if (!row || !Array.isArray(row.cells) || row.cells.length !== columnCount) {
538
+ fail(`${rowPath}.cells must contain exactly ${columnCount} cells`);
539
+ }
540
+ const cells = row.cells
541
+ .map((cell, cellIndex) => {
542
+ const cellPath = `${rowPath}.cells[${cellIndex}]`;
543
+ if (!cell || typeof cell !== "object" || Array.isArray(cell)) {
544
+ fail(`${cellPath} must be an object`);
545
+ }
546
+ const text =
547
+ cell.text !== undefined
548
+ ? cell.text
549
+ : { paragraphs: cell.paragraphs };
550
+ const fill = colorXml(cell.fill, `${cellPath}.fill`);
551
+ const strokeColor = colorOf(cell.stroke, `${cellPath}.stroke`);
552
+ const strokeWidth =
553
+ cell.strokeWidth === undefined
554
+ ? 1
555
+ : positiveNumber(cell.strokeWidth, `${cellPath}.strokeWidth`);
556
+ const borderFill = strokeColor
557
+ ? `<a:solidFill><a:srgbClr val="${strokeColor.hex}"/></a:solidFill>`
558
+ : "<a:noFill/>";
559
+ const borders = ["L", "R", "T", "B"]
560
+ .map(
561
+ (side) =>
562
+ `<a:ln${side} w="${emu(strokeWidth)}">${borderFill}</a:ln${side}>`,
563
+ )
564
+ .join("");
565
+ return `<a:tc>${textBodyXml(text, `${cellPath}.text`, relationships, "a:txBody")}<a:tcPr>${borders}${fill}</a:tcPr></a:tc>`;
566
+ })
567
+ .join("");
568
+ return `<a:tr h="${Math.round(rowHeight)}">${cells}</a:tr>`;
569
+ })
570
+ .join("");
571
+ const columns = Array.from(
572
+ { length: columnCount },
573
+ () => `<a:gridCol w="${Math.round(columnWidth)}"/>`,
574
+ ).join("");
575
+ return `<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="${id}" name="Table ${id}"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>${xfrmXml(bounds, "p:xfrm")}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1" bandRow="1"/><a:tblGrid>${columns}</a:tblGrid>${rows}</a:tbl></a:graphicData></a:graphic></p:graphicFrame>`;
576
+ }
577
+
578
+ function arrowXml(value, path) {
579
+ if (value === undefined || value === null || value === false || value === "none") {
580
+ return "";
581
+ }
582
+ const type =
583
+ value === true
584
+ ? "triangle"
585
+ : {
586
+ triangle: "triangle",
587
+ arrow: "arrow",
588
+ stealth: "stealth",
589
+ diamond: "diamond",
590
+ oval: "oval",
591
+ }[value];
592
+ if (!type) fail(`${path} is not a supported arrow end`);
593
+ return `<a:tailEnd type="${type}"/>`;
594
+ }
595
+
596
+ function connectorXml(element, path, nextId, relationships) {
597
+ if (!Array.isArray(element.points) || element.points.length < 2) {
598
+ fail(`${path}.points must contain at least two points`);
599
+ }
600
+ const points = element.points.map((point, index) => {
601
+ if (!point || typeof point !== "object") fail(`${path}.points[${index}] must be an object`);
602
+ return {
603
+ x: finiteNumber(point.x, `${path}.points[${index}].x`),
604
+ y: finiteNumber(point.y, `${path}.points[${index}].y`),
605
+ };
606
+ });
607
+ const width = element.strokeWidth === undefined
608
+ ? 1
609
+ : positiveNumber(element.strokeWidth, `${path}.strokeWidth`);
610
+ const color = colorOf(element.stroke ?? "#000000", `${path}.stroke`);
611
+ if (!color) fail(`${path}.stroke cannot be null`);
612
+ const opacity = optionalUnitInterval(element.opacity, `${path}.opacity`);
613
+ const alpha = Math.round(color.alpha * opacity * 100000);
614
+ const dash = dashXml(element.dash, `${path}.dash`);
615
+ const shapes = [];
616
+ for (let index = 0; index < points.length - 1; index += 1) {
617
+ const start = points[index];
618
+ const end = points[index + 1];
619
+ if (start.x === end.x && start.y === end.y) {
620
+ fail(`${path}.points[${index}] and points[${index + 1}] must differ`);
621
+ }
622
+ const id = nextId();
623
+ const x = Math.min(start.x, end.x);
624
+ const y = Math.min(start.y, end.y);
625
+ const flipH = end.x < start.x ? ' flipH="1"' : "";
626
+ const flipV = end.y < start.y ? ' flipV="1"' : "";
627
+ const tail =
628
+ index === points.length - 2
629
+ ? arrowXml(element.arrowEnd, `${path}.arrowEnd`)
630
+ : "";
631
+ shapes.push(
632
+ `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Connector ${id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${flipH}${flipV}><a:off x="${emu(x)}" y="${emu(y)}"/><a:ext cx="${emu(Math.abs(end.x - start.x))}" cy="${emu(Math.abs(end.y - start.y))}"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="${emu(width)}"><a:solidFill><a:srgbClr val="${color.hex}">${
633
+ alpha < 100000 ? `<a:alpha val="${alpha}"/>` : ""
634
+ }</a:srgbClr></a:solidFill>${dash}${tail}</a:ln></p:spPr></p:sp>`,
635
+ );
636
+ }
637
+ if (element.label !== undefined) {
638
+ const id = nextId();
639
+ const labelBounds = element.labelBounds
640
+ ? boundsOf(element.labelBounds, `${path}.labelBounds`)
641
+ : {
642
+ x: (Math.min(...points.map((point) => point.x)) +
643
+ Math.max(...points.map((point) => point.x))) /
644
+ 2 -
645
+ 60,
646
+ y: (Math.min(...points.map((point) => point.y)) +
647
+ Math.max(...points.map((point) => point.y))) /
648
+ 2 -
649
+ 12,
650
+ width: 120,
651
+ height: 24,
652
+ };
653
+ shapes.push(
654
+ shapeBase(
655
+ id,
656
+ `Connector label ${id}`,
657
+ labelBounds,
658
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>',
659
+ textBodyXml(element.label, `${path}.label`, relationships),
660
+ ),
661
+ );
662
+ }
663
+ return shapes.join("");
664
+ }
665
+
666
+ function relationshipRegistry() {
667
+ const entries = [
668
+ {
669
+ id: "rId1",
670
+ type: REL.slideLayout,
671
+ target: "../slideLayouts/slideLayout1.xml",
672
+ external: false,
673
+ },
674
+ ];
675
+ const images = new Map();
676
+ const hyperlinks = new Map();
677
+ const add = (type, target, external) => {
678
+ const id = `rId${entries.length + 1}`;
679
+ entries.push({ id, type, target, external });
680
+ return id;
681
+ };
682
+ return {
683
+ image(asset) {
684
+ if (!images.has(asset.id)) {
685
+ images.set(
686
+ asset.id,
687
+ add(REL.image, `../media/${asset.mediaPath.split("/").at(-1)}`, false),
688
+ );
689
+ }
690
+ return images.get(asset.id);
691
+ },
692
+ hyperlink(href) {
693
+ if (!hyperlinks.has(href)) {
694
+ hyperlinks.set(href, add(REL.hyperlink, href, true));
695
+ }
696
+ return hyperlinks.get(href);
697
+ },
698
+ xml() {
699
+ return relationshipsXml(entries);
700
+ },
701
+ };
702
+ }
703
+
704
+ function baseShapeTree() {
705
+ return '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><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>';
706
+ }
707
+
708
+ function buildSlide(slide, slideIndex, assets) {
709
+ const path = `slides[${slideIndex}]`;
710
+ if (!slide || typeof slide !== "object" || Array.isArray(slide)) {
711
+ fail(`${path} must be an object`);
712
+ }
713
+ if (!Array.isArray(slide.elements)) fail(`${path}.elements must be an array`);
714
+ const relationships = relationshipRegistry();
715
+ let shapeId = 2;
716
+ const nextId = () => shapeId++;
717
+ const shapes = [];
718
+ if (slide.backgroundAssetId !== undefined) {
719
+ const asset = requireAsset(
720
+ assets,
721
+ slide.backgroundAssetId,
722
+ `${path}.backgroundAssetId`,
723
+ );
724
+ if (asset.contentType !== "image/png") {
725
+ fail(`${path}.backgroundAssetId must reference a PNG asset`);
726
+ }
727
+ const id = nextId();
728
+ shapes.push(
729
+ pictureXml(
730
+ asset,
731
+ {
732
+ x: 0,
733
+ y: 0,
734
+ width: PPTX_DIMENSIONS.widthPx,
735
+ height: PPTX_DIMENSIONS.heightPx,
736
+ },
737
+ id,
738
+ relationships.image(asset),
739
+ "Slide background",
740
+ ),
741
+ );
742
+ }
743
+ for (let index = 0; index < slide.elements.length; index += 1) {
744
+ const element = slide.elements[index];
745
+ const elementPath = `${path}.elements[${index}]`;
746
+ if (!element || typeof element !== "object" || Array.isArray(element)) {
747
+ fail(`${elementPath} must be an object`);
748
+ }
749
+ if (element.type === "text") {
750
+ shapes.push(textShapeXml(element, elementPath, nextId(), relationships));
751
+ } else if (element.type === "table") {
752
+ shapes.push(tableXml(element, elementPath, nextId(), relationships));
753
+ } else if (element.type === "image") {
754
+ const asset = requireAsset(assets, element.assetId, `${elementPath}.assetId`);
755
+ shapes.push(
756
+ pictureXml(
757
+ asset,
758
+ boundsOf(element, elementPath),
759
+ nextId(),
760
+ relationships.image(asset),
761
+ ),
762
+ );
763
+ } else if (element.type === "shape") {
764
+ shapes.push(nativeShapeXml(element, elementPath, nextId(), relationships));
765
+ } else if (element.type === "connector" || element.type === "polyline") {
766
+ shapes.push(connectorXml(element, elementPath, nextId, relationships));
767
+ } else {
768
+ fail(`${elementPath}.type is not supported`);
769
+ }
770
+ }
771
+ return {
772
+ xml: `${XML}<p:sld xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld><p:spTree>${baseShapeTree()}${shapes.join("")}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`,
773
+ rels: relationships.xml(),
774
+ };
775
+ }
776
+
777
+ function relationshipsXml(entries) {
778
+ return `${XML}<Relationships xmlns="${NS_REL}">${entries
779
+ .map(
780
+ (entry) =>
781
+ `<Relationship Id="${xmlEscape(entry.id)}" Type="${xmlEscape(entry.type)}" Target="${xmlEscape(entry.target)}"${
782
+ entry.external ? ' TargetMode="External"' : ""
783
+ }/>`,
784
+ )
785
+ .join("")}</Relationships>`;
786
+ }
787
+
788
+ function contentTypesXml(slideCount, assets) {
789
+ const imageTypes = new Map();
790
+ for (const asset of assets.values()) {
791
+ imageTypes.set(asset.extension, asset.contentType);
792
+ }
793
+ const defaults = [...imageTypes]
794
+ .map(
795
+ ([extension, contentType]) =>
796
+ `<Default Extension="${extension}" ContentType="${contentType}"/>`,
797
+ )
798
+ .join("");
799
+ const slides = Array.from(
800
+ { length: slideCount },
801
+ (_, index) =>
802
+ `<Override PartName="/ppt/slides/slide${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`,
803
+ ).join("");
804
+ return `${XML}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${defaults}<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/><Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>${slides}</Types>`;
805
+ }
806
+
807
+ function presentationXml(slideCount) {
808
+ const slides = Array.from(
809
+ { length: slideCount },
810
+ (_, index) =>
811
+ `<p:sldId id="${256 + index}" r:id="rId${index + 2}"/>`,
812
+ ).join("");
813
+ return `${XML}<p:presentation xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst>${slides}</p:sldIdLst><p:sldSz cx="${PPTX_DIMENSIONS.widthEmu}" cy="${PPTX_DIMENSIONS.heightEmu}" type="screen16x9"/><p:notesSz cx="6858000" cy="9144000"/><p:defaultTextStyle/></p:presentation>`;
814
+ }
815
+
816
+ function presentationRelsXml(slideCount) {
817
+ const entries = [
818
+ {
819
+ id: "rId1",
820
+ type: REL.slideMaster,
821
+ target: "slideMasters/slideMaster1.xml",
822
+ },
823
+ ...Array.from({ length: slideCount }, (_, index) => ({
824
+ id: `rId${index + 2}`,
825
+ type: REL.slide,
826
+ target: `slides/slide${index + 1}.xml`,
827
+ })),
828
+ ];
829
+ return relationshipsXml(entries);
830
+ }
831
+
832
+ function coreXml(title) {
833
+ return `${XML}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(title)}</dc:title><dc:creator>MarkdStage</dc:creator><cp:lastModifiedBy>MarkdStage</cp:lastModifiedBy></cp:coreProperties>`;
834
+ }
835
+
836
+ function appXml(slideCount) {
837
+ return `${XML}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>MarkdStage</Application><PresentationFormat>On-screen Show (16:9)</PresentationFormat><Slides>${slideCount}</Slides><Notes>0</Notes><HiddenSlides>0</HiddenSlides><MMClips>0</MMClips><ScaleCrop>false</ScaleCrop><Company/><LinksUpToDate>false</LinksUpToDate><SharedDoc>false</SharedDoc><HyperlinksChanged>false</HyperlinksChanged><AppVersion>1.0</AppVersion></Properties>`;
838
+ }
839
+
840
+ function themeXml() {
841
+ return `${XML}<a:theme xmlns:a="${NS_A}" name="MarkdStage"><a:themeElements><a:clrScheme name="MarkdStage"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F1F1F"/></a:dk2><a:lt2><a:srgbClr val="F2F2F2"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="MarkdStage"><a:majorFont><a:latin typeface="Aptos Display"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Aptos"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="MarkdStage"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="accent1"/></a:solidFill><a:solidFill><a:schemeClr val="accent2"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="28575"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:solidFill><a:schemeClr val="lt2"/></a:solidFill><a:solidFill><a:schemeClr val="dk1"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>`;
842
+ }
843
+
844
+ function slideMasterXml() {
845
+ return `${XML}<p:sldMaster xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld name="MarkdStage"><p:spTree>${baseShapeTree()}</p:spTree></p:cSld><p:clrMap accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" bg1="lt1" bg2="lt2" folHlink="folHlink" hlink="hlink" tx1="dk1" tx2="dk2"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst><p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles></p:sldMaster>`;
846
+ }
847
+
848
+ function slideLayoutXml() {
849
+ return `${XML}<p:sldLayout xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}" type="blank" preserve="1"><p:cSld name="Blank"><p:spTree>${baseShapeTree()}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>`;
850
+ }
851
+
852
+ function crc32(data) {
853
+ let crc = 0xffffffff;
854
+ for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
855
+ return (crc ^ 0xffffffff) >>> 0;
856
+ }
857
+
858
+ function zipStored(entries) {
859
+ if (entries.length > 0xffff) throw new RangeError("ZIP contains too many entries");
860
+ const localParts = [];
861
+ const centralParts = [];
862
+ let offset = 0;
863
+ for (const entry of entries) {
864
+ const name = Buffer.from(entry.name, "utf8");
865
+ const data = Buffer.isBuffer(entry.data)
866
+ ? entry.data
867
+ : Buffer.from(entry.data, "utf8");
868
+ if (name.length > 0xffff || data.length > 0xffffffff) {
869
+ throw new RangeError("ZIP entry is too large");
870
+ }
871
+ const crc = crc32(data);
872
+ const local = Buffer.alloc(30);
873
+ local.writeUInt32LE(0x04034b50, 0);
874
+ local.writeUInt16LE(20, 4);
875
+ local.writeUInt16LE(0x0800, 6);
876
+ local.writeUInt16LE(0, 8);
877
+ local.writeUInt16LE(0, 10);
878
+ local.writeUInt16LE(0x0021, 12);
879
+ local.writeUInt32LE(crc, 14);
880
+ local.writeUInt32LE(data.length, 18);
881
+ local.writeUInt32LE(data.length, 22);
882
+ local.writeUInt16LE(name.length, 26);
883
+ local.writeUInt16LE(0, 28);
884
+ localParts.push(local, name, data);
885
+
886
+ const central = Buffer.alloc(46);
887
+ central.writeUInt32LE(0x02014b50, 0);
888
+ central.writeUInt16LE(20, 4);
889
+ central.writeUInt16LE(20, 6);
890
+ central.writeUInt16LE(0x0800, 8);
891
+ central.writeUInt16LE(0, 10);
892
+ central.writeUInt16LE(0, 12);
893
+ central.writeUInt16LE(0x0021, 14);
894
+ central.writeUInt32LE(crc, 16);
895
+ central.writeUInt32LE(data.length, 20);
896
+ central.writeUInt32LE(data.length, 24);
897
+ central.writeUInt16LE(name.length, 28);
898
+ central.writeUInt16LE(0, 30);
899
+ central.writeUInt16LE(0, 32);
900
+ central.writeUInt16LE(0, 34);
901
+ central.writeUInt16LE(0, 36);
902
+ central.writeUInt32LE(0, 38);
903
+ central.writeUInt32LE(offset, 42);
904
+ centralParts.push(central, name);
905
+ offset += local.length + name.length + data.length;
906
+ }
907
+ const centralDirectory = Buffer.concat(centralParts);
908
+ const eocd = Buffer.alloc(22);
909
+ eocd.writeUInt32LE(0x06054b50, 0);
910
+ eocd.writeUInt16LE(0, 4);
911
+ eocd.writeUInt16LE(0, 6);
912
+ eocd.writeUInt16LE(entries.length, 8);
913
+ eocd.writeUInt16LE(entries.length, 10);
914
+ eocd.writeUInt32LE(centralDirectory.length, 12);
915
+ eocd.writeUInt32LE(offset, 16);
916
+ eocd.writeUInt16LE(0, 20);
917
+ return Buffer.concat([...localParts, centralDirectory, eocd]);
918
+ }
919
+
920
+ function packageEntries(buffer) {
921
+ if (!Buffer.isBuffer(buffer)) {
922
+ throw new TypeError("PowerPoint package must be a Buffer");
923
+ }
924
+ let eocdOffset = -1;
925
+ const minimum = Math.max(0, buffer.length - 65557);
926
+ for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
927
+ if (buffer.readUInt32LE(offset) === 0x06054b50) {
928
+ eocdOffset = offset;
929
+ break;
930
+ }
931
+ }
932
+ if (eocdOffset < 0) throw new Error("Invalid ZIP: EOCD record is missing");
933
+ const count = buffer.readUInt16LE(eocdOffset + 10);
934
+ const centralSize = buffer.readUInt32LE(eocdOffset + 12);
935
+ const centralOffset = buffer.readUInt32LE(eocdOffset + 16);
936
+ const commentLength = buffer.readUInt16LE(eocdOffset + 20);
937
+ if (eocdOffset + 22 + commentLength !== buffer.length) {
938
+ throw new Error("Invalid ZIP: EOCD length is inconsistent");
939
+ }
940
+ if (centralOffset + centralSize !== eocdOffset) {
941
+ throw new Error("Invalid ZIP: central directory is inconsistent");
942
+ }
943
+ const files = new Map();
944
+ let cursor = centralOffset;
945
+ for (let index = 0; index < count; index += 1) {
946
+ if (cursor + 46 > eocdOffset || buffer.readUInt32LE(cursor) !== 0x02014b50) {
947
+ throw new Error("Invalid ZIP: central directory entry is missing");
948
+ }
949
+ const method = buffer.readUInt16LE(cursor + 10);
950
+ const crc = buffer.readUInt32LE(cursor + 16);
951
+ const compressedSize = buffer.readUInt32LE(cursor + 20);
952
+ const size = buffer.readUInt32LE(cursor + 24);
953
+ const nameLength = buffer.readUInt16LE(cursor + 28);
954
+ const extraLength = buffer.readUInt16LE(cursor + 30);
955
+ const entryCommentLength = buffer.readUInt16LE(cursor + 32);
956
+ const localOffset = buffer.readUInt32LE(cursor + 42);
957
+ const name = buffer
958
+ .subarray(cursor + 46, cursor + 46 + nameLength)
959
+ .toString("utf8");
960
+ if (files.has(name)) throw new Error(`Invalid ZIP: duplicate entry "${name}"`);
961
+ if (method !== 0 || compressedSize !== size) {
962
+ throw new Error(`Invalid ZIP: "${name}" is not stored`);
963
+ }
964
+ if (
965
+ localOffset + 30 > centralOffset ||
966
+ buffer.readUInt32LE(localOffset) !== 0x04034b50
967
+ ) {
968
+ throw new Error(`Invalid ZIP: local header for "${name}" is missing`);
969
+ }
970
+ const localNameLength = buffer.readUInt16LE(localOffset + 26);
971
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
972
+ const localName = buffer
973
+ .subarray(localOffset + 30, localOffset + 30 + localNameLength)
974
+ .toString("utf8");
975
+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
976
+ const data = buffer.subarray(dataOffset, dataOffset + size);
977
+ if (localName !== name || data.length !== size || crc32(data) !== crc) {
978
+ throw new Error(`Invalid ZIP: local data for "${name}" is inconsistent`);
979
+ }
980
+ files.set(name, { data, size, crc32: crc });
981
+ cursor += 46 + nameLength + extraLength + entryCommentLength;
982
+ }
983
+ if (cursor !== eocdOffset) {
984
+ throw new Error("Invalid ZIP: central directory size is inconsistent");
985
+ }
986
+ return files;
987
+ }
988
+
989
+ export function buildPptxPackage({ title = "Presentation", slides, assets = [] } = {}) {
990
+ if (typeof title !== "string") fail("title must be a string");
991
+ if (!Array.isArray(slides) || slides.length === 0) {
992
+ fail("slides must be a non-empty array");
993
+ }
994
+ if (slides.length > 0x7ffffeff) fail("slides contains too many items");
995
+ const normalizedAssets = normalizeAssets(assets);
996
+ const builtSlides = slides.map((slide, index) =>
997
+ buildSlide(slide, index, normalizedAssets),
998
+ );
999
+ const entries = [
1000
+ { name: "[Content_Types].xml", data: contentTypesXml(slides.length, normalizedAssets) },
1001
+ {
1002
+ name: "_rels/.rels",
1003
+ data: relationshipsXml([
1004
+ { id: "rId1", type: REL.officeDocument, target: "ppt/presentation.xml" },
1005
+ { id: "rId2", type: REL.core, target: "docProps/core.xml" },
1006
+ { id: "rId3", type: REL.app, target: "docProps/app.xml" },
1007
+ ]),
1008
+ },
1009
+ { name: "docProps/core.xml", data: coreXml(title) },
1010
+ { name: "docProps/app.xml", data: appXml(slides.length) },
1011
+ { name: "ppt/presentation.xml", data: presentationXml(slides.length) },
1012
+ {
1013
+ name: "ppt/_rels/presentation.xml.rels",
1014
+ data: presentationRelsXml(slides.length),
1015
+ },
1016
+ { name: "ppt/theme/theme1.xml", data: themeXml() },
1017
+ { name: "ppt/slideMasters/slideMaster1.xml", data: slideMasterXml() },
1018
+ {
1019
+ name: "ppt/slideMasters/_rels/slideMaster1.xml.rels",
1020
+ data: relationshipsXml([
1021
+ {
1022
+ id: "rId1",
1023
+ type: REL.slideLayout,
1024
+ target: "../slideLayouts/slideLayout1.xml",
1025
+ },
1026
+ { id: "rId2", type: REL.theme, target: "../theme/theme1.xml" },
1027
+ ]),
1028
+ },
1029
+ { name: "ppt/slideLayouts/slideLayout1.xml", data: slideLayoutXml() },
1030
+ {
1031
+ name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels",
1032
+ data: relationshipsXml([
1033
+ {
1034
+ id: "rId1",
1035
+ type: REL.slideMaster,
1036
+ target: "../slideMasters/slideMaster1.xml",
1037
+ },
1038
+ ]),
1039
+ },
1040
+ ...builtSlides.flatMap((slide, index) => [
1041
+ { name: `ppt/slides/slide${index + 1}.xml`, data: slide.xml },
1042
+ {
1043
+ name: `ppt/slides/_rels/slide${index + 1}.xml.rels`,
1044
+ data: slide.rels,
1045
+ },
1046
+ ]),
1047
+ ...[...normalizedAssets.values()].map((asset) => ({
1048
+ name: asset.mediaPath,
1049
+ data: asset.data,
1050
+ })),
1051
+ ];
1052
+ return zipStored(entries);
1053
+ }
1054
+
1055
+ export function inspectPptxPackage(buffer) {
1056
+ const files = packageEntries(buffer);
1057
+ const presentation = files.get("ppt/presentation.xml")?.data.toString("utf8");
1058
+ if (!presentation) throw new Error("Invalid PowerPoint package: presentation.xml is missing");
1059
+ const dimensions = /<p:sldSz\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(
1060
+ presentation,
1061
+ );
1062
+ if (!dimensions) throw new Error("Invalid PowerPoint package: slide dimensions are missing");
1063
+ const slideNames = [...files.keys()]
1064
+ .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
1065
+ .sort((left, right) => {
1066
+ const number = (name) => Number(/\d+/.exec(name)[0]);
1067
+ return number(left) - number(right);
1068
+ });
1069
+ const core = files.get("docProps/core.xml")?.data.toString("utf8") || "";
1070
+ const title = /<dc:title>([\s\S]*?)<\/dc:title>/.exec(core);
1071
+ return {
1072
+ valid: true,
1073
+ byteLength: buffer.length,
1074
+ entries: [...files].map(([name, entry]) => ({
1075
+ name,
1076
+ size: entry.size,
1077
+ crc32: entry.crc32,
1078
+ })),
1079
+ slideCount: slideNames.length,
1080
+ mediaCount: [...files.keys()].filter((name) => name.startsWith("ppt/media/"))
1081
+ .length,
1082
+ dimensions: {
1083
+ widthEmu: Number(dimensions[1]),
1084
+ heightEmu: Number(dimensions[2]),
1085
+ },
1086
+ title: title ? xmlUnescape(title[1]) : "",
1087
+ };
1088
+ }